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 has_minimum_real_players(settings, submission):
333 Determines if the submission has enough human players to store in the database. The minimum
334 setting comes from the config file under the setting xonstat.minimum_real_players.
337 minimum_required_players = int(settings.get("xonstat.minimum_required_players"))
339 minimum_required_players = 2
341 return len(submission.humans) >= minimum_required_players
344 def do_precondition_checks(settings, submission):
345 """Precondition checks for ALL gametypes. These do not require a database connection."""
346 if not has_required_metadata(submission):
347 msg = "Missing required game metadata"
349 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
351 content_type="text/plain"
354 if submission.version is None:
355 msg = "Invalid or incorrect game metadata provided"
357 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
359 content_type="text/plain"
362 if not is_supported_gametype(submission):
363 msg = "Unsupported game type ({})".format(submission.game_type_cd)
365 raise pyramid.httpexceptions.HTTPOk(
367 content_type="text/plain"
370 if not has_minimum_real_players(settings, submission):
371 msg = "Not enough real players"
373 raise pyramid.httpexceptions.HTTPOk(
375 content_type="text/plain"
378 if is_blank_game(submission):
381 raise pyramid.httpexceptions.HTTPOk(
383 content_type="text/plain"
387 def get_remote_addr(request):
388 """Get the Xonotic server's IP address"""
389 if 'X-Forwarded-For' in request.headers:
390 return request.headers['X-Forwarded-For']
392 return request.remote_addr
395 def should_do_weapon_stats(game_type_cd):
396 """True of the game type should record weapon stats. False otherwise."""
397 return game_type_cd not in {'cts'}
400 def gametype_elo_eligible(game_type_cd):
401 """True of the game type should process Elos. False otherwise."""
402 return game_type_cd in {'duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft'}
405 def register_new_nick(session, player, new_nick):
407 Change the player record's nick to the newly found nick. Store the old
408 nick in the player_nicks table for that player.
410 session - SQLAlchemy database session factory
411 player - player record whose nick is changing
412 new_nick - the new nickname
414 # see if that nick already exists
415 stripped_nick = strip_colors(qfont_decode(player.nick))
417 player_nick = session.query(PlayerNick).filter_by(
418 player_id=player.player_id, stripped_nick=stripped_nick).one()
419 except NoResultFound, e:
420 # player_id/stripped_nick not found, create one
421 # but we don't store "Anonymous Player #N"
422 if not re.search('^Anonymous Player #\d+$', player.nick):
423 player_nick = PlayerNick()
424 player_nick.player_id = player.player_id
425 player_nick.stripped_nick = stripped_nick
426 player_nick.nick = player.nick
427 session.add(player_nick)
429 # We change to the new nick regardless
430 player.nick = new_nick
431 player.stripped_nick = strip_colors(qfont_decode(new_nick))
435 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
437 Check the fastest cap time for the player and map. If there isn't
438 one, insert one. If there is, check if the passed time is faster.
441 # we don't record fastest cap times for bots or anonymous players
445 # see if a cap entry exists already
446 # then check to see if the new captime is faster
448 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
449 player_id=player_id, map_id=map_id, mod=mod).one()
451 # current captime is faster, so update
452 if captime < cur_fastest_cap.fastest_cap:
453 cur_fastest_cap.fastest_cap = captime
454 cur_fastest_cap.game_id = game_id
455 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
456 session.add(cur_fastest_cap)
458 except NoResultFound, e:
459 # none exists, so insert
460 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
462 session.add(cur_fastest_cap)
466 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
468 Updates the server in the given DB session, if needed.
470 :param server: The found server instance.
471 :param name: The incoming server name.
472 :param hashkey: The incoming server hashkey.
473 :param ip_addr: The incoming server IP address.
474 :param port: The incoming server port.
475 :param revision: The incoming server revision.
476 :param impure_cvars: The incoming number of impure server cvars.
479 # ensure the two int attributes are actually ints
486 impure_cvars = int(impure_cvars)
491 if name and server.name != name:
494 if hashkey and server.hashkey != hashkey:
495 server.hashkey = hashkey
497 if ip_addr and server.ip_addr != ip_addr:
498 server.ip_addr = ip_addr
500 if port and server.port != port:
503 if revision and server.revision != revision:
504 server.revision = revision
506 if impure_cvars and server.impure_cvars != impure_cvars:
507 server.impure_cvars = impure_cvars
508 server.pure_ind = True if impure_cvars == 0 else False
514 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
516 Find a server by name or create one if not found. Parameters:
518 session - SQLAlchemy database session factory
519 name - server name of the server to be found or created
520 hashkey - server hashkey
521 ip_addr - the IP address of the server
522 revision - the xonotic revision number
523 port - the port number of the server
524 impure_cvars - the number of impure cvar changes
526 servers_q = DBSession.query(Server).filter(Server.active_ind)
529 # if the hashkey is provided, we'll use that
530 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
532 # otherwise, it is just by name
533 servers_q = servers_q.filter(Server.name == name)
535 # order by the hashkey, which means any hashkey match will appear first if there are multiple
536 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
538 if len(servers) == 0:
539 server = Server(name=name, hashkey=hashkey)
542 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
545 if len(servers) == 1:
546 log.info("Found existing server {}.".format(server.server_id))
548 elif len(servers) > 1:
549 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
550 log.warn("Multiple servers found ({})! Using the first one ({})."
551 .format(server_id_list, server.server_id))
553 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
559 def get_or_create_map(session, name):
561 Find a map by name or create one if not found. Parameters:
563 session - SQLAlchemy database session factory
564 name - map name of the map to be found or created
566 maps = session.query(Map).filter_by(name=name).order_by(Map.map_id).all()
568 if maps is None or len(maps) == 0:
569 gmap = Map(name=name)
572 log.debug("Created map id {}: {}".format(gmap.map_id, gmap.name))
575 log.debug("Found map id {}: {}".format(gmap.map_id, gmap.name))
578 map_id_list = ", ".join(["{}".format(m.map_id) for m in maps])
579 log.warn("Multiple maps found for {} ({})! Using the first one.".format(name, map_id_list))
584 def create_game(session, game_type_cd, server_id, map_id, match_id, start_dt, duration, mod,
587 Creates a game. Parameters:
589 session - SQLAlchemy database session factory
590 game_type_cd - the game type of the game being played
591 mod - mods in use during the game
592 server_id - server identifier of the server hosting the game
593 map_id - map on which the game was played
594 match_id - a unique match ID given by the server
595 start_dt - when the game started (datetime object)
596 duration - how long the game lasted
597 winner - the team id of the team that won
599 seq = Sequence('games_game_id_seq')
600 game_id = session.execute(seq)
601 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd, server_id=server_id,
602 map_id=map_id, winner=winner)
603 game.match_id = match_id
606 # There is some drift between start_dt (provided by app) and create_dt
607 # (default in the database), so we'll make them the same until this is
609 game.create_dt = start_dt
611 game.duration = duration
614 session.query(Game).filter(Game.server_id == server_id)\
615 .filter(Game.match_id == match_id).one()
617 log.debug("Error: game with same server and match_id found! Ignoring.")
619 # if a game under the same server_id and match_id exists, this is a duplicate
620 msg = "Duplicate game (pre-existing match_id)"
622 raise pyramid.httpexceptions.HTTPOk(body=msg, content_type="text/plain")
624 except NoResultFound:
625 # server_id/match_id combination not found. game is ok to insert
628 log.debug("Created game id {} on server {}, map {} at {}"
629 .format(game.game_id, server_id, map_id, start_dt))
634 def get_or_create_player(session=None, hashkey=None, nick=None):
636 Finds a player by hashkey or creates a new one (along with a
637 corresponding hashkey entry. Parameters:
639 session - SQLAlchemy database session factory
640 hashkey - hashkey of the player to be found or created
641 nick - nick of the player (in case of a first time create)
644 if re.search('^bot#\d+', hashkey):
645 player = session.query(Player).filter_by(player_id=1).one()
646 # if we have an untracked player
647 elif re.search('^player#\d+$', hashkey):
648 player = session.query(Player).filter_by(player_id=2).one()
649 # else it is a tracked player
651 # see if the player is already in the database
652 # if not, create one and the hashkey along with it
654 hk = session.query(Hashkey).filter_by(
655 hashkey=hashkey).one()
656 player = session.query(Player).filter_by(
657 player_id=hk.player_id).one()
658 log.debug("Found existing player {0} with hashkey {1}".format(
659 player.player_id, hashkey))
665 # if nick is given to us, use it. If not, use "Anonymous Player"
666 # with a suffix added for uniqueness.
668 player.nick = nick[:128]
669 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
671 player.nick = "Anonymous Player #{0}".format(player.player_id)
672 player.stripped_nick = player.nick
674 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
676 log.debug("Created player {0} ({2}) with hashkey {1}".format(
677 player.player_id, hashkey, player.nick.encode('utf-8')))
682 def create_default_game_stat(session, game_type_cd):
683 """Creates a blanked-out pgstat record for the given game type"""
685 # this is what we have to do to get partitioned records in - grab the
686 # sequence value first, then insert using the explicit ID (vs autogenerate)
687 seq = Sequence('player_game_stats_player_game_stat_id_seq')
688 pgstat_id = session.execute(seq)
689 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
690 create_dt=datetime.datetime.utcnow())
692 if game_type_cd == 'as':
693 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
695 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
696 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
698 if game_type_cd == 'cq':
699 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
702 if game_type_cd == 'ctf':
703 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
704 pgstat.returns = pgstat.carrier_frags = 0
706 if game_type_cd == 'cts':
709 if game_type_cd == 'dom':
710 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
713 if game_type_cd == 'ft':
714 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
716 if game_type_cd == 'ka':
717 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
718 pgstat.carrier_frags = 0
719 pgstat.time = datetime.timedelta(seconds=0)
721 if game_type_cd == 'kh':
722 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
723 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
724 pgstat.carrier_frags = 0
726 if game_type_cd == 'lms':
727 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
729 if game_type_cd == 'nb':
730 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
733 if game_type_cd == 'rc':
734 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
739 def create_game_stat(session, game, gmap, player, events):
740 """Game stats handler for all game types"""
742 game_type_cd = game.game_type_cd
744 pgstat = create_default_game_stat(session, game_type_cd)
746 # these fields should be on every pgstat record
747 pgstat.game_id = game.game_id
748 pgstat.player_id = player.player_id
749 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
750 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
751 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
752 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
753 pgstat.rank = int(events.get('rank', None))
754 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
758 # gametype-specific stuff is handled here. if passed to us, we store it
759 for (key,value) in events.items():
760 if key == 'wins': wins = True
761 if key == 't': pgstat.team = int(value)
763 if key == 'scoreboard-drops': pgstat.drops = int(value)
764 if key == 'scoreboard-returns': pgstat.returns = int(value)
765 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
766 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
767 if key == 'scoreboard-caps': pgstat.captures = int(value)
768 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
769 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
770 if key == 'scoreboard-kills': pgstat.kills = int(value)
771 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
772 if key == 'scoreboard-objectives': pgstat.collects = int(value)
773 if key == 'scoreboard-captured': pgstat.captures = int(value)
774 if key == 'scoreboard-released': pgstat.drops = int(value)
775 if key == 'scoreboard-fastest':
776 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
777 if key == 'scoreboard-takes': pgstat.pickups = int(value)
778 if key == 'scoreboard-ticks': pgstat.drops = int(value)
779 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
780 if key == 'scoreboard-bctime':
781 pgstat.time = datetime.timedelta(seconds=int(value))
782 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
783 if key == 'scoreboard-losses': pgstat.drops = int(value)
784 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
785 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
786 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
787 if key == 'scoreboard-lives': pgstat.lives = int(value)
788 if key == 'scoreboard-goals': pgstat.captures = int(value)
789 if key == 'scoreboard-faults': pgstat.drops = int(value)
790 if key == 'scoreboard-laps': pgstat.laps = int(value)
792 if key == 'avglatency': pgstat.avg_latency = float(value)
793 if key == 'scoreboard-captime':
794 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
795 if game.game_type_cd == 'ctf':
796 update_fastest_cap(session, player.player_id, game.game_id,
797 gmap.map_id, pgstat.fastest, game.mod)
799 # there is no "winning team" field, so we have to derive it
800 if wins and pgstat.team is not None and game.winner is None:
801 game.winner = pgstat.team
809 def create_anticheats(session, pgstat, game, player, events):
810 """Anticheats handler for all game types"""
814 # all anticheat events are prefixed by "anticheat"
815 for (key,value) in events.items():
816 if key.startswith("anticheat"):
818 ac = PlayerGameAnticheat(
824 anticheats.append(ac)
826 except Exception as e:
827 log.debug("Could not parse value for key %s. Ignoring." % key)
832 def create_default_team_stat(session, game_type_cd):
833 """Creates a blanked-out teamstat record for the given game type"""
835 # this is what we have to do to get partitioned records in - grab the
836 # sequence value first, then insert using the explicit ID (vs autogenerate)
837 seq = Sequence('team_game_stats_team_game_stat_id_seq')
838 teamstat_id = session.execute(seq)
839 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
840 create_dt=datetime.datetime.utcnow())
842 # all team game modes have a score, so we'll zero that out always
845 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
848 if game_type_cd == 'ctf':
854 def create_team_stat(session, game, events):
855 """Team stats handler for all game types"""
858 teamstat = create_default_team_stat(session, game.game_type_cd)
859 teamstat.game_id = game.game_id
861 # we should have a team ID if we have a 'Q' event
862 if re.match(r'^team#\d+$', events.get('Q', '')):
863 team = int(events.get('Q').replace('team#', ''))
866 # gametype-specific stuff is handled here. if passed to us, we store it
867 for (key,value) in events.items():
868 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
869 if key == 'scoreboard-caps': teamstat.caps = int(value)
870 if key == 'scoreboard-goals': teamstat.caps = int(value)
871 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
873 session.add(teamstat)
874 except Exception as e:
880 def create_weapon_stats(session, version, game, player, pgstat, events):
881 """Weapon stats handler for all game types"""
884 # Version 1 of stats submissions doubled the data sent.
885 # To counteract this we divide the data by 2 only for
886 # POSTs coming from version 1.
890 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
896 for (key,value) in events.items():
897 matched = re.search("acc-(.*?)-cnt-fired", key)
899 weapon_cd = matched.group(1)
901 # Weapon names changed for 0.8. We'll convert the old
902 # ones to use the new scheme as well.
903 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
905 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
906 pwstat_id = session.execute(seq)
907 pwstat = PlayerWeaponStat()
908 pwstat.player_weapon_stats_id = pwstat_id
909 pwstat.player_id = player.player_id
910 pwstat.game_id = game.game_id
911 pwstat.player_game_stat_id = pgstat.player_game_stat_id
912 pwstat.weapon_cd = mapped_weapon_cd
915 pwstat.nick = events['n']
917 pwstat.nick = events['P']
919 if 'acc-' + weapon_cd + '-cnt-fired' in events:
920 pwstat.fired = int(round(float(
921 events['acc-' + weapon_cd + '-cnt-fired'])))
922 if 'acc-' + weapon_cd + '-fired' in events:
923 pwstat.max = int(round(float(
924 events['acc-' + weapon_cd + '-fired'])))
925 if 'acc-' + weapon_cd + '-cnt-hit' in events:
926 pwstat.hit = int(round(float(
927 events['acc-' + weapon_cd + '-cnt-hit'])))
928 if 'acc-' + weapon_cd + '-hit' in events:
929 pwstat.actual = int(round(float(
930 events['acc-' + weapon_cd + '-hit'])))
931 if 'acc-' + weapon_cd + '-frags' in events:
932 pwstat.frags = int(round(float(
933 events['acc-' + weapon_cd + '-frags'])))
936 pwstat.fired = pwstat.fired/2
937 pwstat.max = pwstat.max/2
938 pwstat.hit = pwstat.hit/2
939 pwstat.actual = pwstat.actual/2
940 pwstat.frags = pwstat.frags/2
943 pwstats.append(pwstat)
948 def get_ranks(session, player_ids, game_type_cd):
950 Gets the rank entries for all players in the given list, returning a dict
951 of player_id -> PlayerRank instance. The rank entry corresponds to the
952 game type of the parameter passed in as well.
955 for pr in session.query(PlayerRank).\
956 filter(PlayerRank.player_id.in_(player_ids)).\
957 filter(PlayerRank.game_type_cd == game_type_cd).\
959 ranks[pr.player_id] = pr
964 def update_player(session, player, events):
966 Updates a player record using the latest information.
967 :param session: SQLAlchemy session
968 :param player: Player model representing what is in the database right now (before updates)
969 :param events: Dict of player events from the submission
972 nick = events.get('n', 'Anonymous Player')[:128]
973 if nick != player.nick and not nick.startswith("Anonymous Player"):
974 register_new_nick(session, player, nick)
979 def create_player(session, events):
981 Creates a new player from the list of events.
982 :param session: SQLAlchemy session
983 :param events: Dict of player events from the submission
990 nick = events.get('n', None)
992 player.nick = nick[:128]
993 player.stripped_nick = strip_colors(qfont_decode(player.nick))
995 player.nick = "Anonymous Player #{0}".format(player.player_id)
996 player.stripped_nick = player.nick
998 hk = Hashkey(player_id=player.player_id, hashkey=events.get('P', None))
1004 def get_or_create_players(session, events_by_hashkey):
1005 hashkeys = set(events_by_hashkey.keys())
1006 players_by_hashkey = {}
1008 bot = session.query(Player).filter(Player.player_id == 1).one()
1009 anon = session.query(Player).filter(Player.player_id == 2).one()
1011 # fill in the bots and anonymous players
1012 for hashkey in events_by_hashkey.keys():
1013 if hashkey.startswith("bot#"):
1014 players_by_hashkey[hashkey] = bot
1015 hashkeys.remove(hashkey)
1016 elif hashkey.startswith("player#"):
1017 players_by_hashkey[hashkey] = anon
1018 hashkeys.remove(hashkey)
1020 # We are left with the "real" players and can now fetch them by their collective hashkeys.
1021 # Those that are returned here are pre-existing players who need to be updated.
1022 for p, hk in session.query(Player, Hashkey)\
1023 .filter(Player.player_id == Hashkey.player_id)\
1024 .filter(Hashkey.hashkey.in_(hashkeys))\
1026 log.debug("Found existing player {} with hashkey {}"
1027 .format(p.player_id, hk.hashkey))
1029 player = update_player(session, p, events_by_hashkey[hk.hashkey])
1030 players_by_hashkey[hk.hashkey] = player
1031 hashkeys.remove(hk.hashkey)
1033 # The remainder are the players we haven't seen before, so we need to create them.
1034 for hashkey in hashkeys:
1035 player = create_player(session, events_by_hashkey[hashkey])
1037 log.debug("Created player {0} ({2}) with hashkey {1}"
1038 .format(player.player_id, hashkey, player.nick.encode('utf-8')))
1040 players_by_hashkey[hashkey] = player
1042 return players_by_hashkey
1045 def submit_stats(request):
1047 Entry handler for POST stats submissions.
1050 # placeholder for the actual session
1053 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1054 "----- END REQUEST BODY -----\n\n")
1056 (idfp, status) = verify_request(request)
1057 submission = Submission(request.body, request.headers)
1059 do_precondition_checks(request.registry.settings, submission)
1061 #----------------------------------------------------------------------
1062 # Actual setup (inserts/updates) below here
1063 #----------------------------------------------------------------------
1064 session = DBSession()
1066 # All game types create Game, Server, Map, and Player records
1068 server = get_or_create_server(
1071 name=submission.server_name,
1072 revision=submission.revision,
1073 ip_addr=get_remote_addr(request),
1074 port=submission.port_number,
1075 impure_cvars=submission.impure_cvar_changes
1078 gmap = get_or_create_map(session, submission.map_name)
1082 game_type_cd=submission.game_type_cd,
1084 server_id=server.server_id,
1086 match_id=submission.match_id,
1087 start_dt=datetime.datetime.utcnow(),
1088 duration=submission.duration
1091 events_by_hashkey = {elem["P"]: elem for elem in submission.humans + submission.bots}
1092 players_by_hashkey = get_or_create_players(session, events_by_hashkey)
1096 hashkeys_by_player_id = {}
1097 for hashkey, player in players_by_hashkey.items():
1098 events = events_by_hashkey[hashkey]
1099 pgstat = create_game_stat(session, game, gmap, player, events)
1100 pgstats.append(pgstat)
1102 if player.player_id > 1:
1103 create_anticheats(session, pgstat, game, player, events)
1105 if player.player_id > 2:
1106 player_ids.append(player.player_id)
1107 hashkeys_by_player_id[player.player_id] = hashkey
1109 if should_do_weapon_stats(submission.game_type_cd) and player.player_id > 1:
1110 create_weapon_stats(session, submission.version, game, player, pgstat, events)
1112 # player_ids for human players get stored directly on games for fast indexing
1113 game.players = player_ids
1115 for events in submission.teams:
1116 create_team_stat(session, game, events)
1118 if server.elo_ind and gametype_elo_eligible(submission.game_type_cd):
1119 ep = EloProcessor(session, game, pgstats)
1123 log.debug('Success! Stats recorded.')
1125 # ranks are fetched after we've done the "real" processing
1126 ranks = get_ranks(session, player_ids, submission.game_type_cd)
1128 # plain text response
1129 request.response.content_type = 'text/plain'
1132 "now": calendar.timegm(datetime.datetime.utcnow().timetuple()),
1136 "player_ids": player_ids,
1137 "hashkeys": hashkeys_by_player_id,
1142 except Exception as e: