7 import pyramid.httpexceptions
8 from sqlalchemy import Sequence
9 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
10 from xonstat.elo import EloProcessor
11 from xonstat.models import DBSession, Server, Map, Game, PlayerGameStat, PlayerWeaponStat
12 from xonstat.models import PlayerRank, PlayerCaptime
13 from xonstat.models import TeamGameStat, PlayerGameAnticheat, Player, Hashkey, PlayerNick
14 from xonstat.util import strip_colors, qfont_decode, verify_request, weapon_map
16 log = logging.getLogger(__name__)
19 def is_real_player(events):
21 Determines if a given set of events correspond with a non-bot
23 if not events['P'].startswith('bot'):
29 def played_in_game(events):
31 Determines if a given set of player events correspond with a player who
32 played in the game (matches 1 and scoreboardvalid 1)
34 if 'matches' in events and 'scoreboardvalid' in events:
40 class Submission(object):
41 """Parses an incoming POST request for stats submissions."""
43 def __init__(self, body, headers):
44 # a copy of the HTTP headers
45 self.headers = headers
47 # a copy of the HTTP POST body
50 # the submission code version (from the server)
53 # the revision string of the server
56 # the game type played
57 self.game_type_cd = None
62 # the name of the map played
65 # unique identifier (string) for a match on a given server
68 # the name of the server
69 self.server_name = None
71 # the number of cvars that were changed to be different than default
72 self.impure_cvar_changes = None
74 # the port number the game server is listening on
75 self.port_number = None
77 # how long the game lasted
80 # which ladder is being used, if any
83 # players involved in the match (humans, bots, and spectators)
89 # the parsing deque (we use this to allow peeking)
90 self.q = collections.deque(self.body.split("\n"))
92 ############################################################################################
93 # Below this point are fields useful in determining if the submission is valid or
94 # performance optimizations that save us from looping over the events over and over again.
95 ############################################################################################
97 # humans who played in the match
100 # bots who played in the match
103 # distinct weapons that we have seen fired
106 # has a human player fired a shot?
107 self.human_fired_weapon = False
109 # does any human have a non-zero score?
110 self.human_nonzero_score = False
112 # does any human have a fastest cap?
113 self.human_fastest = False
116 """Returns the next key:value pair off the queue."""
118 items = self.q.popleft().strip().split(' ', 1)
120 # Some keys won't have values, like 'L' records where the server isn't actually
121 # participating in any ladders. These can be safely ignored.
128 def check_for_new_weapon_fired(self, sub_key):
129 """Checks if a given weapon fired event is a new one for the match."""
130 weapon = sub_key.split("-")[1]
131 if weapon not in self.weapons:
132 self.weapons.add(weapon)
134 def parse_player(self, key, pid):
135 """Construct a player events listing from the submission."""
137 # all of the keys related to player records
138 player_keys = ['i', 'n', 't', 'e']
142 player_fired_weapon = False
143 player_nonzero_score = False
144 player_fastest = False
146 # Consume all following 'i' 'n' 't' 'e' records
147 while len(self.q) > 0:
148 (key, value) = self.next_item()
149 if key is None and value is None:
152 (sub_key, sub_value) = value.split(' ', 1)
153 player[sub_key] = sub_value
155 if sub_key.endswith("cnt-fired"):
156 player_fired_weapon = True
157 self.check_for_new_weapon_fired(sub_key)
158 elif sub_key == 'scoreboard-score' and int(value) != 0:
159 player_nonzero_score = True
160 elif sub_key == 'scoreboard-fastest':
161 player_fastest = True
163 player[key] = unicode(value, 'utf-8')
164 elif key in player_keys:
167 # something we didn't expect - put it back on the deque
168 self.q.appendleft("{} {}".format(key, value))
171 played = played_in_game(player)
172 human = is_real_player(player)
175 self.humans.append(player)
177 if player_fired_weapon:
178 self.human_fired_weapon = True
180 if player_nonzero_score:
181 self.human_nonzero_score = True
184 self.human_fastest = True
186 elif played and not human:
187 self.bots.append(player)
189 self.players.append(player)
191 def parse_team(self, key, tid):
192 """Construct a team events listing from the submission."""
195 # Consume all following 'e' records
196 while len(self.q) > 0 and self.q[0].startswith('e'):
197 (_, value) = self.next_item()
198 (sub_key, sub_value) = value.split(' ', 1)
199 team[sub_key] = sub_value
201 self.teams.append(team)
204 """Parses the request body into instance variables."""
205 while len(self.q) > 0:
206 (key, value) = self.next_item()
207 if key is None and value is None:
212 self.revision = value
214 self.game_type_cd = value
218 self.map_name = value
220 self.match_id = value
222 self.server_name = unicode(value, 'utf-8')
224 self.impure_cvar_changes = int(value)
226 self.port_number = int(value)
228 self.duration = datetime.timedelta(seconds=int(round(float(value))))
232 self.parse_team(key, value)
234 self.parse_player(key, value)
236 raise Exception("Invalid submission")
241 def elo_submission_category(submission):
242 """Determines the Elo category purely by what is in the submission data."""
243 mod = submission.meta.get("O", "None")
245 vanilla_allowed_weapons = {"shotgun", "devastator", "blaster", "mortar", "vortex", "electro",
246 "arc", "hagar", "crylink", "machinegun"}
247 insta_allowed_weapons = {"vaporizer", "blaster"}
248 overkill_allowed_weapons = {"hmg", "vortex", "shotgun", "blaster", "machinegun", "rpc"}
251 if len(submission.weapons - vanilla_allowed_weapons) == 0:
253 elif mod == "InstaGib":
254 if len(submission.weapons - insta_allowed_weapons) == 0:
256 elif mod == "Overkill":
257 if len(submission.weapons - overkill_allowed_weapons) == 0:
265 def parse_stats_submission(body):
267 Parses the POST request body for a stats submission
269 # storage vars for the request body
275 # we're not in either stanza to start
278 for line in body.split('\n'):
280 (key, value) = line.strip().split(' ', 1)
282 # Server (S) and Nick (n) fields can have international characters.
284 value = unicode(value, 'utf-8')
286 if key not in 'P' 'Q' 'n' 'e' 't' 'i':
287 game_meta[key] = value
289 if key == 'Q' or key == 'P':
290 #log.debug('Found a {0}'.format(key))
291 #log.debug('in_Q: {0}'.format(in_Q))
292 #log.debug('in_P: {0}'.format(in_P))
293 #log.debug('events: {0}'.format(events))
295 # check where we were before and append events accordingly
296 if in_Q and len(events) > 0:
297 #log.debug('creating a team (Q) entry')
300 elif in_P and len(events) > 0:
301 #log.debug('creating a player (P) entry')
302 players.append(events)
306 #log.debug('key == P')
310 #log.debug('key == Q')
317 (subkey, subvalue) = value.split(' ', 1)
318 events[subkey] = subvalue
324 # no key/value pair - move on to the next line
327 # add the last entity we were working on
328 if in_P and len(events) > 0:
329 players.append(events)
330 elif in_Q and len(events) > 0:
333 return (game_meta, players, teams)
336 def is_blank_game(gametype, players):
337 """Determine if this is a blank game or not. A blank game is either:
339 1) a match that ended in the warmup stage, where accuracy events are not
340 present (for non-CTS games)
342 2) a match in which no player made a positive or negative score AND was
345 ... or for CTS, which doesn't record accuracy events
347 1) a match in which no player made a fastest lap AND was
350 ... or for NB, in which not all maps have weapons
352 1) a match in which no player made a positive or negative score
354 r = re.compile(r'acc-.*-cnt-fired')
355 flg_nonzero_score = False
356 flg_acc_events = False
357 flg_fastest_lap = False
359 for events in players:
360 if is_real_player(events) and played_in_game(events):
361 for (key,value) in events.items():
362 if key == 'scoreboard-score' and value != 0:
363 flg_nonzero_score = True
365 flg_acc_events = True
366 if key == 'scoreboard-fastest':
367 flg_fastest_lap = True
369 if gametype == 'cts':
370 return not flg_fastest_lap
371 elif gametype == 'nb':
372 return not flg_nonzero_score
374 return not (flg_nonzero_score and flg_acc_events)
377 def get_remote_addr(request):
378 """Get the Xonotic server's IP address"""
379 if 'X-Forwarded-For' in request.headers:
380 return request.headers['X-Forwarded-For']
382 return request.remote_addr
385 def is_supported_gametype(gametype, version):
386 """Whether a gametype is supported or not"""
389 # if the type can be supported, but with version constraints, uncomment
390 # here and add the restriction for a specific version below
391 supported_game_types = (
410 if gametype in supported_game_types:
415 # some game types were buggy before revisions, thus this additional filter
416 if gametype == 'ca' and version <= 5:
422 def do_precondition_checks(request, game_meta, raw_players):
423 """Precondition checks for ALL gametypes.
424 These do not require a database connection."""
425 if not has_required_metadata(game_meta):
426 msg = "Missing required game metadata"
428 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
430 content_type="text/plain"
434 version = int(game_meta['V'])
436 msg = "Invalid or incorrect game metadata provided"
438 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
440 content_type="text/plain"
443 if not is_supported_gametype(game_meta['G'], version):
444 msg = "Unsupported game type ({})".format(game_meta['G'])
446 raise pyramid.httpexceptions.HTTPOk(
448 content_type="text/plain"
451 if not has_minimum_real_players(request.registry.settings, raw_players):
452 msg = "Not enough real players"
454 raise pyramid.httpexceptions.HTTPOk(
456 content_type="text/plain"
459 if is_blank_game(game_meta['G'], raw_players):
462 raise pyramid.httpexceptions.HTTPOk(
464 content_type="text/plain"
468 def num_real_players(player_events):
470 Returns the number of real players (those who played
471 and are on the scoreboard).
475 for events in player_events:
476 if is_real_player(events) and played_in_game(events):
482 def has_minimum_real_players(settings, player_events):
484 Determines if the collection of player events has enough "real" players
485 to store in the database. The minimum setting comes from the config file
486 under the setting xonstat.minimum_real_players.
488 flg_has_min_real_players = True
491 minimum_required_players = int(
492 settings['xonstat.minimum_required_players'])
494 minimum_required_players = 2
496 real_players = num_real_players(player_events)
498 if real_players < minimum_required_players:
499 flg_has_min_real_players = False
501 return flg_has_min_real_players
504 def has_required_metadata(metadata):
506 Determines if a give set of metadata has enough data to create a game,
507 server, and map with.
509 flg_has_req_metadata = True
511 if 'G' not in metadata or\
512 'M' not in metadata or\
513 'I' not in metadata or\
515 flg_has_req_metadata = False
517 return flg_has_req_metadata
520 def should_do_weapon_stats(game_type_cd):
521 """True of the game type should record weapon stats. False otherwise."""
522 if game_type_cd in 'cts':
528 def gametype_elo_eligible(game_type_cd):
529 """True of the game type should process Elos. False otherwise."""
530 elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft')
532 if game_type_cd in elo_game_types:
538 def register_new_nick(session, player, new_nick):
540 Change the player record's nick to the newly found nick. Store the old
541 nick in the player_nicks table for that player.
543 session - SQLAlchemy database session factory
544 player - player record whose nick is changing
545 new_nick - the new nickname
547 # see if that nick already exists
548 stripped_nick = strip_colors(qfont_decode(player.nick))
550 player_nick = session.query(PlayerNick).filter_by(
551 player_id=player.player_id, stripped_nick=stripped_nick).one()
552 except NoResultFound, e:
553 # player_id/stripped_nick not found, create one
554 # but we don't store "Anonymous Player #N"
555 if not re.search('^Anonymous Player #\d+$', player.nick):
556 player_nick = PlayerNick()
557 player_nick.player_id = player.player_id
558 player_nick.stripped_nick = stripped_nick
559 player_nick.nick = player.nick
560 session.add(player_nick)
562 # We change to the new nick regardless
563 player.nick = new_nick
564 player.stripped_nick = strip_colors(qfont_decode(new_nick))
568 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
570 Check the fastest cap time for the player and map. If there isn't
571 one, insert one. If there is, check if the passed time is faster.
574 # we don't record fastest cap times for bots or anonymous players
578 # see if a cap entry exists already
579 # then check to see if the new captime is faster
581 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
582 player_id=player_id, map_id=map_id, mod=mod).one()
584 # current captime is faster, so update
585 if captime < cur_fastest_cap.fastest_cap:
586 cur_fastest_cap.fastest_cap = captime
587 cur_fastest_cap.game_id = game_id
588 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
589 session.add(cur_fastest_cap)
591 except NoResultFound, e:
592 # none exists, so insert
593 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
595 session.add(cur_fastest_cap)
599 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
601 Updates the server in the given DB session, if needed.
603 :param server: The found server instance.
604 :param name: The incoming server name.
605 :param hashkey: The incoming server hashkey.
606 :param ip_addr: The incoming server IP address.
607 :param port: The incoming server port.
608 :param revision: The incoming server revision.
609 :param impure_cvars: The incoming number of impure server cvars.
612 # ensure the two int attributes are actually ints
619 impure_cvars = int(impure_cvars)
624 if name and server.name != name:
627 if hashkey and server.hashkey != hashkey:
628 server.hashkey = hashkey
630 if ip_addr and server.ip_addr != ip_addr:
631 server.ip_addr = ip_addr
633 if port and server.port != port:
636 if revision and server.revision != revision:
637 server.revision = revision
639 if impure_cvars and server.impure_cvars != impure_cvars:
640 server.impure_cvars = impure_cvars
641 server.pure_ind = True if impure_cvars == 0 else False
647 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
649 Find a server by name or create one if not found. Parameters:
651 session - SQLAlchemy database session factory
652 name - server name of the server to be found or created
653 hashkey - server hashkey
654 ip_addr - the IP address of the server
655 revision - the xonotic revision number
656 port - the port number of the server
657 impure_cvars - the number of impure cvar changes
659 servers_q = DBSession.query(Server).filter(Server.active_ind)
662 # if the hashkey is provided, we'll use that
663 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
665 # otherwise, it is just by name
666 servers_q = servers_q.filter(Server.name == name)
668 # order by the hashkey, which means any hashkey match will appear first if there are multiple
669 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
671 if len(servers) == 0:
672 server = Server(name=name, hashkey=hashkey)
675 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
678 if len(servers) == 1:
679 log.info("Found existing server {}.".format(server.server_id))
681 elif len(servers) > 1:
682 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
683 log.warn("Multiple servers found ({})! Using the first one ({})."
684 .format(server_id_list, server.server_id))
686 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
692 def get_or_create_map(session=None, name=None):
694 Find a map by name or create one if not found. Parameters:
696 session - SQLAlchemy database session factory
697 name - map name of the map to be found or created
700 # find one by the name, if it exists
701 gmap = session.query(Map).filter_by(name=name).one()
702 log.debug("Found map id {0}: {1}".format(gmap.map_id,
704 except NoResultFound, e:
705 gmap = Map(name=name)
708 log.debug("Created map id {0}: {1}".format(gmap.map_id,
710 except MultipleResultsFound, e:
711 # multiple found, so use the first one but warn
713 gmaps = session.query(Map).filter_by(name=name).order_by(
716 log.debug("Found map id {0}: {1} but found \
717 multiple".format(gmap.map_id, gmap.name))
722 def create_game(session, start_dt, game_type_cd, server_id, map_id,
723 match_id, duration, mod, winner=None):
725 Creates a game. Parameters:
727 session - SQLAlchemy database session factory
728 start_dt - when the game started (datetime object)
729 game_type_cd - the game type of the game being played
730 server_id - server identifier of the server hosting the game
731 map_id - map on which the game was played
732 winner - the team id of the team that won
733 duration - how long the game lasted
734 mod - mods in use during the game
736 seq = Sequence('games_game_id_seq')
737 game_id = session.execute(seq)
738 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
739 server_id=server_id, map_id=map_id, winner=winner)
740 game.match_id = match_id
743 # There is some drift between start_dt (provided by app) and create_dt
744 # (default in the database), so we'll make them the same until this is
746 game.create_dt = start_dt
749 game.duration = datetime.timedelta(seconds=int(round(float(duration))))
754 session.query(Game).filter(Game.server_id==server_id).\
755 filter(Game.match_id==match_id).one()
757 log.debug("Error: game with same server and match_id found! Ignoring.")
759 # if a game under the same server and match_id found,
760 # this is a duplicate game and can be ignored
761 raise pyramid.httpexceptions.HTTPOk('OK')
762 except NoResultFound, e:
763 # server_id/match_id combination not found. game is ok to insert
766 log.debug("Created game id {0} on server {1}, map {2} at \
767 {3}".format(game.game_id,
768 server_id, map_id, start_dt))
773 def get_or_create_player(session=None, hashkey=None, nick=None):
775 Finds a player by hashkey or creates a new one (along with a
776 corresponding hashkey entry. Parameters:
778 session - SQLAlchemy database session factory
779 hashkey - hashkey of the player to be found or created
780 nick - nick of the player (in case of a first time create)
783 if re.search('^bot#\d+', hashkey):
784 player = session.query(Player).filter_by(player_id=1).one()
785 # if we have an untracked player
786 elif re.search('^player#\d+$', hashkey):
787 player = session.query(Player).filter_by(player_id=2).one()
788 # else it is a tracked player
790 # see if the player is already in the database
791 # if not, create one and the hashkey along with it
793 hk = session.query(Hashkey).filter_by(
794 hashkey=hashkey).one()
795 player = session.query(Player).filter_by(
796 player_id=hk.player_id).one()
797 log.debug("Found existing player {0} with hashkey {1}".format(
798 player.player_id, hashkey))
804 # if nick is given to us, use it. If not, use "Anonymous Player"
805 # with a suffix added for uniqueness.
807 player.nick = nick[:128]
808 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
810 player.nick = "Anonymous Player #{0}".format(player.player_id)
811 player.stripped_nick = player.nick
813 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
815 log.debug("Created player {0} ({2}) with hashkey {1}".format(
816 player.player_id, hashkey, player.nick.encode('utf-8')))
821 def create_default_game_stat(session, game_type_cd):
822 """Creates a blanked-out pgstat record for the given game type"""
824 # this is what we have to do to get partitioned records in - grab the
825 # sequence value first, then insert using the explicit ID (vs autogenerate)
826 seq = Sequence('player_game_stats_player_game_stat_id_seq')
827 pgstat_id = session.execute(seq)
828 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
829 create_dt=datetime.datetime.utcnow())
831 if game_type_cd == 'as':
832 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
834 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
835 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
837 if game_type_cd == 'cq':
838 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
841 if game_type_cd == 'ctf':
842 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
843 pgstat.returns = pgstat.carrier_frags = 0
845 if game_type_cd == 'cts':
848 if game_type_cd == 'dom':
849 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
852 if game_type_cd == 'ft':
853 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
855 if game_type_cd == 'ka':
856 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
857 pgstat.carrier_frags = 0
858 pgstat.time = datetime.timedelta(seconds=0)
860 if game_type_cd == 'kh':
861 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
862 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
863 pgstat.carrier_frags = 0
865 if game_type_cd == 'lms':
866 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
868 if game_type_cd == 'nb':
869 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
872 if game_type_cd == 'rc':
873 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
878 def create_game_stat(session, game_meta, game, server, gmap, player, events):
879 """Game stats handler for all game types"""
881 game_type_cd = game.game_type_cd
883 pgstat = create_default_game_stat(session, game_type_cd)
885 # these fields should be on every pgstat record
886 pgstat.game_id = game.game_id
887 pgstat.player_id = player.player_id
888 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
889 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
890 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
891 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
892 pgstat.rank = int(events.get('rank', None))
893 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
895 if pgstat.nick != player.nick \
896 and player.player_id > 2 \
897 and pgstat.nick != 'Anonymous Player':
898 register_new_nick(session, player, pgstat.nick)
902 # gametype-specific stuff is handled here. if passed to us, we store it
903 for (key,value) in events.items():
904 if key == 'wins': wins = True
905 if key == 't': pgstat.team = int(value)
907 if key == 'scoreboard-drops': pgstat.drops = int(value)
908 if key == 'scoreboard-returns': pgstat.returns = int(value)
909 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
910 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
911 if key == 'scoreboard-caps': pgstat.captures = int(value)
912 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
913 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
914 if key == 'scoreboard-kills': pgstat.kills = int(value)
915 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
916 if key == 'scoreboard-objectives': pgstat.collects = int(value)
917 if key == 'scoreboard-captured': pgstat.captures = int(value)
918 if key == 'scoreboard-released': pgstat.drops = int(value)
919 if key == 'scoreboard-fastest':
920 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
921 if key == 'scoreboard-takes': pgstat.pickups = int(value)
922 if key == 'scoreboard-ticks': pgstat.drops = int(value)
923 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
924 if key == 'scoreboard-bctime':
925 pgstat.time = datetime.timedelta(seconds=int(value))
926 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
927 if key == 'scoreboard-losses': pgstat.drops = int(value)
928 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
929 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
930 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
931 if key == 'scoreboard-lives': pgstat.lives = int(value)
932 if key == 'scoreboard-goals': pgstat.captures = int(value)
933 if key == 'scoreboard-faults': pgstat.drops = int(value)
934 if key == 'scoreboard-laps': pgstat.laps = int(value)
936 if key == 'avglatency': pgstat.avg_latency = float(value)
937 if key == 'scoreboard-captime':
938 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
939 if game.game_type_cd == 'ctf':
940 update_fastest_cap(session, player.player_id, game.game_id,
941 gmap.map_id, pgstat.fastest, game.mod)
943 # there is no "winning team" field, so we have to derive it
944 if wins and pgstat.team is not None and game.winner is None:
945 game.winner = pgstat.team
953 def create_anticheats(session, pgstat, game, player, events):
954 """Anticheats handler for all game types"""
958 # all anticheat events are prefixed by "anticheat"
959 for (key,value) in events.items():
960 if key.startswith("anticheat"):
962 ac = PlayerGameAnticheat(
968 anticheats.append(ac)
970 except Exception as e:
971 log.debug("Could not parse value for key %s. Ignoring." % key)
976 def create_default_team_stat(session, game_type_cd):
977 """Creates a blanked-out teamstat record for the given game type"""
979 # this is what we have to do to get partitioned records in - grab the
980 # sequence value first, then insert using the explicit ID (vs autogenerate)
981 seq = Sequence('team_game_stats_team_game_stat_id_seq')
982 teamstat_id = session.execute(seq)
983 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
984 create_dt=datetime.datetime.utcnow())
986 # all team game modes have a score, so we'll zero that out always
989 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
992 if game_type_cd == 'ctf':
998 def create_team_stat(session, game, events):
999 """Team stats handler for all game types"""
1002 teamstat = create_default_team_stat(session, game.game_type_cd)
1003 teamstat.game_id = game.game_id
1005 # we should have a team ID if we have a 'Q' event
1006 if re.match(r'^team#\d+$', events.get('Q', '')):
1007 team = int(events.get('Q').replace('team#', ''))
1008 teamstat.team = team
1010 # gametype-specific stuff is handled here. if passed to us, we store it
1011 for (key,value) in events.items():
1012 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
1013 if key == 'scoreboard-caps': teamstat.caps = int(value)
1014 if key == 'scoreboard-goals': teamstat.caps = int(value)
1015 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
1017 session.add(teamstat)
1018 except Exception as e:
1024 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
1025 """Weapon stats handler for all game types"""
1028 # Version 1 of stats submissions doubled the data sent.
1029 # To counteract this we divide the data by 2 only for
1030 # POSTs coming from version 1.
1032 version = int(game_meta['V'])
1035 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
1041 for (key,value) in events.items():
1042 matched = re.search("acc-(.*?)-cnt-fired", key)
1044 weapon_cd = matched.group(1)
1046 # Weapon names changed for 0.8. We'll convert the old
1047 # ones to use the new scheme as well.
1048 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
1050 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
1051 pwstat_id = session.execute(seq)
1052 pwstat = PlayerWeaponStat()
1053 pwstat.player_weapon_stats_id = pwstat_id
1054 pwstat.player_id = player.player_id
1055 pwstat.game_id = game.game_id
1056 pwstat.player_game_stat_id = pgstat.player_game_stat_id
1057 pwstat.weapon_cd = mapped_weapon_cd
1060 pwstat.nick = events['n']
1062 pwstat.nick = events['P']
1064 if 'acc-' + weapon_cd + '-cnt-fired' in events:
1065 pwstat.fired = int(round(float(
1066 events['acc-' + weapon_cd + '-cnt-fired'])))
1067 if 'acc-' + weapon_cd + '-fired' in events:
1068 pwstat.max = int(round(float(
1069 events['acc-' + weapon_cd + '-fired'])))
1070 if 'acc-' + weapon_cd + '-cnt-hit' in events:
1071 pwstat.hit = int(round(float(
1072 events['acc-' + weapon_cd + '-cnt-hit'])))
1073 if 'acc-' + weapon_cd + '-hit' in events:
1074 pwstat.actual = int(round(float(
1075 events['acc-' + weapon_cd + '-hit'])))
1076 if 'acc-' + weapon_cd + '-frags' in events:
1077 pwstat.frags = int(round(float(
1078 events['acc-' + weapon_cd + '-frags'])))
1081 pwstat.fired = pwstat.fired/2
1082 pwstat.max = pwstat.max/2
1083 pwstat.hit = pwstat.hit/2
1084 pwstat.actual = pwstat.actual/2
1085 pwstat.frags = pwstat.frags/2
1088 pwstats.append(pwstat)
1093 def get_ranks(session, player_ids, game_type_cd):
1095 Gets the rank entries for all players in the given list, returning a dict
1096 of player_id -> PlayerRank instance. The rank entry corresponds to the
1097 game type of the parameter passed in as well.
1100 for pr in session.query(PlayerRank).\
1101 filter(PlayerRank.player_id.in_(player_ids)).\
1102 filter(PlayerRank.game_type_cd == game_type_cd).\
1104 ranks[pr.player_id] = pr
1109 def submit_stats(request):
1111 Entry handler for POST stats submissions.
1114 # placeholder for the actual session
1117 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1118 "----- END REQUEST BODY -----\n\n")
1120 (idfp, status) = verify_request(request)
1121 (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
1122 revision = game_meta.get('R', 'unknown')
1123 duration = game_meta.get('D', None)
1125 # only players present at the end of the match are eligible for stats
1126 raw_players = filter(played_in_game, raw_players)
1128 do_precondition_checks(request, game_meta, raw_players)
1130 # the "duel" gametype is fake
1131 if len(raw_players) == 2 \
1132 and num_real_players(raw_players) == 2 \
1133 and game_meta['G'] == 'dm':
1134 game_meta['G'] = 'duel'
1136 #----------------------------------------------------------------------
1137 # Actual setup (inserts/updates) below here
1138 #----------------------------------------------------------------------
1139 session = DBSession()
1141 game_type_cd = game_meta['G']
1143 # All game types create Game, Server, Map, and Player records
1145 server = get_or_create_server(
1148 name = game_meta['S'],
1149 revision = revision,
1150 ip_addr = get_remote_addr(request),
1151 port = game_meta.get('U', None),
1152 impure_cvars = game_meta.get('C', 0))
1154 gmap = get_or_create_map(
1156 name = game_meta['M'])
1160 start_dt = datetime.datetime.utcnow(),
1161 server_id = server.server_id,
1162 game_type_cd = game_type_cd,
1163 map_id = gmap.map_id,
1164 match_id = game_meta['I'],
1165 duration = duration,
1166 mod = game_meta.get('O', None))
1168 # keep track of the players we've seen
1172 for events in raw_players:
1173 player = get_or_create_player(
1175 hashkey = events['P'],
1176 nick = events.get('n', None))
1178 pgstat = create_game_stat(session, game_meta, game, server,
1179 gmap, player, events)
1180 pgstats.append(pgstat)
1182 if player.player_id > 1:
1183 anticheats = create_anticheats(session, pgstat, game, player, events)
1185 if player.player_id > 2:
1186 player_ids.append(player.player_id)
1187 hashkeys[player.player_id] = events['P']
1189 if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
1190 pwstats = create_weapon_stats(session, game_meta, game, player,
1193 # store them on games for easy access
1194 game.players = player_ids
1196 for events in raw_teams:
1198 teamstat = create_team_stat(session, game, events)
1199 except Exception as e:
1202 if server.elo_ind and gametype_elo_eligible(game_type_cd):
1203 ep = EloProcessor(session, game, pgstats)
1207 log.debug('Success! Stats recorded.')
1209 # ranks are fetched after we've done the "real" processing
1210 ranks = get_ranks(session, player_ids, game_type_cd)
1212 # plain text response
1213 request.response.content_type = 'text/plain'
1216 "now" : calendar.timegm(datetime.datetime.utcnow().timetuple()),
1220 "player_ids" : player_ids,
1221 "hashkeys" : hashkeys,
1226 except Exception as e: