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 is_supported_gametype(submission):
299 """Determines if a submission is of a valid and supported game type."""
301 # if the type can be supported, but with version constraints, uncomment
302 # here and add the restriction for a specific version below
303 supported_game_types = (
322 is_supported = submission.game_type_cd in supported_game_types
324 # some game types were buggy before revisions, thus this additional filter
325 if submission.game_type_cd == 'ca' and submission.version <= 5:
331 def get_remote_addr(request):
332 """Get the Xonotic server's IP address"""
333 if 'X-Forwarded-For' in request.headers:
334 return request.headers['X-Forwarded-For']
336 return request.remote_addr
339 def do_precondition_checks(request, game_meta, raw_players):
340 """Precondition checks for ALL gametypes.
341 These do not require a database connection."""
342 if not has_required_metadata(game_meta):
343 msg = "Missing required game metadata"
345 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
347 content_type="text/plain"
351 version = int(game_meta['V'])
353 msg = "Invalid or incorrect game metadata provided"
355 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
357 content_type="text/plain"
360 if not is_supported_gametype(game_meta['G'], version):
361 msg = "Unsupported game type ({})".format(game_meta['G'])
363 raise pyramid.httpexceptions.HTTPOk(
365 content_type="text/plain"
368 if not has_minimum_real_players(request.registry.settings, raw_players):
369 msg = "Not enough real players"
371 raise pyramid.httpexceptions.HTTPOk(
373 content_type="text/plain"
376 if is_blank_game(game_meta['G'], raw_players):
379 raise pyramid.httpexceptions.HTTPOk(
381 content_type="text/plain"
385 def num_real_players(player_events):
387 Returns the number of real players (those who played
388 and are on the scoreboard).
392 for events in player_events:
393 if is_real_player(events) and played_in_game(events):
399 def has_minimum_real_players(settings, player_events):
401 Determines if the collection of player events has enough "real" players
402 to store in the database. The minimum setting comes from the config file
403 under the setting xonstat.minimum_real_players.
405 flg_has_min_real_players = True
408 minimum_required_players = int(
409 settings['xonstat.minimum_required_players'])
411 minimum_required_players = 2
413 real_players = num_real_players(player_events)
415 if real_players < minimum_required_players:
416 flg_has_min_real_players = False
418 return flg_has_min_real_players
421 def should_do_weapon_stats(game_type_cd):
422 """True of the game type should record weapon stats. False otherwise."""
423 if game_type_cd in 'cts':
429 def gametype_elo_eligible(game_type_cd):
430 """True of the game type should process Elos. False otherwise."""
431 elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft')
433 if game_type_cd in elo_game_types:
439 def register_new_nick(session, player, new_nick):
441 Change the player record's nick to the newly found nick. Store the old
442 nick in the player_nicks table for that player.
444 session - SQLAlchemy database session factory
445 player - player record whose nick is changing
446 new_nick - the new nickname
448 # see if that nick already exists
449 stripped_nick = strip_colors(qfont_decode(player.nick))
451 player_nick = session.query(PlayerNick).filter_by(
452 player_id=player.player_id, stripped_nick=stripped_nick).one()
453 except NoResultFound, e:
454 # player_id/stripped_nick not found, create one
455 # but we don't store "Anonymous Player #N"
456 if not re.search('^Anonymous Player #\d+$', player.nick):
457 player_nick = PlayerNick()
458 player_nick.player_id = player.player_id
459 player_nick.stripped_nick = stripped_nick
460 player_nick.nick = player.nick
461 session.add(player_nick)
463 # We change to the new nick regardless
464 player.nick = new_nick
465 player.stripped_nick = strip_colors(qfont_decode(new_nick))
469 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
471 Check the fastest cap time for the player and map. If there isn't
472 one, insert one. If there is, check if the passed time is faster.
475 # we don't record fastest cap times for bots or anonymous players
479 # see if a cap entry exists already
480 # then check to see if the new captime is faster
482 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
483 player_id=player_id, map_id=map_id, mod=mod).one()
485 # current captime is faster, so update
486 if captime < cur_fastest_cap.fastest_cap:
487 cur_fastest_cap.fastest_cap = captime
488 cur_fastest_cap.game_id = game_id
489 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
490 session.add(cur_fastest_cap)
492 except NoResultFound, e:
493 # none exists, so insert
494 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
496 session.add(cur_fastest_cap)
500 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
502 Updates the server in the given DB session, if needed.
504 :param server: The found server instance.
505 :param name: The incoming server name.
506 :param hashkey: The incoming server hashkey.
507 :param ip_addr: The incoming server IP address.
508 :param port: The incoming server port.
509 :param revision: The incoming server revision.
510 :param impure_cvars: The incoming number of impure server cvars.
513 # ensure the two int attributes are actually ints
520 impure_cvars = int(impure_cvars)
525 if name and server.name != name:
528 if hashkey and server.hashkey != hashkey:
529 server.hashkey = hashkey
531 if ip_addr and server.ip_addr != ip_addr:
532 server.ip_addr = ip_addr
534 if port and server.port != port:
537 if revision and server.revision != revision:
538 server.revision = revision
540 if impure_cvars and server.impure_cvars != impure_cvars:
541 server.impure_cvars = impure_cvars
542 server.pure_ind = True if impure_cvars == 0 else False
548 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
550 Find a server by name or create one if not found. Parameters:
552 session - SQLAlchemy database session factory
553 name - server name of the server to be found or created
554 hashkey - server hashkey
555 ip_addr - the IP address of the server
556 revision - the xonotic revision number
557 port - the port number of the server
558 impure_cvars - the number of impure cvar changes
560 servers_q = DBSession.query(Server).filter(Server.active_ind)
563 # if the hashkey is provided, we'll use that
564 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
566 # otherwise, it is just by name
567 servers_q = servers_q.filter(Server.name == name)
569 # order by the hashkey, which means any hashkey match will appear first if there are multiple
570 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
572 if len(servers) == 0:
573 server = Server(name=name, hashkey=hashkey)
576 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
579 if len(servers) == 1:
580 log.info("Found existing server {}.".format(server.server_id))
582 elif len(servers) > 1:
583 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
584 log.warn("Multiple servers found ({})! Using the first one ({})."
585 .format(server_id_list, server.server_id))
587 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
593 def get_or_create_map(session=None, name=None):
595 Find a map by name or create one if not found. Parameters:
597 session - SQLAlchemy database session factory
598 name - map name of the map to be found or created
601 # find one by the name, if it exists
602 gmap = session.query(Map).filter_by(name=name).one()
603 log.debug("Found map id {0}: {1}".format(gmap.map_id,
605 except NoResultFound, e:
606 gmap = Map(name=name)
609 log.debug("Created map id {0}: {1}".format(gmap.map_id,
611 except MultipleResultsFound, e:
612 # multiple found, so use the first one but warn
614 gmaps = session.query(Map).filter_by(name=name).order_by(
617 log.debug("Found map id {0}: {1} but found \
618 multiple".format(gmap.map_id, gmap.name))
623 def create_game(session, start_dt, game_type_cd, server_id, map_id,
624 match_id, duration, mod, winner=None):
626 Creates a game. Parameters:
628 session - SQLAlchemy database session factory
629 start_dt - when the game started (datetime object)
630 game_type_cd - the game type of the game being played
631 server_id - server identifier of the server hosting the game
632 map_id - map on which the game was played
633 winner - the team id of the team that won
634 duration - how long the game lasted
635 mod - mods in use during the game
637 seq = Sequence('games_game_id_seq')
638 game_id = session.execute(seq)
639 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
640 server_id=server_id, map_id=map_id, winner=winner)
641 game.match_id = match_id
644 # There is some drift between start_dt (provided by app) and create_dt
645 # (default in the database), so we'll make them the same until this is
647 game.create_dt = start_dt
650 game.duration = datetime.timedelta(seconds=int(round(float(duration))))
655 session.query(Game).filter(Game.server_id==server_id).\
656 filter(Game.match_id==match_id).one()
658 log.debug("Error: game with same server and match_id found! Ignoring.")
660 # if a game under the same server and match_id found,
661 # this is a duplicate game and can be ignored
662 raise pyramid.httpexceptions.HTTPOk('OK')
663 except NoResultFound, e:
664 # server_id/match_id combination not found. game is ok to insert
667 log.debug("Created game id {0} on server {1}, map {2} at \
668 {3}".format(game.game_id,
669 server_id, map_id, start_dt))
674 def get_or_create_player(session=None, hashkey=None, nick=None):
676 Finds a player by hashkey or creates a new one (along with a
677 corresponding hashkey entry. Parameters:
679 session - SQLAlchemy database session factory
680 hashkey - hashkey of the player to be found or created
681 nick - nick of the player (in case of a first time create)
684 if re.search('^bot#\d+', hashkey):
685 player = session.query(Player).filter_by(player_id=1).one()
686 # if we have an untracked player
687 elif re.search('^player#\d+$', hashkey):
688 player = session.query(Player).filter_by(player_id=2).one()
689 # else it is a tracked player
691 # see if the player is already in the database
692 # if not, create one and the hashkey along with it
694 hk = session.query(Hashkey).filter_by(
695 hashkey=hashkey).one()
696 player = session.query(Player).filter_by(
697 player_id=hk.player_id).one()
698 log.debug("Found existing player {0} with hashkey {1}".format(
699 player.player_id, hashkey))
705 # if nick is given to us, use it. If not, use "Anonymous Player"
706 # with a suffix added for uniqueness.
708 player.nick = nick[:128]
709 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
711 player.nick = "Anonymous Player #{0}".format(player.player_id)
712 player.stripped_nick = player.nick
714 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
716 log.debug("Created player {0} ({2}) with hashkey {1}".format(
717 player.player_id, hashkey, player.nick.encode('utf-8')))
722 def create_default_game_stat(session, game_type_cd):
723 """Creates a blanked-out pgstat record for the given game type"""
725 # this is what we have to do to get partitioned records in - grab the
726 # sequence value first, then insert using the explicit ID (vs autogenerate)
727 seq = Sequence('player_game_stats_player_game_stat_id_seq')
728 pgstat_id = session.execute(seq)
729 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
730 create_dt=datetime.datetime.utcnow())
732 if game_type_cd == 'as':
733 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
735 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
736 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
738 if game_type_cd == 'cq':
739 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
742 if game_type_cd == 'ctf':
743 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
744 pgstat.returns = pgstat.carrier_frags = 0
746 if game_type_cd == 'cts':
749 if game_type_cd == 'dom':
750 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
753 if game_type_cd == 'ft':
754 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
756 if game_type_cd == 'ka':
757 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
758 pgstat.carrier_frags = 0
759 pgstat.time = datetime.timedelta(seconds=0)
761 if game_type_cd == 'kh':
762 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
763 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
764 pgstat.carrier_frags = 0
766 if game_type_cd == 'lms':
767 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
769 if game_type_cd == 'nb':
770 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
773 if game_type_cd == 'rc':
774 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
779 def create_game_stat(session, game_meta, game, server, gmap, player, events):
780 """Game stats handler for all game types"""
782 game_type_cd = game.game_type_cd
784 pgstat = create_default_game_stat(session, game_type_cd)
786 # these fields should be on every pgstat record
787 pgstat.game_id = game.game_id
788 pgstat.player_id = player.player_id
789 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
790 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
791 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
792 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
793 pgstat.rank = int(events.get('rank', None))
794 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
796 if pgstat.nick != player.nick \
797 and player.player_id > 2 \
798 and pgstat.nick != 'Anonymous Player':
799 register_new_nick(session, player, pgstat.nick)
803 # gametype-specific stuff is handled here. if passed to us, we store it
804 for (key,value) in events.items():
805 if key == 'wins': wins = True
806 if key == 't': pgstat.team = int(value)
808 if key == 'scoreboard-drops': pgstat.drops = int(value)
809 if key == 'scoreboard-returns': pgstat.returns = int(value)
810 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
811 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
812 if key == 'scoreboard-caps': pgstat.captures = int(value)
813 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
814 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
815 if key == 'scoreboard-kills': pgstat.kills = int(value)
816 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
817 if key == 'scoreboard-objectives': pgstat.collects = int(value)
818 if key == 'scoreboard-captured': pgstat.captures = int(value)
819 if key == 'scoreboard-released': pgstat.drops = int(value)
820 if key == 'scoreboard-fastest':
821 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
822 if key == 'scoreboard-takes': pgstat.pickups = int(value)
823 if key == 'scoreboard-ticks': pgstat.drops = int(value)
824 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
825 if key == 'scoreboard-bctime':
826 pgstat.time = datetime.timedelta(seconds=int(value))
827 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
828 if key == 'scoreboard-losses': pgstat.drops = int(value)
829 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
830 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
831 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
832 if key == 'scoreboard-lives': pgstat.lives = int(value)
833 if key == 'scoreboard-goals': pgstat.captures = int(value)
834 if key == 'scoreboard-faults': pgstat.drops = int(value)
835 if key == 'scoreboard-laps': pgstat.laps = int(value)
837 if key == 'avglatency': pgstat.avg_latency = float(value)
838 if key == 'scoreboard-captime':
839 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
840 if game.game_type_cd == 'ctf':
841 update_fastest_cap(session, player.player_id, game.game_id,
842 gmap.map_id, pgstat.fastest, game.mod)
844 # there is no "winning team" field, so we have to derive it
845 if wins and pgstat.team is not None and game.winner is None:
846 game.winner = pgstat.team
854 def create_anticheats(session, pgstat, game, player, events):
855 """Anticheats handler for all game types"""
859 # all anticheat events are prefixed by "anticheat"
860 for (key,value) in events.items():
861 if key.startswith("anticheat"):
863 ac = PlayerGameAnticheat(
869 anticheats.append(ac)
871 except Exception as e:
872 log.debug("Could not parse value for key %s. Ignoring." % key)
877 def create_default_team_stat(session, game_type_cd):
878 """Creates a blanked-out teamstat record for the given game type"""
880 # this is what we have to do to get partitioned records in - grab the
881 # sequence value first, then insert using the explicit ID (vs autogenerate)
882 seq = Sequence('team_game_stats_team_game_stat_id_seq')
883 teamstat_id = session.execute(seq)
884 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
885 create_dt=datetime.datetime.utcnow())
887 # all team game modes have a score, so we'll zero that out always
890 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
893 if game_type_cd == 'ctf':
899 def create_team_stat(session, game, events):
900 """Team stats handler for all game types"""
903 teamstat = create_default_team_stat(session, game.game_type_cd)
904 teamstat.game_id = game.game_id
906 # we should have a team ID if we have a 'Q' event
907 if re.match(r'^team#\d+$', events.get('Q', '')):
908 team = int(events.get('Q').replace('team#', ''))
911 # gametype-specific stuff is handled here. if passed to us, we store it
912 for (key,value) in events.items():
913 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
914 if key == 'scoreboard-caps': teamstat.caps = int(value)
915 if key == 'scoreboard-goals': teamstat.caps = int(value)
916 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
918 session.add(teamstat)
919 except Exception as e:
925 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
926 """Weapon stats handler for all game types"""
929 # Version 1 of stats submissions doubled the data sent.
930 # To counteract this we divide the data by 2 only for
931 # POSTs coming from version 1.
933 version = int(game_meta['V'])
936 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
942 for (key,value) in events.items():
943 matched = re.search("acc-(.*?)-cnt-fired", key)
945 weapon_cd = matched.group(1)
947 # Weapon names changed for 0.8. We'll convert the old
948 # ones to use the new scheme as well.
949 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
951 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
952 pwstat_id = session.execute(seq)
953 pwstat = PlayerWeaponStat()
954 pwstat.player_weapon_stats_id = pwstat_id
955 pwstat.player_id = player.player_id
956 pwstat.game_id = game.game_id
957 pwstat.player_game_stat_id = pgstat.player_game_stat_id
958 pwstat.weapon_cd = mapped_weapon_cd
961 pwstat.nick = events['n']
963 pwstat.nick = events['P']
965 if 'acc-' + weapon_cd + '-cnt-fired' in events:
966 pwstat.fired = int(round(float(
967 events['acc-' + weapon_cd + '-cnt-fired'])))
968 if 'acc-' + weapon_cd + '-fired' in events:
969 pwstat.max = int(round(float(
970 events['acc-' + weapon_cd + '-fired'])))
971 if 'acc-' + weapon_cd + '-cnt-hit' in events:
972 pwstat.hit = int(round(float(
973 events['acc-' + weapon_cd + '-cnt-hit'])))
974 if 'acc-' + weapon_cd + '-hit' in events:
975 pwstat.actual = int(round(float(
976 events['acc-' + weapon_cd + '-hit'])))
977 if 'acc-' + weapon_cd + '-frags' in events:
978 pwstat.frags = int(round(float(
979 events['acc-' + weapon_cd + '-frags'])))
982 pwstat.fired = pwstat.fired/2
983 pwstat.max = pwstat.max/2
984 pwstat.hit = pwstat.hit/2
985 pwstat.actual = pwstat.actual/2
986 pwstat.frags = pwstat.frags/2
989 pwstats.append(pwstat)
994 def get_ranks(session, player_ids, game_type_cd):
996 Gets the rank entries for all players in the given list, returning a dict
997 of player_id -> PlayerRank instance. The rank entry corresponds to the
998 game type of the parameter passed in as well.
1001 for pr in session.query(PlayerRank).\
1002 filter(PlayerRank.player_id.in_(player_ids)).\
1003 filter(PlayerRank.game_type_cd == game_type_cd).\
1005 ranks[pr.player_id] = pr
1010 def submit_stats(request):
1012 Entry handler for POST stats submissions.
1015 # placeholder for the actual session
1018 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1019 "----- END REQUEST BODY -----\n\n")
1021 (idfp, status) = verify_request(request)
1022 (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
1023 revision = game_meta.get('R', 'unknown')
1024 duration = game_meta.get('D', None)
1026 # only players present at the end of the match are eligible for stats
1027 raw_players = filter(played_in_game, raw_players)
1029 do_precondition_checks(request, game_meta, raw_players)
1031 # the "duel" gametype is fake
1032 if len(raw_players) == 2 \
1033 and num_real_players(raw_players) == 2 \
1034 and game_meta['G'] == 'dm':
1035 game_meta['G'] = 'duel'
1037 #----------------------------------------------------------------------
1038 # Actual setup (inserts/updates) below here
1039 #----------------------------------------------------------------------
1040 session = DBSession()
1042 game_type_cd = game_meta['G']
1044 # All game types create Game, Server, Map, and Player records
1046 server = get_or_create_server(
1049 name = game_meta['S'],
1050 revision = revision,
1051 ip_addr = get_remote_addr(request),
1052 port = game_meta.get('U', None),
1053 impure_cvars = game_meta.get('C', 0))
1055 gmap = get_or_create_map(
1057 name = game_meta['M'])
1061 start_dt = datetime.datetime.utcnow(),
1062 server_id = server.server_id,
1063 game_type_cd = game_type_cd,
1064 map_id = gmap.map_id,
1065 match_id = game_meta['I'],
1066 duration = duration,
1067 mod = game_meta.get('O', None))
1069 # keep track of the players we've seen
1073 for events in raw_players:
1074 player = get_or_create_player(
1076 hashkey = events['P'],
1077 nick = events.get('n', None))
1079 pgstat = create_game_stat(session, game_meta, game, server,
1080 gmap, player, events)
1081 pgstats.append(pgstat)
1083 if player.player_id > 1:
1084 anticheats = create_anticheats(session, pgstat, game, player, events)
1086 if player.player_id > 2:
1087 player_ids.append(player.player_id)
1088 hashkeys[player.player_id] = events['P']
1090 if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
1091 pwstats = create_weapon_stats(session, game_meta, game, player,
1094 # store them on games for easy access
1095 game.players = player_ids
1097 for events in raw_teams:
1099 teamstat = create_team_stat(session, game, events)
1100 except Exception as e:
1103 if server.elo_ind and gametype_elo_eligible(game_type_cd):
1104 ep = EloProcessor(session, game, pgstats)
1108 log.debug('Success! Stats recorded.')
1110 # ranks are fetched after we've done the "real" processing
1111 ranks = get_ranks(session, player_ids, game_type_cd)
1113 # plain text response
1114 request.response.content_type = 'text/plain'
1117 "now" : calendar.timegm(datetime.datetime.utcnow().timetuple()),
1121 "player_ids" : player_ids,
1122 "hashkeys" : hashkeys,
1127 except Exception as e: