4 import pyramid.httpexceptions
7 from pyramid.response import Response
8 from sqlalchemy import Sequence
9 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
10 from xonstat.d0_blind_id import d0_blind_id_verify
11 from xonstat.models import *
12 from xonstat.util import strip_colors, qfont_decode
14 log = logging.getLogger(__name__)
17 def is_blank_game(players):
18 """Determine if this is a blank game or not. A blank game is either:
20 1) a match that ended in the warmup stage, where accuracy events are not
23 2) a match in which no player made a positive or negative score AND was
26 r = re.compile(r'acc-.*-cnt-fired')
27 flg_nonzero_score = False
28 flg_acc_events = False
30 for events in players:
31 if is_real_player(events):
32 for (key,value) in events.items():
33 if key == 'scoreboard-score' and value != '0':
34 flg_nonzero_score = True
38 return not (flg_nonzero_score and flg_acc_events)
40 def get_remote_addr(request):
41 """Get the Xonotic server's IP address"""
42 if 'X-Forwarded-For' in request.headers:
43 return request.headers['X-Forwarded-For']
45 return request.remote_addr
48 def is_supported_gametype(gametype):
49 """Whether a gametype is supported or not"""
52 if gametype == 'cts' or gametype == 'lms':
58 def verify_request(request):
60 (idfp, status) = d0_blind_id_verify(
61 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],
63 postdata=request.body)
65 log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))
73 def num_real_players(player_events, count_bots=False):
75 Returns the number of real players (those who played
76 and are on the scoreboard).
80 for events in player_events:
81 if is_real_player(events, count_bots):
87 def has_minimum_real_players(settings, player_events):
89 Determines if the collection of player events has enough "real" players
90 to store in the database. The minimum setting comes from the config file
91 under the setting xonstat.minimum_real_players.
93 flg_has_min_real_players = True
96 minimum_required_players = int(
97 settings['xonstat.minimum_required_players'])
99 minimum_required_players = 2
101 real_players = num_real_players(player_events)
103 #TODO: put this into a config setting in the ini file?
104 if real_players < minimum_required_players:
105 flg_has_min_real_players = False
107 return flg_has_min_real_players
110 def has_required_metadata(metadata):
112 Determines if a give set of metadata has enough data to create a game,
113 server, and map with.
115 flg_has_req_metadata = True
117 if 'T' not in metadata or\
118 'G' not in metadata or\
119 'M' not in metadata or\
120 'I' not in metadata or\
122 flg_has_req_metadata = False
124 return flg_has_req_metadata
127 def is_real_player(events, count_bots=False):
129 Determines if a given set of player events correspond with a player who
131 1) is not a bot (P event does not look like a bot)
132 2) played in the game (matches 1)
133 3) was present at the end of the game (scoreboardvalid 1)
135 Returns True if the player meets the above conditions, and false otherwise.
139 # removing 'joins' here due to bug, but it should be here
140 if 'matches' in events and 'scoreboardvalid' in events:
141 if (events['P'].startswith('bot') and count_bots) or \
142 not events['P'].startswith('bot'):
148 def register_new_nick(session, player, new_nick):
150 Change the player record's nick to the newly found nick. Store the old
151 nick in the player_nicks table for that player.
153 session - SQLAlchemy database session factory
154 player - player record whose nick is changing
155 new_nick - the new nickname
157 # see if that nick already exists
158 stripped_nick = strip_colors(player.nick)
160 player_nick = session.query(PlayerNick).filter_by(
161 player_id=player.player_id, stripped_nick=stripped_nick).one()
162 except NoResultFound, e:
163 # player_id/stripped_nick not found, create one
164 # but we don't store "Anonymous Player #N"
165 if not re.search('^Anonymous Player #\d+$', player.nick):
166 player_nick = PlayerNick()
167 player_nick.player_id = player.player_id
168 player_nick.stripped_nick = player.stripped_nick
169 player_nick.nick = player.nick
170 session.add(player_nick)
172 # We change to the new nick regardless
173 player.nick = new_nick
174 player.stripped_nick = strip_colors(new_nick)
178 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
181 Find a server by name or create one if not found. Parameters:
183 session - SQLAlchemy database session factory
184 name - server name of the server to be found or created
185 hashkey - server hashkey
188 # find one by that name, if it exists
189 server = session.query(Server).filter_by(name=name).one()
192 if server.hashkey != hashkey:
193 server.hashkey = hashkey
196 # store new IP address
197 if server.ip_addr != ip_addr:
198 server.ip_addr = ip_addr
202 if server.revision != revision:
203 server.revision = revision
206 log.debug("Found existing server {0}".format(server.server_id))
208 except MultipleResultsFound, e:
209 # multiple found, so also filter by hashkey
210 server = session.query(Server).filter_by(name=name).\
211 filter_by(hashkey=hashkey).one()
212 log.debug("Found existing server {0}".format(server.server_id))
214 except NoResultFound, e:
215 # not found, create one
216 server = Server(name=name, hashkey=hashkey)
219 log.debug("Created server {0} with hashkey {1}".format(
220 server.server_id, server.hashkey))
225 def get_or_create_map(session=None, name=None):
227 Find a map by name or create one if not found. Parameters:
229 session - SQLAlchemy database session factory
230 name - map name of the map to be found or created
233 # find one by the name, if it exists
234 gmap = session.query(Map).filter_by(name=name).one()
235 log.debug("Found map id {0}: {1}".format(gmap.map_id,
237 except NoResultFound, e:
238 gmap = Map(name=name)
241 log.debug("Created map id {0}: {1}".format(gmap.map_id,
243 except MultipleResultsFound, e:
244 # multiple found, so use the first one but warn
246 gmaps = session.query(Map).filter_by(name=name).order_by(
249 log.debug("Found map id {0}: {1} but found \
250 multiple".format(gmap.map_id, gmap.name))
255 def create_game(session=None, start_dt=None, game_type_cd=None,
256 server_id=None, map_id=None, winner=None, match_id=None):
258 Creates a game. Parameters:
260 session - SQLAlchemy database session factory
261 start_dt - when the game started (datetime object)
262 game_type_cd - the game type of the game being played
263 server_id - server identifier of the server hosting the game
264 map_id - map on which the game was played
265 winner - the team id of the team that won
267 seq = Sequence('games_game_id_seq')
268 game_id = session.execute(seq)
269 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
270 server_id=server_id, map_id=map_id, winner=winner)
271 game.match_id = match_id
274 session.query(Game).filter(Game.server_id==server_id).\
275 filter(Game.match_id==match_id).one()
276 # if a game under the same server and match_id found,
277 # this is a duplicate game and can be ignored
278 raise pyramid.httpexceptions.HTTPOk('OK')
279 except NoResultFound, e:
280 # server_id/match_id combination not found. game is ok to insert
282 log.debug("Created game id {0} on server {1}, map {2} at \
283 {3}".format(game.game_id,
284 server_id, map_id, start_dt))
289 def get_or_create_player(session=None, hashkey=None, nick=None):
291 Finds a player by hashkey or creates a new one (along with a
292 corresponding hashkey entry. Parameters:
294 session - SQLAlchemy database session factory
295 hashkey - hashkey of the player to be found or created
296 nick - nick of the player (in case of a first time create)
299 if re.search('^bot#\d+$', hashkey) or re.search('^bot#\d+#', hashkey):
300 player = session.query(Player).filter_by(player_id=1).one()
301 # if we have an untracked player
302 elif re.search('^player#\d+$', hashkey):
303 player = session.query(Player).filter_by(player_id=2).one()
304 # else it is a tracked player
306 # see if the player is already in the database
307 # if not, create one and the hashkey along with it
309 hk = session.query(Hashkey).filter_by(
310 hashkey=hashkey).one()
311 player = session.query(Player).filter_by(
312 player_id=hk.player_id).one()
313 log.debug("Found existing player {0} with hashkey {1}".format(
314 player.player_id, hashkey))
320 # if nick is given to us, use it. If not, use "Anonymous Player"
321 # with a suffix added for uniqueness.
323 player.nick = nick[:128]
324 player.stripped_nick = strip_colors(nick[:128])
326 player.nick = "Anonymous Player #{0}".format(player.player_id)
327 player.stripped_nick = player.nick
329 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
331 log.debug("Created player {0} ({2}) with hashkey {1}".format(
332 player.player_id, hashkey, player.nick.encode('utf-8')))
336 def create_player_game_stat(session=None, player=None,
337 game=None, player_events=None):
339 Creates game statistics for a given player in a given game. Parameters:
341 session - SQLAlchemy session factory
342 player - Player record of the player who owns the stats
343 game - Game record for the game to which the stats pertain
344 player_events - dictionary for the actual stats that need to be transformed
347 # in here setup default values (e.g. if game type is CTF then
348 # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
349 # TODO: use game's create date here instead of now()
350 seq = Sequence('player_game_stats_player_game_stat_id_seq')
351 pgstat_id = session.execute(seq)
352 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
353 create_dt=datetime.datetime.utcnow())
355 # set player id from player record
356 pgstat.player_id = player.player_id
358 #set game id from game record
359 pgstat.game_id = game.game_id
361 # all games have a score
364 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
368 elif game.game_type_cd == 'ctf':
374 pgstat.carrier_frags = 0
376 for (key,value) in player_events.items():
377 if key == 'n': pgstat.nick = value[:128]
378 if key == 't': pgstat.team = value
379 if key == 'rank': pgstat.rank = value
380 if key == 'alivetime':
381 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
382 if key == 'scoreboard-drops': pgstat.drops = value
383 if key == 'scoreboard-returns': pgstat.returns = value
384 if key == 'scoreboard-fckills': pgstat.carrier_frags = value
385 if key == 'scoreboard-pickups': pgstat.pickups = value
386 if key == 'scoreboard-caps': pgstat.captures = value
387 if key == 'scoreboard-score': pgstat.score = value
388 if key == 'scoreboard-deaths': pgstat.deaths = value
389 if key == 'scoreboard-kills': pgstat.kills = value
390 if key == 'scoreboard-suicides': pgstat.suicides = value
392 # check to see if we had a name, and if
393 # not use an anonymous handle
394 if pgstat.nick == None:
395 pgstat.nick = "Anonymous Player"
396 pgstat.stripped_nick = "Anonymous Player"
398 # otherwise process a nick change
399 elif pgstat.nick != player.nick and player.player_id > 2:
400 register_new_nick(session, player, pgstat.nick)
402 # if the player is ranked #1 and it is a team game, set the game's winner
403 # to be the team of that player
404 # FIXME: this is a hack, should be using the 'W' field (not present)
405 if pgstat.rank == '1' and pgstat.team:
406 game.winner = pgstat.team
414 def create_player_weapon_stats(session=None, player=None,
415 game=None, pgstat=None, player_events=None):
417 Creates accuracy records for each weapon used by a given player in a
418 given game. Parameters:
420 session - SQLAlchemy session factory object
421 player - Player record who owns the weapon stats
422 game - Game record in which the stats were created
423 pgstat - Corresponding PlayerGameStat record for these weapon stats
424 player_events - dictionary containing the raw weapon values that need to be
429 for (key,value) in player_events.items():
430 matched = re.search("acc-(.*?)-cnt-fired", key)
432 weapon_cd = matched.group(1)
433 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
434 pwstat_id = session.execute(seq)
435 pwstat = PlayerWeaponStat()
436 pwstat.player_weapon_stats_id = pwstat_id
437 pwstat.player_id = player.player_id
438 pwstat.game_id = game.game_id
439 pwstat.player_game_stat_id = pgstat.player_game_stat_id
440 pwstat.weapon_cd = weapon_cd
442 if 'n' in player_events:
443 pwstat.nick = player_events['n']
445 pwstat.nick = player_events['P']
447 if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
448 pwstat.fired = int(round(float(
449 player_events['acc-' + weapon_cd + '-cnt-fired'])))
450 if 'acc-' + weapon_cd + '-fired' in player_events:
451 pwstat.max = int(round(float(
452 player_events['acc-' + weapon_cd + '-fired'])))
453 if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
454 pwstat.hit = int(round(float(
455 player_events['acc-' + weapon_cd + '-cnt-hit'])))
456 if 'acc-' + weapon_cd + '-hit' in player_events:
457 pwstat.actual = int(round(float(
458 player_events['acc-' + weapon_cd + '-hit'])))
459 if 'acc-' + weapon_cd + '-frags' in player_events:
460 pwstat.frags = int(round(float(
461 player_events['acc-' + weapon_cd + '-frags'])))
464 pwstats.append(pwstat)
469 def parse_body(request):
471 Parses the POST request body for a stats submission
473 # storage vars for the request body
479 for line in request.body.split('\n'):
481 (key, value) = line.strip().split(' ', 1)
483 # Server (S) and Nick (n) fields can have international characters.
484 # We convert to UTF-8.
486 value = unicode(value, 'utf-8')
488 if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
489 game_meta[key] = value
492 # if we were working on a player record already, append
493 # it and work on a new one (only set team info)
494 if len(player_events) != 0:
495 players.append(player_events)
498 player_events[key] = value
501 (subkey, subvalue) = value.split(' ', 1)
502 player_events[subkey] = subvalue
504 player_events[key] = value
506 player_events[key] = value
508 # no key/value pair - move on to the next line
511 # add the last player we were working on
512 if len(player_events) > 0:
513 players.append(player_events)
515 return (game_meta, players)
518 def create_player_stats(session=None, player=None, game=None,
521 Creates player game and weapon stats according to what type of player
523 pgstat = create_player_game_stat(session=session,
524 player=player, game=game, player_events=player_events)
526 #TODO: put this into a config setting in the ini file?
527 if not re.search('^bot#\d+$', player_events['P']):
528 create_player_weapon_stats(session=session,
529 player=player, game=game, pgstat=pgstat,
530 player_events=player_events)
533 def stats_submit(request):
535 Entry handler for POST stats submissions.
538 session = DBSession()
540 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
541 "----- END REQUEST BODY -----\n\n")
543 (idfp, status) = verify_request(request)
545 log.debug("ERROR: Unverified request")
546 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
548 (game_meta, players) = parse_body(request)
550 if not has_required_metadata(game_meta):
551 log.debug("ERROR: Required game meta missing")
552 raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta")
554 if not is_supported_gametype(game_meta['G']):
555 log.debug("ERROR: Unsupported gametype")
556 raise pyramid.httpexceptions.HTTPOk("OK")
558 if not has_minimum_real_players(request.registry.settings, players):
559 log.debug("ERROR: Not enough real players")
560 raise pyramid.httpexceptions.HTTPOk("OK")
562 if is_blank_game(players):
563 log.debug("ERROR: Blank game")
564 raise pyramid.httpexceptions.HTTPOk("OK")
566 # the "duel" gametype is fake
567 if num_real_players(players, count_bots=True) == 2 and \
568 game_meta['G'] == 'dm':
569 game_meta['G'] = 'duel'
572 # fix for DTG, who didn't #ifdef WATERMARK to set the revision info
574 revision = game_meta['R']
578 server = get_or_create_server(session=session, hashkey=idfp,
579 name=game_meta['S'], revision=revision,
580 ip_addr=get_remote_addr(request))
582 gmap = get_or_create_map(session=session, name=game_meta['M'])
584 # FIXME: use the gmtime instead of utcnow() when the timezone bug is
586 game = create_game(session=session,
587 start_dt=datetime.datetime.utcnow(),
588 #start_dt=datetime.datetime(
589 #*time.gmtime(float(game_meta['T']))[:6]),
590 server_id=server.server_id, game_type_cd=game_meta['G'],
591 map_id=gmap.map_id, match_id=game_meta['I'])
593 # find or create a record for each player
594 # and add stats for each if they were present at the end
596 for player_events in players:
597 if 'n' in player_events:
598 nick = player_events['n']
602 if 'matches' in player_events and 'scoreboardvalid' \
604 player = get_or_create_player(session=session,
605 hashkey=player_events['P'], nick=nick)
606 log.debug('Creating stats for %s' % player_events['P'])
607 create_player_stats(session=session, player=player, game=game,
608 player_events=player_events)
612 game.process_elos(session)
613 except Exception as e:
614 log.debug('Error (non-fatal): elo processing failed.')
617 log.debug('Success! Stats recorded.')
618 return Response('200 OK')
619 except Exception as e: