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 class Submission(object):
20 """Parses an incoming POST request for stats submissions."""
22 def __init__(self, body, headers):
23 # a copy of the HTTP headers
24 self.headers = headers
26 # a copy of the HTTP POST body
29 # the submission code version (from the server)
32 # the revision string of the server
35 # the game type played
36 self.game_type_cd = None
41 # the name of the map played
44 # unique identifier (string) for a match on a given server
47 # the name of the server
48 self.server_name = None
50 # the number of cvars that were changed to be different than default
51 self.impure_cvar_changes = None
53 # the port number the game server is listening on
54 self.port_number = None
56 # how long the game lasted
59 # which ladder is being used, if any
62 # players involved in the match (humans, bots, and spectators)
68 # the parsing deque (we use this to allow peeking)
69 self.q = collections.deque(self.body.split("\n"))
71 ############################################################################################
72 # Below this point are fields useful in determining if the submission is valid or
73 # performance optimizations that save us from looping over the events over and over again.
74 ############################################################################################
76 # humans who played in the match
79 # bots who played in the match
82 # distinct weapons that we have seen fired
85 # has a human player fired a shot?
86 self.human_fired_weapon = False
88 # does any human have a non-zero score?
89 self.human_nonzero_score = False
91 # does any human have a fastest cap?
92 self.human_fastest = False
95 """Returns the next key:value pair off the queue."""
97 items = self.q.popleft().strip().split(' ', 1)
99 # Some keys won't have values, like 'L' records where the server isn't actually
100 # participating in any ladders. These can be safely ignored.
107 def add_weapon_fired(self, sub_key):
108 """Adds a weapon to the set of weapons fired during the match (a set)."""
109 self.weapons.add(sub_key.split("-")[1])
112 def is_human_player(player):
114 Determines if a given set of events correspond with a non-bot
116 return not player['P'].startswith('bot')
119 def played_in_game(player):
121 Determines if a given set of player events correspond with a player who
122 played in the game (matches 1 and scoreboardvalid 1)
124 return 'matches' in player and 'scoreboardvalid' in player
126 def parse_player(self, key, pid):
127 """Construct a player events listing from the submission."""
129 # all of the keys related to player records
130 player_keys = ['i', 'n', 't', 'e']
134 player_fired_weapon = False
135 player_nonzero_score = False
136 player_fastest = False
138 # Consume all following 'i' 'n' 't' 'e' records
139 while len(self.q) > 0:
140 (key, value) = self.next_item()
141 if key is None and value is None:
144 (sub_key, sub_value) = value.split(' ', 1)
145 player[sub_key] = sub_value
147 if sub_key.endswith("cnt-fired"):
148 player_fired_weapon = True
149 self.add_weapon_fired(sub_key)
150 elif sub_key == 'scoreboard-score' and int(sub_value) != 0:
151 player_nonzero_score = True
152 elif sub_key == 'scoreboard-fastest':
153 player_fastest = True
155 player[key] = unicode(value, 'utf-8')
156 elif key in player_keys:
159 # something we didn't expect - put it back on the deque
160 self.q.appendleft("{} {}".format(key, value))
163 played = self.played_in_game(player)
164 human = self.is_human_player(player)
167 self.humans.append(player)
169 if player_fired_weapon:
170 self.human_fired_weapon = True
172 if player_nonzero_score:
173 self.human_nonzero_score = True
176 self.human_fastest = True
178 elif played and not human:
179 self.bots.append(player)
181 self.players.append(player)
183 def parse_team(self, key, tid):
184 """Construct a team events listing from the submission."""
187 # Consume all following 'e' records
188 while len(self.q) > 0 and self.q[0].startswith('e'):
189 (_, value) = self.next_item()
190 (sub_key, sub_value) = value.split(' ', 1)
191 team[sub_key] = sub_value
193 self.teams.append(team)
196 """Parses the request body into instance variables."""
197 while len(self.q) > 0:
198 (key, value) = self.next_item()
199 if key is None and value is None:
204 self.revision = value
206 self.game_type_cd = value
210 self.map_name = value
212 self.match_id = value
214 self.server_name = unicode(value, 'utf-8')
216 self.impure_cvar_changes = int(value)
218 self.port_number = int(value)
220 self.duration = datetime.timedelta(seconds=int(round(float(value))))
224 self.parse_team(key, value)
226 self.parse_player(key, value)
228 raise Exception("Invalid submission")
233 """Debugging representation of a submission."""
234 return "game_type_cd: {}, mod: {}, players: {}, humans: {}, bots: {}, weapons: {}".format(
235 self.game_type_cd, self.mod, len(self.players), len(self.humans), len(self.bots),
239 def elo_submission_category(submission):
240 """Determines the Elo category purely by what is in the submission data."""
243 vanilla_allowed_weapons = {"shotgun", "devastator", "blaster", "mortar", "vortex", "electro",
244 "arc", "hagar", "crylink", "machinegun"}
245 insta_allowed_weapons = {"vaporizer", "blaster"}
246 overkill_allowed_weapons = {"hmg", "vortex", "shotgun", "blaster", "machinegun", "rpc"}
249 if len(submission.weapons - vanilla_allowed_weapons) == 0:
251 elif mod == "InstaGib":
252 if len(submission.weapons - insta_allowed_weapons) == 0:
254 elif mod == "Overkill":
255 if len(submission.weapons - overkill_allowed_weapons) == 0:
263 def is_blank_game(submission):
265 Determine if this is a blank game or not. A blank game is either:
267 1) a match that ended in the warmup stage, where accuracy events are not
268 present (for non-CTS games)
270 2) a match in which no player made a positive or negative score AND was
273 ... or for CTS, which doesn't record accuracy events
275 1) a match in which no player made a fastest lap AND was
278 ... or for NB, in which not all maps have weapons
280 1) a match in which no player made a positive or negative score
282 if submission.game_type_cd == 'cts':
283 return not submission.human_fastest
284 elif submission.game_type_cd == 'nb':
285 return not submission.human_nonzero_score
287 return not (submission.human_nonzero_score and submission.human_fired_weapon)
290 def has_required_metadata(submission):
291 """Determines if a submission has all the required metadata fields."""
292 return (submission.game_type_cd is not None
293 and submission.map_name is not None
294 and submission.match_id is not None
295 and submission.server_name is not None)
298 def get_remote_addr(request):
299 """Get the Xonotic server's IP address"""
300 if 'X-Forwarded-For' in request.headers:
301 return request.headers['X-Forwarded-For']
303 return request.remote_addr
306 def is_supported_gametype(gametype, version):
307 """Whether a gametype is supported or not"""
310 # if the type can be supported, but with version constraints, uncomment
311 # here and add the restriction for a specific version below
312 supported_game_types = (
331 if gametype in supported_game_types:
336 # some game types were buggy before revisions, thus this additional filter
337 if gametype == 'ca' and version <= 5:
343 def do_precondition_checks(request, game_meta, raw_players):
344 """Precondition checks for ALL gametypes.
345 These do not require a database connection."""
346 if not has_required_metadata(game_meta):
347 msg = "Missing required game metadata"
349 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
351 content_type="text/plain"
355 version = int(game_meta['V'])
357 msg = "Invalid or incorrect game metadata provided"
359 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
361 content_type="text/plain"
364 if not is_supported_gametype(game_meta['G'], version):
365 msg = "Unsupported game type ({})".format(game_meta['G'])
367 raise pyramid.httpexceptions.HTTPOk(
369 content_type="text/plain"
372 if not has_minimum_real_players(request.registry.settings, raw_players):
373 msg = "Not enough real players"
375 raise pyramid.httpexceptions.HTTPOk(
377 content_type="text/plain"
380 if is_blank_game(game_meta['G'], raw_players):
383 raise pyramid.httpexceptions.HTTPOk(
385 content_type="text/plain"
389 def num_real_players(player_events):
391 Returns the number of real players (those who played
392 and are on the scoreboard).
396 for events in player_events:
397 if is_real_player(events) and played_in_game(events):
403 def has_minimum_real_players(settings, player_events):
405 Determines if the collection of player events has enough "real" players
406 to store in the database. The minimum setting comes from the config file
407 under the setting xonstat.minimum_real_players.
409 flg_has_min_real_players = True
412 minimum_required_players = int(
413 settings['xonstat.minimum_required_players'])
415 minimum_required_players = 2
417 real_players = num_real_players(player_events)
419 if real_players < minimum_required_players:
420 flg_has_min_real_players = False
422 return flg_has_min_real_players
428 def should_do_weapon_stats(game_type_cd):
429 """True of the game type should record weapon stats. False otherwise."""
430 if game_type_cd in 'cts':
436 def gametype_elo_eligible(game_type_cd):
437 """True of the game type should process Elos. False otherwise."""
438 elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft')
440 if game_type_cd in elo_game_types:
446 def register_new_nick(session, player, new_nick):
448 Change the player record's nick to the newly found nick. Store the old
449 nick in the player_nicks table for that player.
451 session - SQLAlchemy database session factory
452 player - player record whose nick is changing
453 new_nick - the new nickname
455 # see if that nick already exists
456 stripped_nick = strip_colors(qfont_decode(player.nick))
458 player_nick = session.query(PlayerNick).filter_by(
459 player_id=player.player_id, stripped_nick=stripped_nick).one()
460 except NoResultFound, e:
461 # player_id/stripped_nick not found, create one
462 # but we don't store "Anonymous Player #N"
463 if not re.search('^Anonymous Player #\d+$', player.nick):
464 player_nick = PlayerNick()
465 player_nick.player_id = player.player_id
466 player_nick.stripped_nick = stripped_nick
467 player_nick.nick = player.nick
468 session.add(player_nick)
470 # We change to the new nick regardless
471 player.nick = new_nick
472 player.stripped_nick = strip_colors(qfont_decode(new_nick))
476 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
478 Check the fastest cap time for the player and map. If there isn't
479 one, insert one. If there is, check if the passed time is faster.
482 # we don't record fastest cap times for bots or anonymous players
486 # see if a cap entry exists already
487 # then check to see if the new captime is faster
489 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
490 player_id=player_id, map_id=map_id, mod=mod).one()
492 # current captime is faster, so update
493 if captime < cur_fastest_cap.fastest_cap:
494 cur_fastest_cap.fastest_cap = captime
495 cur_fastest_cap.game_id = game_id
496 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
497 session.add(cur_fastest_cap)
499 except NoResultFound, e:
500 # none exists, so insert
501 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
503 session.add(cur_fastest_cap)
507 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
509 Updates the server in the given DB session, if needed.
511 :param server: The found server instance.
512 :param name: The incoming server name.
513 :param hashkey: The incoming server hashkey.
514 :param ip_addr: The incoming server IP address.
515 :param port: The incoming server port.
516 :param revision: The incoming server revision.
517 :param impure_cvars: The incoming number of impure server cvars.
520 # ensure the two int attributes are actually ints
527 impure_cvars = int(impure_cvars)
532 if name and server.name != name:
535 if hashkey and server.hashkey != hashkey:
536 server.hashkey = hashkey
538 if ip_addr and server.ip_addr != ip_addr:
539 server.ip_addr = ip_addr
541 if port and server.port != port:
544 if revision and server.revision != revision:
545 server.revision = revision
547 if impure_cvars and server.impure_cvars != impure_cvars:
548 server.impure_cvars = impure_cvars
549 server.pure_ind = True if impure_cvars == 0 else False
555 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
557 Find a server by name or create one if not found. Parameters:
559 session - SQLAlchemy database session factory
560 name - server name of the server to be found or created
561 hashkey - server hashkey
562 ip_addr - the IP address of the server
563 revision - the xonotic revision number
564 port - the port number of the server
565 impure_cvars - the number of impure cvar changes
567 servers_q = DBSession.query(Server).filter(Server.active_ind)
570 # if the hashkey is provided, we'll use that
571 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
573 # otherwise, it is just by name
574 servers_q = servers_q.filter(Server.name == name)
576 # order by the hashkey, which means any hashkey match will appear first if there are multiple
577 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
579 if len(servers) == 0:
580 server = Server(name=name, hashkey=hashkey)
583 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
586 if len(servers) == 1:
587 log.info("Found existing server {}.".format(server.server_id))
589 elif len(servers) > 1:
590 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
591 log.warn("Multiple servers found ({})! Using the first one ({})."
592 .format(server_id_list, server.server_id))
594 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
600 def get_or_create_map(session=None, name=None):
602 Find a map by name or create one if not found. Parameters:
604 session - SQLAlchemy database session factory
605 name - map name of the map to be found or created
608 # find one by the name, if it exists
609 gmap = session.query(Map).filter_by(name=name).one()
610 log.debug("Found map id {0}: {1}".format(gmap.map_id,
612 except NoResultFound, e:
613 gmap = Map(name=name)
616 log.debug("Created map id {0}: {1}".format(gmap.map_id,
618 except MultipleResultsFound, e:
619 # multiple found, so use the first one but warn
621 gmaps = session.query(Map).filter_by(name=name).order_by(
624 log.debug("Found map id {0}: {1} but found \
625 multiple".format(gmap.map_id, gmap.name))
630 def create_game(session, start_dt, game_type_cd, server_id, map_id,
631 match_id, duration, mod, winner=None):
633 Creates a game. Parameters:
635 session - SQLAlchemy database session factory
636 start_dt - when the game started (datetime object)
637 game_type_cd - the game type of the game being played
638 server_id - server identifier of the server hosting the game
639 map_id - map on which the game was played
640 winner - the team id of the team that won
641 duration - how long the game lasted
642 mod - mods in use during the game
644 seq = Sequence('games_game_id_seq')
645 game_id = session.execute(seq)
646 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
647 server_id=server_id, map_id=map_id, winner=winner)
648 game.match_id = match_id
651 # There is some drift between start_dt (provided by app) and create_dt
652 # (default in the database), so we'll make them the same until this is
654 game.create_dt = start_dt
657 game.duration = datetime.timedelta(seconds=int(round(float(duration))))
662 session.query(Game).filter(Game.server_id==server_id).\
663 filter(Game.match_id==match_id).one()
665 log.debug("Error: game with same server and match_id found! Ignoring.")
667 # if a game under the same server and match_id found,
668 # this is a duplicate game and can be ignored
669 raise pyramid.httpexceptions.HTTPOk('OK')
670 except NoResultFound, e:
671 # server_id/match_id combination not found. game is ok to insert
674 log.debug("Created game id {0} on server {1}, map {2} at \
675 {3}".format(game.game_id,
676 server_id, map_id, start_dt))
681 def get_or_create_player(session=None, hashkey=None, nick=None):
683 Finds a player by hashkey or creates a new one (along with a
684 corresponding hashkey entry. Parameters:
686 session - SQLAlchemy database session factory
687 hashkey - hashkey of the player to be found or created
688 nick - nick of the player (in case of a first time create)
691 if re.search('^bot#\d+', hashkey):
692 player = session.query(Player).filter_by(player_id=1).one()
693 # if we have an untracked player
694 elif re.search('^player#\d+$', hashkey):
695 player = session.query(Player).filter_by(player_id=2).one()
696 # else it is a tracked player
698 # see if the player is already in the database
699 # if not, create one and the hashkey along with it
701 hk = session.query(Hashkey).filter_by(
702 hashkey=hashkey).one()
703 player = session.query(Player).filter_by(
704 player_id=hk.player_id).one()
705 log.debug("Found existing player {0} with hashkey {1}".format(
706 player.player_id, hashkey))
712 # if nick is given to us, use it. If not, use "Anonymous Player"
713 # with a suffix added for uniqueness.
715 player.nick = nick[:128]
716 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
718 player.nick = "Anonymous Player #{0}".format(player.player_id)
719 player.stripped_nick = player.nick
721 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
723 log.debug("Created player {0} ({2}) with hashkey {1}".format(
724 player.player_id, hashkey, player.nick.encode('utf-8')))
729 def create_default_game_stat(session, game_type_cd):
730 """Creates a blanked-out pgstat record for the given game type"""
732 # this is what we have to do to get partitioned records in - grab the
733 # sequence value first, then insert using the explicit ID (vs autogenerate)
734 seq = Sequence('player_game_stats_player_game_stat_id_seq')
735 pgstat_id = session.execute(seq)
736 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
737 create_dt=datetime.datetime.utcnow())
739 if game_type_cd == 'as':
740 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
742 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
743 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
745 if game_type_cd == 'cq':
746 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
749 if game_type_cd == 'ctf':
750 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
751 pgstat.returns = pgstat.carrier_frags = 0
753 if game_type_cd == 'cts':
756 if game_type_cd == 'dom':
757 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
760 if game_type_cd == 'ft':
761 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
763 if game_type_cd == 'ka':
764 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
765 pgstat.carrier_frags = 0
766 pgstat.time = datetime.timedelta(seconds=0)
768 if game_type_cd == 'kh':
769 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
770 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
771 pgstat.carrier_frags = 0
773 if game_type_cd == 'lms':
774 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
776 if game_type_cd == 'nb':
777 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
780 if game_type_cd == 'rc':
781 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
786 def create_game_stat(session, game_meta, game, server, gmap, player, events):
787 """Game stats handler for all game types"""
789 game_type_cd = game.game_type_cd
791 pgstat = create_default_game_stat(session, game_type_cd)
793 # these fields should be on every pgstat record
794 pgstat.game_id = game.game_id
795 pgstat.player_id = player.player_id
796 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
797 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
798 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
799 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
800 pgstat.rank = int(events.get('rank', None))
801 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
803 if pgstat.nick != player.nick \
804 and player.player_id > 2 \
805 and pgstat.nick != 'Anonymous Player':
806 register_new_nick(session, player, pgstat.nick)
810 # gametype-specific stuff is handled here. if passed to us, we store it
811 for (key,value) in events.items():
812 if key == 'wins': wins = True
813 if key == 't': pgstat.team = int(value)
815 if key == 'scoreboard-drops': pgstat.drops = int(value)
816 if key == 'scoreboard-returns': pgstat.returns = int(value)
817 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
818 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
819 if key == 'scoreboard-caps': pgstat.captures = int(value)
820 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
821 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
822 if key == 'scoreboard-kills': pgstat.kills = int(value)
823 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
824 if key == 'scoreboard-objectives': pgstat.collects = int(value)
825 if key == 'scoreboard-captured': pgstat.captures = int(value)
826 if key == 'scoreboard-released': pgstat.drops = int(value)
827 if key == 'scoreboard-fastest':
828 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
829 if key == 'scoreboard-takes': pgstat.pickups = int(value)
830 if key == 'scoreboard-ticks': pgstat.drops = int(value)
831 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
832 if key == 'scoreboard-bctime':
833 pgstat.time = datetime.timedelta(seconds=int(value))
834 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
835 if key == 'scoreboard-losses': pgstat.drops = int(value)
836 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
837 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
838 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
839 if key == 'scoreboard-lives': pgstat.lives = int(value)
840 if key == 'scoreboard-goals': pgstat.captures = int(value)
841 if key == 'scoreboard-faults': pgstat.drops = int(value)
842 if key == 'scoreboard-laps': pgstat.laps = int(value)
844 if key == 'avglatency': pgstat.avg_latency = float(value)
845 if key == 'scoreboard-captime':
846 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
847 if game.game_type_cd == 'ctf':
848 update_fastest_cap(session, player.player_id, game.game_id,
849 gmap.map_id, pgstat.fastest, game.mod)
851 # there is no "winning team" field, so we have to derive it
852 if wins and pgstat.team is not None and game.winner is None:
853 game.winner = pgstat.team
861 def create_anticheats(session, pgstat, game, player, events):
862 """Anticheats handler for all game types"""
866 # all anticheat events are prefixed by "anticheat"
867 for (key,value) in events.items():
868 if key.startswith("anticheat"):
870 ac = PlayerGameAnticheat(
876 anticheats.append(ac)
878 except Exception as e:
879 log.debug("Could not parse value for key %s. Ignoring." % key)
884 def create_default_team_stat(session, game_type_cd):
885 """Creates a blanked-out teamstat record for the given game type"""
887 # this is what we have to do to get partitioned records in - grab the
888 # sequence value first, then insert using the explicit ID (vs autogenerate)
889 seq = Sequence('team_game_stats_team_game_stat_id_seq')
890 teamstat_id = session.execute(seq)
891 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
892 create_dt=datetime.datetime.utcnow())
894 # all team game modes have a score, so we'll zero that out always
897 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
900 if game_type_cd == 'ctf':
906 def create_team_stat(session, game, events):
907 """Team stats handler for all game types"""
910 teamstat = create_default_team_stat(session, game.game_type_cd)
911 teamstat.game_id = game.game_id
913 # we should have a team ID if we have a 'Q' event
914 if re.match(r'^team#\d+$', events.get('Q', '')):
915 team = int(events.get('Q').replace('team#', ''))
918 # gametype-specific stuff is handled here. if passed to us, we store it
919 for (key,value) in events.items():
920 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
921 if key == 'scoreboard-caps': teamstat.caps = int(value)
922 if key == 'scoreboard-goals': teamstat.caps = int(value)
923 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
925 session.add(teamstat)
926 except Exception as e:
932 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
933 """Weapon stats handler for all game types"""
936 # Version 1 of stats submissions doubled the data sent.
937 # To counteract this we divide the data by 2 only for
938 # POSTs coming from version 1.
940 version = int(game_meta['V'])
943 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
949 for (key,value) in events.items():
950 matched = re.search("acc-(.*?)-cnt-fired", key)
952 weapon_cd = matched.group(1)
954 # Weapon names changed for 0.8. We'll convert the old
955 # ones to use the new scheme as well.
956 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
958 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
959 pwstat_id = session.execute(seq)
960 pwstat = PlayerWeaponStat()
961 pwstat.player_weapon_stats_id = pwstat_id
962 pwstat.player_id = player.player_id
963 pwstat.game_id = game.game_id
964 pwstat.player_game_stat_id = pgstat.player_game_stat_id
965 pwstat.weapon_cd = mapped_weapon_cd
968 pwstat.nick = events['n']
970 pwstat.nick = events['P']
972 if 'acc-' + weapon_cd + '-cnt-fired' in events:
973 pwstat.fired = int(round(float(
974 events['acc-' + weapon_cd + '-cnt-fired'])))
975 if 'acc-' + weapon_cd + '-fired' in events:
976 pwstat.max = int(round(float(
977 events['acc-' + weapon_cd + '-fired'])))
978 if 'acc-' + weapon_cd + '-cnt-hit' in events:
979 pwstat.hit = int(round(float(
980 events['acc-' + weapon_cd + '-cnt-hit'])))
981 if 'acc-' + weapon_cd + '-hit' in events:
982 pwstat.actual = int(round(float(
983 events['acc-' + weapon_cd + '-hit'])))
984 if 'acc-' + weapon_cd + '-frags' in events:
985 pwstat.frags = int(round(float(
986 events['acc-' + weapon_cd + '-frags'])))
989 pwstat.fired = pwstat.fired/2
990 pwstat.max = pwstat.max/2
991 pwstat.hit = pwstat.hit/2
992 pwstat.actual = pwstat.actual/2
993 pwstat.frags = pwstat.frags/2
996 pwstats.append(pwstat)
1001 def get_ranks(session, player_ids, game_type_cd):
1003 Gets the rank entries for all players in the given list, returning a dict
1004 of player_id -> PlayerRank instance. The rank entry corresponds to the
1005 game type of the parameter passed in as well.
1008 for pr in session.query(PlayerRank).\
1009 filter(PlayerRank.player_id.in_(player_ids)).\
1010 filter(PlayerRank.game_type_cd == game_type_cd).\
1012 ranks[pr.player_id] = pr
1017 def submit_stats(request):
1019 Entry handler for POST stats submissions.
1022 # placeholder for the actual session
1025 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1026 "----- END REQUEST BODY -----\n\n")
1028 (idfp, status) = verify_request(request)
1029 (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
1030 revision = game_meta.get('R', 'unknown')
1031 duration = game_meta.get('D', None)
1033 # only players present at the end of the match are eligible for stats
1034 raw_players = filter(played_in_game, raw_players)
1036 do_precondition_checks(request, game_meta, raw_players)
1038 # the "duel" gametype is fake
1039 if len(raw_players) == 2 \
1040 and num_real_players(raw_players) == 2 \
1041 and game_meta['G'] == 'dm':
1042 game_meta['G'] = 'duel'
1044 #----------------------------------------------------------------------
1045 # Actual setup (inserts/updates) below here
1046 #----------------------------------------------------------------------
1047 session = DBSession()
1049 game_type_cd = game_meta['G']
1051 # All game types create Game, Server, Map, and Player records
1053 server = get_or_create_server(
1056 name = game_meta['S'],
1057 revision = revision,
1058 ip_addr = get_remote_addr(request),
1059 port = game_meta.get('U', None),
1060 impure_cvars = game_meta.get('C', 0))
1062 gmap = get_or_create_map(
1064 name = game_meta['M'])
1068 start_dt = datetime.datetime.utcnow(),
1069 server_id = server.server_id,
1070 game_type_cd = game_type_cd,
1071 map_id = gmap.map_id,
1072 match_id = game_meta['I'],
1073 duration = duration,
1074 mod = game_meta.get('O', None))
1076 # keep track of the players we've seen
1080 for events in raw_players:
1081 player = get_or_create_player(
1083 hashkey = events['P'],
1084 nick = events.get('n', None))
1086 pgstat = create_game_stat(session, game_meta, game, server,
1087 gmap, player, events)
1088 pgstats.append(pgstat)
1090 if player.player_id > 1:
1091 anticheats = create_anticheats(session, pgstat, game, player, events)
1093 if player.player_id > 2:
1094 player_ids.append(player.player_id)
1095 hashkeys[player.player_id] = events['P']
1097 if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
1098 pwstats = create_weapon_stats(session, game_meta, game, player,
1101 # store them on games for easy access
1102 game.players = player_ids
1104 for events in raw_teams:
1106 teamstat = create_team_stat(session, game, events)
1107 except Exception as e:
1110 if server.elo_ind and gametype_elo_eligible(game_type_cd):
1111 ep = EloProcessor(session, game, pgstats)
1115 log.debug('Success! Stats recorded.')
1117 # ranks are fetched after we've done the "real" processing
1118 ranks = get_ranks(session, player_ids, game_type_cd)
1120 # plain text response
1121 request.response.content_type = 'text/plain'
1124 "now" : calendar.timegm(datetime.datetime.utcnow().timetuple()),
1128 "player_ids" : player_ids,
1129 "hashkeys" : hashkeys,
1134 except Exception as e: