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
113 """Returns the next key:value pair off the queue."""
115 items = self.q.popleft().strip().split(' ', 1)
117 # Some keys won't have values, like 'L' records where the server isn't actually
118 # participating in any ladders. These can be safely ignored.
125 def check_for_new_weapon_fired(self, sub_key):
126 """Checks if a given weapon fired event is a new one for the match."""
127 weapon = sub_key.split("-")[1]
128 if weapon not in self.weapons:
129 self.weapons.add(weapon)
131 def parse_player(self, key, pid):
132 """Construct a player events listing from the submission."""
134 # all of the keys related to player records
135 player_keys = ['i', 'n', 't', 'e']
139 player_fired_weapon = False
140 player_nonzero_score = False
142 # Consume all following 'i' 'n' 't' 'e' records
143 while len(self.q) > 0:
144 (key, value) = self.next_item()
145 if key is None and value is None:
148 (sub_key, sub_value) = value.split(' ', 1)
149 player[sub_key] = sub_value
151 if sub_key.endswith("cnt-fired"):
152 player_fired_weapon = True
153 self.check_for_new_weapon_fired(sub_key)
154 elif sub_key == 'scoreboard-score' and int(value) != 0:
155 player_nonzero_score = True
157 player[key] = unicode(value, 'utf-8')
158 elif key in player_keys:
161 # something we didn't expect - put it back on the deque
162 self.q.appendleft("{} {}".format(key, value))
165 played = played_in_game(player)
166 human = is_real_player(player)
169 self.humans.append(player)
171 if player_fired_weapon:
172 self.human_fired_weapon = True
174 if player_nonzero_score:
175 self.human_nonzero_score = True
177 elif played and not human:
178 self.bots.append(player)
180 self.players.append(player)
182 def parse_team(self, key, tid):
183 """Construct a team events listing from the submission."""
186 # Consume all following 'e' records
187 while len(self.q) > 0 and self.q[0].startswith('e'):
188 (_, value) = self.next_item()
189 (sub_key, sub_value) = value.split(' ', 1)
190 team[sub_key] = sub_value
192 self.teams.append(team)
195 """Parses the request body into instance variables."""
196 while len(self.q) > 0:
197 (key, value) = self.next_item()
198 if key is None and value is None:
203 self.revision = value
205 self.game_type_cd = value
209 self.map_name = value
211 self.match_id = value
213 self.server_name = unicode(value, 'utf-8')
215 self.impure_cvar_changes = int(value)
217 self.port_number = int(value)
219 self.duration = datetime.timedelta(seconds=int(round(float(value))))
223 self.parse_team(key, value)
225 self.parse_player(key, value)
227 raise Exception("Invalid submission")
232 def elo_submission_category(submission):
233 """Determines the Elo category purely by what is in the submission data."""
234 mod = submission.meta.get("O", "None")
236 vanilla_allowed_weapons = {"shotgun", "devastator", "blaster", "mortar", "vortex", "electro",
237 "arc", "hagar", "crylink", "machinegun"}
238 insta_allowed_weapons = {"vaporizer", "blaster"}
239 overkill_allowed_weapons = {"hmg", "vortex", "shotgun", "blaster", "machinegun", "rpc"}
242 if len(submission.weapons - vanilla_allowed_weapons) == 0:
244 elif mod == "InstaGib":
245 if len(submission.weapons - insta_allowed_weapons) == 0:
247 elif mod == "Overkill":
248 if len(submission.weapons - overkill_allowed_weapons) == 0:
256 def parse_stats_submission(body):
258 Parses the POST request body for a stats submission
260 # storage vars for the request body
266 # we're not in either stanza to start
269 for line in body.split('\n'):
271 (key, value) = line.strip().split(' ', 1)
273 # Server (S) and Nick (n) fields can have international characters.
275 value = unicode(value, 'utf-8')
277 if key not in 'P' 'Q' 'n' 'e' 't' 'i':
278 game_meta[key] = value
280 if key == 'Q' or key == 'P':
281 #log.debug('Found a {0}'.format(key))
282 #log.debug('in_Q: {0}'.format(in_Q))
283 #log.debug('in_P: {0}'.format(in_P))
284 #log.debug('events: {0}'.format(events))
286 # check where we were before and append events accordingly
287 if in_Q and len(events) > 0:
288 #log.debug('creating a team (Q) entry')
291 elif in_P and len(events) > 0:
292 #log.debug('creating a player (P) entry')
293 players.append(events)
297 #log.debug('key == P')
301 #log.debug('key == Q')
308 (subkey, subvalue) = value.split(' ', 1)
309 events[subkey] = subvalue
315 # no key/value pair - move on to the next line
318 # add the last entity we were working on
319 if in_P and len(events) > 0:
320 players.append(events)
321 elif in_Q and len(events) > 0:
324 return (game_meta, players, teams)
327 def is_blank_game(gametype, players):
328 """Determine if this is a blank game or not. A blank game is either:
330 1) a match that ended in the warmup stage, where accuracy events are not
331 present (for non-CTS games)
333 2) a match in which no player made a positive or negative score AND was
336 ... or for CTS, which doesn't record accuracy events
338 1) a match in which no player made a fastest lap AND was
341 ... or for NB, in which not all maps have weapons
343 1) a match in which no player made a positive or negative score
345 r = re.compile(r'acc-.*-cnt-fired')
346 flg_nonzero_score = False
347 flg_acc_events = False
348 flg_fastest_lap = False
350 for events in players:
351 if is_real_player(events) and played_in_game(events):
352 for (key,value) in events.items():
353 if key == 'scoreboard-score' and value != 0:
354 flg_nonzero_score = True
356 flg_acc_events = True
357 if key == 'scoreboard-fastest':
358 flg_fastest_lap = True
360 if gametype == 'cts':
361 return not flg_fastest_lap
362 elif gametype == 'nb':
363 return not flg_nonzero_score
365 return not (flg_nonzero_score and flg_acc_events)
368 def get_remote_addr(request):
369 """Get the Xonotic server's IP address"""
370 if 'X-Forwarded-For' in request.headers:
371 return request.headers['X-Forwarded-For']
373 return request.remote_addr
376 def is_supported_gametype(gametype, version):
377 """Whether a gametype is supported or not"""
380 # if the type can be supported, but with version constraints, uncomment
381 # here and add the restriction for a specific version below
382 supported_game_types = (
401 if gametype in supported_game_types:
406 # some game types were buggy before revisions, thus this additional filter
407 if gametype == 'ca' and version <= 5:
413 def do_precondition_checks(request, game_meta, raw_players):
414 """Precondition checks for ALL gametypes.
415 These do not require a database connection."""
416 if not has_required_metadata(game_meta):
417 msg = "Missing required game metadata"
419 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
421 content_type="text/plain"
425 version = int(game_meta['V'])
427 msg = "Invalid or incorrect game metadata provided"
429 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
431 content_type="text/plain"
434 if not is_supported_gametype(game_meta['G'], version):
435 msg = "Unsupported game type ({})".format(game_meta['G'])
437 raise pyramid.httpexceptions.HTTPOk(
439 content_type="text/plain"
442 if not has_minimum_real_players(request.registry.settings, raw_players):
443 msg = "Not enough real players"
445 raise pyramid.httpexceptions.HTTPOk(
447 content_type="text/plain"
450 if is_blank_game(game_meta['G'], raw_players):
453 raise pyramid.httpexceptions.HTTPOk(
455 content_type="text/plain"
459 def num_real_players(player_events):
461 Returns the number of real players (those who played
462 and are on the scoreboard).
466 for events in player_events:
467 if is_real_player(events) and played_in_game(events):
473 def has_minimum_real_players(settings, player_events):
475 Determines if the collection of player events has enough "real" players
476 to store in the database. The minimum setting comes from the config file
477 under the setting xonstat.minimum_real_players.
479 flg_has_min_real_players = True
482 minimum_required_players = int(
483 settings['xonstat.minimum_required_players'])
485 minimum_required_players = 2
487 real_players = num_real_players(player_events)
489 if real_players < minimum_required_players:
490 flg_has_min_real_players = False
492 return flg_has_min_real_players
495 def has_required_metadata(metadata):
497 Determines if a give set of metadata has enough data to create a game,
498 server, and map with.
500 flg_has_req_metadata = True
502 if 'G' not in metadata or\
503 'M' not in metadata or\
504 'I' not in metadata or\
506 flg_has_req_metadata = False
508 return flg_has_req_metadata
511 def should_do_weapon_stats(game_type_cd):
512 """True of the game type should record weapon stats. False otherwise."""
513 if game_type_cd in 'cts':
519 def gametype_elo_eligible(game_type_cd):
520 """True of the game type should process Elos. False otherwise."""
521 elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft')
523 if game_type_cd in elo_game_types:
529 def register_new_nick(session, player, new_nick):
531 Change the player record's nick to the newly found nick. Store the old
532 nick in the player_nicks table for that player.
534 session - SQLAlchemy database session factory
535 player - player record whose nick is changing
536 new_nick - the new nickname
538 # see if that nick already exists
539 stripped_nick = strip_colors(qfont_decode(player.nick))
541 player_nick = session.query(PlayerNick).filter_by(
542 player_id=player.player_id, stripped_nick=stripped_nick).one()
543 except NoResultFound, e:
544 # player_id/stripped_nick not found, create one
545 # but we don't store "Anonymous Player #N"
546 if not re.search('^Anonymous Player #\d+$', player.nick):
547 player_nick = PlayerNick()
548 player_nick.player_id = player.player_id
549 player_nick.stripped_nick = stripped_nick
550 player_nick.nick = player.nick
551 session.add(player_nick)
553 # We change to the new nick regardless
554 player.nick = new_nick
555 player.stripped_nick = strip_colors(qfont_decode(new_nick))
559 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
561 Check the fastest cap time for the player and map. If there isn't
562 one, insert one. If there is, check if the passed time is faster.
565 # we don't record fastest cap times for bots or anonymous players
569 # see if a cap entry exists already
570 # then check to see if the new captime is faster
572 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
573 player_id=player_id, map_id=map_id, mod=mod).one()
575 # current captime is faster, so update
576 if captime < cur_fastest_cap.fastest_cap:
577 cur_fastest_cap.fastest_cap = captime
578 cur_fastest_cap.game_id = game_id
579 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
580 session.add(cur_fastest_cap)
582 except NoResultFound, e:
583 # none exists, so insert
584 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
586 session.add(cur_fastest_cap)
590 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
592 Updates the server in the given DB session, if needed.
594 :param server: The found server instance.
595 :param name: The incoming server name.
596 :param hashkey: The incoming server hashkey.
597 :param ip_addr: The incoming server IP address.
598 :param port: The incoming server port.
599 :param revision: The incoming server revision.
600 :param impure_cvars: The incoming number of impure server cvars.
603 # ensure the two int attributes are actually ints
610 impure_cvars = int(impure_cvars)
615 if name and server.name != name:
618 if hashkey and server.hashkey != hashkey:
619 server.hashkey = hashkey
621 if ip_addr and server.ip_addr != ip_addr:
622 server.ip_addr = ip_addr
624 if port and server.port != port:
627 if revision and server.revision != revision:
628 server.revision = revision
630 if impure_cvars and server.impure_cvars != impure_cvars:
631 server.impure_cvars = impure_cvars
632 server.pure_ind = True if impure_cvars == 0 else False
638 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
640 Find a server by name or create one if not found. Parameters:
642 session - SQLAlchemy database session factory
643 name - server name of the server to be found or created
644 hashkey - server hashkey
645 ip_addr - the IP address of the server
646 revision - the xonotic revision number
647 port - the port number of the server
648 impure_cvars - the number of impure cvar changes
650 servers_q = DBSession.query(Server).filter(Server.active_ind)
653 # if the hashkey is provided, we'll use that
654 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
656 # otherwise, it is just by name
657 servers_q = servers_q.filter(Server.name == name)
659 # order by the hashkey, which means any hashkey match will appear first if there are multiple
660 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
662 if len(servers) == 0:
663 server = Server(name=name, hashkey=hashkey)
666 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
669 if len(servers) == 1:
670 log.info("Found existing server {}.".format(server.server_id))
672 elif len(servers) > 1:
673 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
674 log.warn("Multiple servers found ({})! Using the first one ({})."
675 .format(server_id_list, server.server_id))
677 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
683 def get_or_create_map(session=None, name=None):
685 Find a map by name or create one if not found. Parameters:
687 session - SQLAlchemy database session factory
688 name - map name of the map to be found or created
691 # find one by the name, if it exists
692 gmap = session.query(Map).filter_by(name=name).one()
693 log.debug("Found map id {0}: {1}".format(gmap.map_id,
695 except NoResultFound, e:
696 gmap = Map(name=name)
699 log.debug("Created map id {0}: {1}".format(gmap.map_id,
701 except MultipleResultsFound, e:
702 # multiple found, so use the first one but warn
704 gmaps = session.query(Map).filter_by(name=name).order_by(
707 log.debug("Found map id {0}: {1} but found \
708 multiple".format(gmap.map_id, gmap.name))
713 def create_game(session, start_dt, game_type_cd, server_id, map_id,
714 match_id, duration, mod, winner=None):
716 Creates a game. Parameters:
718 session - SQLAlchemy database session factory
719 start_dt - when the game started (datetime object)
720 game_type_cd - the game type of the game being played
721 server_id - server identifier of the server hosting the game
722 map_id - map on which the game was played
723 winner - the team id of the team that won
724 duration - how long the game lasted
725 mod - mods in use during the game
727 seq = Sequence('games_game_id_seq')
728 game_id = session.execute(seq)
729 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
730 server_id=server_id, map_id=map_id, winner=winner)
731 game.match_id = match_id
734 # There is some drift between start_dt (provided by app) and create_dt
735 # (default in the database), so we'll make them the same until this is
737 game.create_dt = start_dt
740 game.duration = datetime.timedelta(seconds=int(round(float(duration))))
745 session.query(Game).filter(Game.server_id==server_id).\
746 filter(Game.match_id==match_id).one()
748 log.debug("Error: game with same server and match_id found! Ignoring.")
750 # if a game under the same server and match_id found,
751 # this is a duplicate game and can be ignored
752 raise pyramid.httpexceptions.HTTPOk('OK')
753 except NoResultFound, e:
754 # server_id/match_id combination not found. game is ok to insert
757 log.debug("Created game id {0} on server {1}, map {2} at \
758 {3}".format(game.game_id,
759 server_id, map_id, start_dt))
764 def get_or_create_player(session=None, hashkey=None, nick=None):
766 Finds a player by hashkey or creates a new one (along with a
767 corresponding hashkey entry. Parameters:
769 session - SQLAlchemy database session factory
770 hashkey - hashkey of the player to be found or created
771 nick - nick of the player (in case of a first time create)
774 if re.search('^bot#\d+', hashkey):
775 player = session.query(Player).filter_by(player_id=1).one()
776 # if we have an untracked player
777 elif re.search('^player#\d+$', hashkey):
778 player = session.query(Player).filter_by(player_id=2).one()
779 # else it is a tracked player
781 # see if the player is already in the database
782 # if not, create one and the hashkey along with it
784 hk = session.query(Hashkey).filter_by(
785 hashkey=hashkey).one()
786 player = session.query(Player).filter_by(
787 player_id=hk.player_id).one()
788 log.debug("Found existing player {0} with hashkey {1}".format(
789 player.player_id, hashkey))
795 # if nick is given to us, use it. If not, use "Anonymous Player"
796 # with a suffix added for uniqueness.
798 player.nick = nick[:128]
799 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
801 player.nick = "Anonymous Player #{0}".format(player.player_id)
802 player.stripped_nick = player.nick
804 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
806 log.debug("Created player {0} ({2}) with hashkey {1}".format(
807 player.player_id, hashkey, player.nick.encode('utf-8')))
812 def create_default_game_stat(session, game_type_cd):
813 """Creates a blanked-out pgstat record for the given game type"""
815 # this is what we have to do to get partitioned records in - grab the
816 # sequence value first, then insert using the explicit ID (vs autogenerate)
817 seq = Sequence('player_game_stats_player_game_stat_id_seq')
818 pgstat_id = session.execute(seq)
819 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
820 create_dt=datetime.datetime.utcnow())
822 if game_type_cd == 'as':
823 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
825 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
826 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
828 if game_type_cd == 'cq':
829 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
832 if game_type_cd == 'ctf':
833 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
834 pgstat.returns = pgstat.carrier_frags = 0
836 if game_type_cd == 'cts':
839 if game_type_cd == 'dom':
840 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
843 if game_type_cd == 'ft':
844 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
846 if game_type_cd == 'ka':
847 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
848 pgstat.carrier_frags = 0
849 pgstat.time = datetime.timedelta(seconds=0)
851 if game_type_cd == 'kh':
852 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
853 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
854 pgstat.carrier_frags = 0
856 if game_type_cd == 'lms':
857 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
859 if game_type_cd == 'nb':
860 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
863 if game_type_cd == 'rc':
864 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
869 def create_game_stat(session, game_meta, game, server, gmap, player, events):
870 """Game stats handler for all game types"""
872 game_type_cd = game.game_type_cd
874 pgstat = create_default_game_stat(session, game_type_cd)
876 # these fields should be on every pgstat record
877 pgstat.game_id = game.game_id
878 pgstat.player_id = player.player_id
879 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
880 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
881 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
882 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
883 pgstat.rank = int(events.get('rank', None))
884 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
886 if pgstat.nick != player.nick \
887 and player.player_id > 2 \
888 and pgstat.nick != 'Anonymous Player':
889 register_new_nick(session, player, pgstat.nick)
893 # gametype-specific stuff is handled here. if passed to us, we store it
894 for (key,value) in events.items():
895 if key == 'wins': wins = True
896 if key == 't': pgstat.team = int(value)
898 if key == 'scoreboard-drops': pgstat.drops = int(value)
899 if key == 'scoreboard-returns': pgstat.returns = int(value)
900 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
901 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
902 if key == 'scoreboard-caps': pgstat.captures = int(value)
903 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
904 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
905 if key == 'scoreboard-kills': pgstat.kills = int(value)
906 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
907 if key == 'scoreboard-objectives': pgstat.collects = int(value)
908 if key == 'scoreboard-captured': pgstat.captures = int(value)
909 if key == 'scoreboard-released': pgstat.drops = int(value)
910 if key == 'scoreboard-fastest':
911 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
912 if key == 'scoreboard-takes': pgstat.pickups = int(value)
913 if key == 'scoreboard-ticks': pgstat.drops = int(value)
914 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
915 if key == 'scoreboard-bctime':
916 pgstat.time = datetime.timedelta(seconds=int(value))
917 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
918 if key == 'scoreboard-losses': pgstat.drops = int(value)
919 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
920 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
921 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
922 if key == 'scoreboard-lives': pgstat.lives = int(value)
923 if key == 'scoreboard-goals': pgstat.captures = int(value)
924 if key == 'scoreboard-faults': pgstat.drops = int(value)
925 if key == 'scoreboard-laps': pgstat.laps = int(value)
927 if key == 'avglatency': pgstat.avg_latency = float(value)
928 if key == 'scoreboard-captime':
929 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
930 if game.game_type_cd == 'ctf':
931 update_fastest_cap(session, player.player_id, game.game_id,
932 gmap.map_id, pgstat.fastest, game.mod)
934 # there is no "winning team" field, so we have to derive it
935 if wins and pgstat.team is not None and game.winner is None:
936 game.winner = pgstat.team
944 def create_anticheats(session, pgstat, game, player, events):
945 """Anticheats handler for all game types"""
949 # all anticheat events are prefixed by "anticheat"
950 for (key,value) in events.items():
951 if key.startswith("anticheat"):
953 ac = PlayerGameAnticheat(
959 anticheats.append(ac)
961 except Exception as e:
962 log.debug("Could not parse value for key %s. Ignoring." % key)
967 def create_default_team_stat(session, game_type_cd):
968 """Creates a blanked-out teamstat record for the given game type"""
970 # this is what we have to do to get partitioned records in - grab the
971 # sequence value first, then insert using the explicit ID (vs autogenerate)
972 seq = Sequence('team_game_stats_team_game_stat_id_seq')
973 teamstat_id = session.execute(seq)
974 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
975 create_dt=datetime.datetime.utcnow())
977 # all team game modes have a score, so we'll zero that out always
980 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
983 if game_type_cd == 'ctf':
989 def create_team_stat(session, game, events):
990 """Team stats handler for all game types"""
993 teamstat = create_default_team_stat(session, game.game_type_cd)
994 teamstat.game_id = game.game_id
996 # we should have a team ID if we have a 'Q' event
997 if re.match(r'^team#\d+$', events.get('Q', '')):
998 team = int(events.get('Q').replace('team#', ''))
1001 # gametype-specific stuff is handled here. if passed to us, we store it
1002 for (key,value) in events.items():
1003 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
1004 if key == 'scoreboard-caps': teamstat.caps = int(value)
1005 if key == 'scoreboard-goals': teamstat.caps = int(value)
1006 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
1008 session.add(teamstat)
1009 except Exception as e:
1015 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
1016 """Weapon stats handler for all game types"""
1019 # Version 1 of stats submissions doubled the data sent.
1020 # To counteract this we divide the data by 2 only for
1021 # POSTs coming from version 1.
1023 version = int(game_meta['V'])
1026 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
1032 for (key,value) in events.items():
1033 matched = re.search("acc-(.*?)-cnt-fired", key)
1035 weapon_cd = matched.group(1)
1037 # Weapon names changed for 0.8. We'll convert the old
1038 # ones to use the new scheme as well.
1039 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
1041 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
1042 pwstat_id = session.execute(seq)
1043 pwstat = PlayerWeaponStat()
1044 pwstat.player_weapon_stats_id = pwstat_id
1045 pwstat.player_id = player.player_id
1046 pwstat.game_id = game.game_id
1047 pwstat.player_game_stat_id = pgstat.player_game_stat_id
1048 pwstat.weapon_cd = mapped_weapon_cd
1051 pwstat.nick = events['n']
1053 pwstat.nick = events['P']
1055 if 'acc-' + weapon_cd + '-cnt-fired' in events:
1056 pwstat.fired = int(round(float(
1057 events['acc-' + weapon_cd + '-cnt-fired'])))
1058 if 'acc-' + weapon_cd + '-fired' in events:
1059 pwstat.max = int(round(float(
1060 events['acc-' + weapon_cd + '-fired'])))
1061 if 'acc-' + weapon_cd + '-cnt-hit' in events:
1062 pwstat.hit = int(round(float(
1063 events['acc-' + weapon_cd + '-cnt-hit'])))
1064 if 'acc-' + weapon_cd + '-hit' in events:
1065 pwstat.actual = int(round(float(
1066 events['acc-' + weapon_cd + '-hit'])))
1067 if 'acc-' + weapon_cd + '-frags' in events:
1068 pwstat.frags = int(round(float(
1069 events['acc-' + weapon_cd + '-frags'])))
1072 pwstat.fired = pwstat.fired/2
1073 pwstat.max = pwstat.max/2
1074 pwstat.hit = pwstat.hit/2
1075 pwstat.actual = pwstat.actual/2
1076 pwstat.frags = pwstat.frags/2
1079 pwstats.append(pwstat)
1084 def get_ranks(session, player_ids, game_type_cd):
1086 Gets the rank entries for all players in the given list, returning a dict
1087 of player_id -> PlayerRank instance. The rank entry corresponds to the
1088 game type of the parameter passed in as well.
1091 for pr in session.query(PlayerRank).\
1092 filter(PlayerRank.player_id.in_(player_ids)).\
1093 filter(PlayerRank.game_type_cd == game_type_cd).\
1095 ranks[pr.player_id] = pr
1100 def submit_stats(request):
1102 Entry handler for POST stats submissions.
1105 # placeholder for the actual session
1108 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1109 "----- END REQUEST BODY -----\n\n")
1111 (idfp, status) = verify_request(request)
1112 (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
1113 revision = game_meta.get('R', 'unknown')
1114 duration = game_meta.get('D', None)
1116 # only players present at the end of the match are eligible for stats
1117 raw_players = filter(played_in_game, raw_players)
1119 do_precondition_checks(request, game_meta, raw_players)
1121 # the "duel" gametype is fake
1122 if len(raw_players) == 2 \
1123 and num_real_players(raw_players) == 2 \
1124 and game_meta['G'] == 'dm':
1125 game_meta['G'] = 'duel'
1127 #----------------------------------------------------------------------
1128 # Actual setup (inserts/updates) below here
1129 #----------------------------------------------------------------------
1130 session = DBSession()
1132 game_type_cd = game_meta['G']
1134 # All game types create Game, Server, Map, and Player records
1136 server = get_or_create_server(
1139 name = game_meta['S'],
1140 revision = revision,
1141 ip_addr = get_remote_addr(request),
1142 port = game_meta.get('U', None),
1143 impure_cvars = game_meta.get('C', 0))
1145 gmap = get_or_create_map(
1147 name = game_meta['M'])
1151 start_dt = datetime.datetime.utcnow(),
1152 server_id = server.server_id,
1153 game_type_cd = game_type_cd,
1154 map_id = gmap.map_id,
1155 match_id = game_meta['I'],
1156 duration = duration,
1157 mod = game_meta.get('O', None))
1159 # keep track of the players we've seen
1163 for events in raw_players:
1164 player = get_or_create_player(
1166 hashkey = events['P'],
1167 nick = events.get('n', None))
1169 pgstat = create_game_stat(session, game_meta, game, server,
1170 gmap, player, events)
1171 pgstats.append(pgstat)
1173 if player.player_id > 1:
1174 anticheats = create_anticheats(session, pgstat, game, player, events)
1176 if player.player_id > 2:
1177 player_ids.append(player.player_id)
1178 hashkeys[player.player_id] = events['P']
1180 if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
1181 pwstats = create_weapon_stats(session, game_meta, game, player,
1184 # store them on games for easy access
1185 game.players = player_ids
1187 for events in raw_teams:
1189 teamstat = create_team_stat(session, game, events)
1190 except Exception as e:
1193 if server.elo_ind and gametype_elo_eligible(game_type_cd):
1194 ep = EloProcessor(session, game, pgstats)
1198 log.debug('Success! Stats recorded.')
1200 # ranks are fetched after we've done the "real" processing
1201 ranks = get_ranks(session, player_ids, game_type_cd)
1203 # plain text response
1204 request.response.content_type = 'text/plain'
1207 "now" : calendar.timegm(datetime.datetime.utcnow().timetuple()),
1211 "player_ids" : player_ids,
1212 "hashkeys" : hashkeys,
1217 except Exception as e: