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.elo import process_elos
12 from xonstat.models import *
13 from xonstat.util import strip_colors, qfont_decode
15 log = logging.getLogger(__name__)
18 def is_blank_game(players):
19 """Determine if this is a blank game or not. A blank game is either:
21 1) a match that ended in the warmup stage, where accuracy events are not
24 2) a match in which no player made a positive or negative score AND was
27 r = re.compile(r'acc-.*-cnt-fired')
28 flg_nonzero_score = False
29 flg_acc_events = False
31 for events in players:
32 if is_real_player(events):
33 for (key,value) in events.items():
34 if key == 'scoreboard-score' and value != '0':
35 flg_nonzero_score = True
39 return not (flg_nonzero_score and flg_acc_events)
41 def get_remote_addr(request):
42 """Get the Xonotic server's IP address"""
43 if 'X-Forwarded-For' in request.headers:
44 return request.headers['X-Forwarded-For']
46 return request.remote_addr
49 def is_supported_gametype(gametype):
50 """Whether a gametype is supported or not"""
53 if gametype == 'cts' or gametype == 'lms':
59 def verify_request(request):
61 (idfp, status) = d0_blind_id_verify(
62 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],
64 postdata=request.body)
66 log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))
74 def num_real_players(player_events, count_bots=False):
76 Returns the number of real players (those who played
77 and are on the scoreboard).
81 for events in player_events:
82 if is_real_player(events, count_bots):
88 def has_minimum_real_players(settings, player_events):
90 Determines if the collection of player events has enough "real" players
91 to store in the database. The minimum setting comes from the config file
92 under the setting xonstat.minimum_real_players.
94 flg_has_min_real_players = True
97 minimum_required_players = int(
98 settings['xonstat.minimum_required_players'])
100 minimum_required_players = 2
102 real_players = num_real_players(player_events)
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(qfont_decode(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(qfont_decode(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()
277 log.debug("Error: game with same server and match_id found! Ignoring.")
279 # if a game under the same server and match_id found,
280 # this is a duplicate game and can be ignored
281 raise pyramid.httpexceptions.HTTPOk('OK')
282 except NoResultFound, e:
283 # server_id/match_id combination not found. game is ok to insert
285 log.debug("Created game id {0} on server {1}, map {2} at \
286 {3}".format(game.game_id,
287 server_id, map_id, start_dt))
292 def get_or_create_player(session=None, hashkey=None, nick=None):
294 Finds a player by hashkey or creates a new one (along with a
295 corresponding hashkey entry. Parameters:
297 session - SQLAlchemy database session factory
298 hashkey - hashkey of the player to be found or created
299 nick - nick of the player (in case of a first time create)
302 if re.search('^bot#\d+$', hashkey) or re.search('^bot#\d+#', hashkey):
303 player = session.query(Player).filter_by(player_id=1).one()
304 # if we have an untracked player
305 elif re.search('^player#\d+$', hashkey):
306 player = session.query(Player).filter_by(player_id=2).one()
307 # else it is a tracked player
309 # see if the player is already in the database
310 # if not, create one and the hashkey along with it
312 hk = session.query(Hashkey).filter_by(
313 hashkey=hashkey).one()
314 player = session.query(Player).filter_by(
315 player_id=hk.player_id).one()
316 log.debug("Found existing player {0} with hashkey {1}".format(
317 player.player_id, hashkey))
323 # if nick is given to us, use it. If not, use "Anonymous Player"
324 # with a suffix added for uniqueness.
326 player.nick = nick[:128]
327 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
329 player.nick = "Anonymous Player #{0}".format(player.player_id)
330 player.stripped_nick = player.nick
332 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
334 log.debug("Created player {0} ({2}) with hashkey {1}".format(
335 player.player_id, hashkey, player.nick.encode('utf-8')))
339 def create_player_game_stat(session=None, player=None,
340 game=None, player_events=None):
342 Creates game statistics for a given player in a given game. Parameters:
344 session - SQLAlchemy session factory
345 player - Player record of the player who owns the stats
346 game - Game record for the game to which the stats pertain
347 player_events - dictionary for the actual stats that need to be transformed
350 # in here setup default values (e.g. if game type is CTF then
351 # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
352 # TODO: use game's create date here instead of now()
353 seq = Sequence('player_game_stats_player_game_stat_id_seq')
354 pgstat_id = session.execute(seq)
355 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
356 create_dt=datetime.datetime.utcnow())
358 # set player id from player record
359 pgstat.player_id = player.player_id
361 #set game id from game record
362 pgstat.game_id = game.game_id
364 # all games have a score and every player has an alivetime
366 pgstat.alivetime = datetime.timedelta(seconds=0)
368 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
372 elif game.game_type_cd == 'ctf':
378 pgstat.carrier_frags = 0
380 for (key,value) in player_events.items():
382 pgstat.nick = value[:128]
383 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
384 if key == 't': pgstat.team = int(value)
385 if key == 'rank': pgstat.rank = int(value)
386 if key == 'alivetime':
387 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
388 if key == 'scoreboard-drops': pgstat.drops = int(value)
389 if key == 'scoreboard-returns': pgstat.returns = int(value)
390 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
391 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
392 if key == 'scoreboard-caps': pgstat.captures = int(value)
393 if key == 'scoreboard-score': pgstat.score = int(value)
394 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
395 if key == 'scoreboard-kills': pgstat.kills = int(value)
396 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
398 # check to see if we had a name, and if
399 # not use an anonymous handle
400 if pgstat.nick == None:
401 pgstat.nick = "Anonymous Player"
402 pgstat.stripped_nick = "Anonymous Player"
404 # otherwise process a nick change
405 elif pgstat.nick != player.nick and player.player_id > 2:
406 register_new_nick(session, player, pgstat.nick)
408 # if the player is ranked #1 and it is a team game, set the game's winner
409 # to be the team of that player
410 # FIXME: this is a hack, should be using the 'W' field (not present)
411 if pgstat.rank == 1 and pgstat.team:
412 game.winner = pgstat.team
420 def create_player_weapon_stats(session=None, player=None,
421 game=None, pgstat=None, player_events=None, game_meta=None):
423 Creates accuracy records for each weapon used by a given player in a
424 given game. Parameters:
426 session - SQLAlchemy session factory object
427 player - Player record who owns the weapon stats
428 game - Game record in which the stats were created
429 pgstat - Corresponding PlayerGameStat record for these weapon stats
430 player_events - dictionary containing the raw weapon values that need to be
432 game_meta - dictionary of game metadata (only used for stats version info)
436 # Version 1 of stats submissions doubled the data sent.
437 # To counteract this we divide the data by 2 only for
438 # POSTs coming from version 1.
440 version = int(game_meta['V'])
443 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
449 for (key,value) in player_events.items():
450 matched = re.search("acc-(.*?)-cnt-fired", key)
452 weapon_cd = matched.group(1)
453 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
454 pwstat_id = session.execute(seq)
455 pwstat = PlayerWeaponStat()
456 pwstat.player_weapon_stats_id = pwstat_id
457 pwstat.player_id = player.player_id
458 pwstat.game_id = game.game_id
459 pwstat.player_game_stat_id = pgstat.player_game_stat_id
460 pwstat.weapon_cd = weapon_cd
462 if 'n' in player_events:
463 pwstat.nick = player_events['n']
465 pwstat.nick = player_events['P']
467 if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
468 pwstat.fired = int(round(float(
469 player_events['acc-' + weapon_cd + '-cnt-fired'])))
470 if 'acc-' + weapon_cd + '-fired' in player_events:
471 pwstat.max = int(round(float(
472 player_events['acc-' + weapon_cd + '-fired'])))
473 if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
474 pwstat.hit = int(round(float(
475 player_events['acc-' + weapon_cd + '-cnt-hit'])))
476 if 'acc-' + weapon_cd + '-hit' in player_events:
477 pwstat.actual = int(round(float(
478 player_events['acc-' + weapon_cd + '-hit'])))
479 if 'acc-' + weapon_cd + '-frags' in player_events:
480 pwstat.frags = int(round(float(
481 player_events['acc-' + weapon_cd + '-frags'])))
484 pwstat.fired = pwstat.fired/2
485 pwstat.max = pwstat.max/2
486 pwstat.hit = pwstat.hit/2
487 pwstat.actual = pwstat.actual/2
488 pwstat.frags = pwstat.frags/2
491 pwstats.append(pwstat)
496 def parse_body(request):
498 Parses the POST request body for a stats submission
500 # storage vars for the request body
506 for line in request.body.split('\n'):
508 (key, value) = line.strip().split(' ', 1)
510 # Server (S) and Nick (n) fields can have international characters.
511 # We convert to UTF-8.
513 value = unicode(value, 'utf-8')
515 if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
516 game_meta[key] = value
519 # if we were working on a player record already, append
520 # it and work on a new one (only set team info)
521 if len(player_events) != 0:
522 players.append(player_events)
525 player_events[key] = value
528 (subkey, subvalue) = value.split(' ', 1)
529 player_events[subkey] = subvalue
531 player_events[key] = value
533 player_events[key] = value
535 # no key/value pair - move on to the next line
538 # add the last player we were working on
539 if len(player_events) > 0:
540 players.append(player_events)
542 return (game_meta, players)
545 def create_player_stats(session=None, player=None, game=None,
546 player_events=None, game_meta=None):
548 Creates player game and weapon stats according to what type of player
550 pgstat = create_player_game_stat(session=session,
551 player=player, game=game, player_events=player_events)
553 #TODO: put this into a config setting in the ini file?
554 if not re.search('^bot#\d+$', player_events['P']):
555 create_player_weapon_stats(session=session,
556 player=player, game=game, pgstat=pgstat,
557 player_events=player_events, game_meta=game_meta)
560 def stats_submit(request):
562 Entry handler for POST stats submissions.
565 # placeholder for the actual session
568 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
569 "----- END REQUEST BODY -----\n\n")
571 (idfp, status) = verify_request(request)
573 log.debug("ERROR: Unverified request")
574 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
576 (game_meta, players) = parse_body(request)
578 if not has_required_metadata(game_meta):
579 log.debug("ERROR: Required game meta missing")
580 raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta")
582 if not is_supported_gametype(game_meta['G']):
583 log.debug("ERROR: Unsupported gametype")
584 raise pyramid.httpexceptions.HTTPOk("OK")
586 if not has_minimum_real_players(request.registry.settings, players):
587 log.debug("ERROR: Not enough real players")
588 raise pyramid.httpexceptions.HTTPOk("OK")
590 if is_blank_game(players):
591 log.debug("ERROR: Blank game")
592 raise pyramid.httpexceptions.HTTPOk("OK")
594 # the "duel" gametype is fake
595 if num_real_players(players, count_bots=True) == 2 and \
596 game_meta['G'] == 'dm':
597 game_meta['G'] = 'duel'
600 # fix for DTG, who didn't #ifdef WATERMARK to set the revision info
602 revision = game_meta['R']
606 #----------------------------------------------------------------------
607 # This ends the "precondition" section of sanity checks. All
608 # functions not requiring a database connection go ABOVE HERE.
609 #----------------------------------------------------------------------
610 session = DBSession()
612 server = get_or_create_server(session=session, hashkey=idfp,
613 name=game_meta['S'], revision=revision,
614 ip_addr=get_remote_addr(request))
616 gmap = get_or_create_map(session=session, name=game_meta['M'])
618 # FIXME: use the gmtime instead of utcnow() when the timezone bug is
620 game = create_game(session=session,
621 start_dt=datetime.datetime.utcnow(),
622 #start_dt=datetime.datetime(
623 #*time.gmtime(float(game_meta['T']))[:6]),
624 server_id=server.server_id, game_type_cd=game_meta['G'],
625 map_id=gmap.map_id, match_id=game_meta['I'])
627 # find or create a record for each player
628 # and add stats for each if they were present at the end
630 for player_events in players:
631 if 'n' in player_events:
632 nick = player_events['n']
636 if 'matches' in player_events and 'scoreboardvalid' \
638 player = get_or_create_player(session=session,
639 hashkey=player_events['P'], nick=nick)
640 log.debug('Creating stats for %s' % player_events['P'])
641 create_player_stats(session=session, player=player, game=game,
642 player_events=player_events, game_meta=game_meta)
646 process_elos(game, session)
647 except Exception as e:
648 log.debug('Error (non-fatal): elo processing failed.')
651 log.debug('Success! Stats recorded.')
652 return Response('200 OK')
653 except Exception as e: