7 import pyramid.httpexceptions
8 from sqlalchemy import Sequence
9 from sqlalchemy.orm.exc import 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, PlayerGameFragMatrix
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 # player indexes for those who played
83 self.player_indexes = set()
85 # distinct weapons that we have seen fired
88 # has a human player fired a shot?
89 self.human_fired_weapon = False
91 # does any human have a non-zero score?
92 self.human_nonzero_score = False
94 # does any human have a fastest cap?
95 self.human_fastest = False
100 """Returns the next key:value pair off the queue."""
102 items = self.q.popleft().strip().split(' ', 1)
104 # Some keys won't have values, like 'L' records where the server isn't actually
105 # participating in any ladders. These can be safely ignored.
112 def add_weapon_fired(self, sub_key):
113 """Adds a weapon to the set of weapons fired during the match (a set)."""
114 self.weapons.add(sub_key.split("-")[1])
117 def is_human_player(player):
119 Determines if a given set of events correspond with a non-bot
121 return not player['P'].startswith('bot')
124 def played_in_game(player):
126 Determines if a given set of player events correspond with a player who
127 played in the game (matches 1 and scoreboardvalid 1)
129 return 'matches' in player and 'scoreboardvalid' in player
131 def parse_player(self, key, pid):
132 """Construct a player events listing from the submission."""
134 # all of the keys related to player records
135 player_keys = ['i', 'n', 't', 'r', 'e']
139 player_fired_weapon = False
140 player_nonzero_score = False
141 player_fastest = False
143 # Consume all following 'i' 'n' 't' 'e' records
144 while len(self.q) > 0:
145 (key, value) = self.next_item()
146 if key is None and value is None:
149 (sub_key, sub_value) = value.split(' ', 1)
150 player[sub_key] = sub_value
152 if sub_key.endswith("cnt-fired"):
153 player_fired_weapon = True
154 self.add_weapon_fired(sub_key)
155 elif sub_key == 'scoreboard-score' and int(round(float(sub_value))) != 0:
156 player_nonzero_score = True
157 elif sub_key == 'scoreboard-fastest':
158 player_fastest = True
160 player[key] = unicode(value, 'utf-8')
161 elif key in player_keys:
164 # something we didn't expect - put it back on the deque
165 self.q.appendleft("{} {}".format(key, value))
168 played = self.played_in_game(player)
169 human = self.is_human_player(player)
172 self.player_indexes.add(int(player["i"]))
175 self.humans.append(player)
177 if player_fired_weapon:
178 self.human_fired_weapon = True
180 if player_nonzero_score:
181 self.human_nonzero_score = True
184 self.human_fastest = True
186 elif played and not human:
187 self.bots.append(player)
189 self.players.append(player)
191 def parse_team(self, key, tid):
192 """Construct a team events listing from the submission."""
195 # Consume all following 'e' records
196 while len(self.q) > 0 and self.q[0].startswith('e'):
197 (_, value) = self.next_item()
198 (sub_key, sub_value) = value.split(' ', 1)
199 team[sub_key] = sub_value
201 self.teams.append(team)
204 """Parses the request body into instance variables."""
205 while len(self.q) > 0:
206 (key, value) = self.next_item()
207 if key is None and value is None:
212 self.revision = value
214 self.game_type_cd = value
218 self.map_name = value
220 self.match_id = value
222 self.server_name = unicode(value, 'utf-8')
224 self.impure_cvar_changes = int(value)
226 self.port_number = int(value)
228 self.duration = datetime.timedelta(seconds=int(round(float(value))))
232 self.parse_team(key, value)
234 self.parse_player(key, value)
236 raise Exception("Invalid submission")
241 """Debugging representation of a submission."""
242 return "game_type_cd: {}, mod: {}, players: {}, humans: {}, bots: {}, weapons: {}".format(
243 self.game_type_cd, self.mod, len(self.players), len(self.humans), len(self.bots),
247 def elo_submission_category(submission):
248 """Determines the Elo category purely by what is in the submission data."""
251 vanilla_allowed_weapons = {"shotgun", "devastator", "blaster", "mortar", "vortex", "electro",
252 "arc", "hagar", "crylink", "machinegun"}
253 insta_allowed_weapons = {"vaporizer", "blaster"}
254 overkill_allowed_weapons = {"hmg", "vortex", "shotgun", "blaster", "machinegun", "rpc"}
257 if len(submission.weapons - vanilla_allowed_weapons) == 0:
259 elif mod == "InstaGib":
260 if len(submission.weapons - insta_allowed_weapons) == 0:
262 elif mod == "Overkill":
263 if len(submission.weapons - overkill_allowed_weapons) == 0:
271 def is_blank_game(submission):
273 Determine if this is a blank game or not. A blank game is either:
275 1) a match that ended in the warmup stage, where accuracy events are not
276 present (for non-CTS games)
278 2) a match in which no player made a positive or negative score AND was
281 ... or for CTS, which doesn't record accuracy events
283 1) a match in which no player made a fastest lap AND was
286 ... or for NB, in which not all maps have weapons
288 1) a match in which no player made a positive or negative score
290 if submission.game_type_cd == 'cts':
291 return not submission.human_fastest
292 elif submission.game_type_cd == 'nb':
293 return not submission.human_nonzero_score
295 return not (submission.human_nonzero_score and submission.human_fired_weapon)
298 def has_required_metadata(submission):
299 """Determines if a submission has all the required metadata fields."""
300 return (submission.game_type_cd is not None
301 and submission.map_name is not None
302 and submission.match_id is not None
303 and submission.server_name is not None)
306 def is_supported_gametype(submission):
307 """Determines if a submission is of a valid and supported game type."""
309 # if the type can be supported, but with version constraints, uncomment
310 # here and add the restriction for a specific version below
311 supported_game_types = (
330 is_supported = submission.game_type_cd in supported_game_types
332 # some game types were buggy before revisions, thus this additional filter
333 if submission.game_type_cd == 'ca' and submission.version <= 5:
339 def has_minimum_real_players(settings, submission):
341 Determines if the submission has enough human players to store in the database. The minimum
342 setting comes from the config file under the setting xonstat.minimum_real_players.
345 minimum_required_players = int(settings.get("xonstat.minimum_required_players"))
347 minimum_required_players = 2
349 return len(submission.humans) >= minimum_required_players
352 def do_precondition_checks(settings, submission):
353 """Precondition checks for ALL gametypes. These do not require a database connection."""
354 if not has_required_metadata(submission):
355 msg = "Missing required game metadata"
357 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
359 content_type="text/plain"
362 if submission.version is None:
363 msg = "Invalid or incorrect game metadata provided"
365 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
367 content_type="text/plain"
370 if not is_supported_gametype(submission):
371 msg = "Unsupported game type ({})".format(submission.game_type_cd)
373 raise pyramid.httpexceptions.HTTPOk(
375 content_type="text/plain"
378 if not has_minimum_real_players(settings, submission):
379 msg = "Not enough real players"
381 raise pyramid.httpexceptions.HTTPOk(
383 content_type="text/plain"
386 if is_blank_game(submission):
389 raise pyramid.httpexceptions.HTTPOk(
391 content_type="text/plain"
395 def get_remote_addr(request):
396 """Get the Xonotic server's IP address"""
397 if 'X-Forwarded-For' in request.headers:
398 return request.headers['X-Forwarded-For']
400 return request.remote_addr
403 def should_do_weapon_stats(game_type_cd):
404 """True of the game type should record weapon stats. False otherwise."""
405 return game_type_cd not in {'cts'}
408 def gametype_elo_eligible(game_type_cd):
409 """True of the game type should process Elos. False otherwise."""
410 return game_type_cd in {'duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft'}
413 def register_new_nick(session, player, new_nick):
415 Change the player record's nick to the newly found nick. Store the old
416 nick in the player_nicks table for that player.
418 session - SQLAlchemy database session factory
419 player - player record whose nick is changing
420 new_nick - the new nickname
422 # see if that nick already exists
423 stripped_nick = strip_colors(qfont_decode(player.nick))
425 player_nick = session.query(PlayerNick).filter_by(
426 player_id=player.player_id, stripped_nick=stripped_nick).one()
427 except NoResultFound, e:
428 # player_id/stripped_nick not found, create one
429 # but we don't store "Anonymous Player #N"
430 if not re.search('^Anonymous Player #\d+$', player.nick):
431 player_nick = PlayerNick()
432 player_nick.player_id = player.player_id
433 player_nick.stripped_nick = stripped_nick
434 player_nick.nick = player.nick
435 session.add(player_nick)
437 # We change to the new nick regardless
438 player.nick = new_nick
439 player.stripped_nick = strip_colors(qfont_decode(new_nick))
443 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
445 Check the fastest cap time for the player and map. If there isn't
446 one, insert one. If there is, check if the passed time is faster.
449 # we don't record fastest cap times for bots or anonymous players
453 # see if a cap entry exists already
454 # then check to see if the new captime is faster
456 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
457 player_id=player_id, map_id=map_id, mod=mod).one()
459 # current captime is faster, so update
460 if captime < cur_fastest_cap.fastest_cap:
461 cur_fastest_cap.fastest_cap = captime
462 cur_fastest_cap.game_id = game_id
463 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
464 session.add(cur_fastest_cap)
466 except NoResultFound, e:
467 # none exists, so insert
468 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
470 session.add(cur_fastest_cap)
474 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
476 Updates the server in the given DB session, if needed.
478 :param server: The found server instance.
479 :param name: The incoming server name.
480 :param hashkey: The incoming server hashkey.
481 :param ip_addr: The incoming server IP address.
482 :param port: The incoming server port.
483 :param revision: The incoming server revision.
484 :param impure_cvars: The incoming number of impure server cvars.
487 # ensure the two int attributes are actually ints
494 impure_cvars = int(impure_cvars)
499 if name and server.name != name:
502 if hashkey and server.hashkey != hashkey:
503 server.hashkey = hashkey
505 if ip_addr and server.ip_addr != ip_addr:
506 server.ip_addr = ip_addr
508 if port and server.port != port:
511 if revision and server.revision != revision:
512 server.revision = revision
514 if impure_cvars and server.impure_cvars != impure_cvars:
515 server.impure_cvars = impure_cvars
516 server.pure_ind = True if impure_cvars == 0 else False
522 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
524 Find a server by name or create one if not found. Parameters:
526 session - SQLAlchemy database session factory
527 name - server name of the server to be found or created
528 hashkey - server hashkey
529 ip_addr - the IP address of the server
530 revision - the xonotic revision number
531 port - the port number of the server
532 impure_cvars - the number of impure cvar changes
534 servers_q = DBSession.query(Server).filter(Server.active_ind)
537 # if the hashkey is provided, we'll use that
538 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
540 # otherwise, it is just by name
541 servers_q = servers_q.filter(Server.name == name)
543 # order by the hashkey, which means any hashkey match will appear first if there are multiple
544 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
546 if len(servers) == 0:
547 server = Server(name=name, hashkey=hashkey)
550 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
553 if len(servers) == 1:
554 log.info("Found existing server {}.".format(server.server_id))
556 elif len(servers) > 1:
557 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
558 log.warn("Multiple servers found ({})! Using the first one ({})."
559 .format(server_id_list, server.server_id))
561 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
567 def get_or_create_map(session, name):
569 Find a map by name or create one if not found. Parameters:
571 session - SQLAlchemy database session factory
572 name - map name of the map to be found or created
574 maps = session.query(Map).filter_by(name=name).order_by(Map.map_id).all()
576 if maps is None or len(maps) == 0:
577 gmap = Map(name=name)
580 log.debug("Created map id {}: {}".format(gmap.map_id, gmap.name))
583 log.debug("Found map id {}: {}".format(gmap.map_id, gmap.name))
586 map_id_list = ", ".join(["{}".format(m.map_id) for m in maps])
587 log.warn("Multiple maps found for {} ({})! Using the first one.".format(name, map_id_list))
592 def create_game(session, game_type_cd, server_id, map_id, match_id, start_dt, duration, mod,
595 Creates a game. Parameters:
597 session - SQLAlchemy database session factory
598 game_type_cd - the game type of the game being played
599 mod - mods in use during the game
600 server_id - server identifier of the server hosting the game
601 map_id - map on which the game was played
602 match_id - a unique match ID given by the server
603 start_dt - when the game started (datetime object)
604 duration - how long the game lasted
605 winner - the team id of the team that won
607 seq = Sequence('games_game_id_seq')
608 game_id = session.execute(seq)
609 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd, server_id=server_id,
610 map_id=map_id, winner=winner)
611 game.match_id = match_id
614 # There is some drift between start_dt (provided by app) and create_dt
615 # (default in the database), so we'll make them the same until this is
617 game.create_dt = start_dt
619 game.duration = duration
622 session.query(Game).filter(Game.server_id == server_id)\
623 .filter(Game.match_id == match_id).one()
625 log.debug("Error: game with same server and match_id found! Ignoring.")
627 # if a game under the same server_id and match_id exists, this is a duplicate
628 msg = "Duplicate game (pre-existing match_id)"
630 raise pyramid.httpexceptions.HTTPOk(body=msg, content_type="text/plain")
632 except NoResultFound:
633 # server_id/match_id combination not found. game is ok to insert
636 log.debug("Created game id {} on server {}, map {} at {}"
637 .format(game.game_id, server_id, map_id, start_dt))
642 def get_or_create_player(session=None, hashkey=None, nick=None):
644 Finds a player by hashkey or creates a new one (along with a
645 corresponding hashkey entry. Parameters:
647 session - SQLAlchemy database session factory
648 hashkey - hashkey of the player to be found or created
649 nick - nick of the player (in case of a first time create)
652 if re.search('^bot#\d+', hashkey):
653 player = session.query(Player).filter_by(player_id=1).one()
654 # if we have an untracked player
655 elif re.search('^player#\d+$', hashkey):
656 player = session.query(Player).filter_by(player_id=2).one()
657 # else it is a tracked player
659 # see if the player is already in the database
660 # if not, create one and the hashkey along with it
662 hk = session.query(Hashkey).filter_by(
663 hashkey=hashkey).one()
664 player = session.query(Player).filter_by(
665 player_id=hk.player_id).one()
666 log.debug("Found existing player {0} with hashkey {1}".format(
667 player.player_id, hashkey))
673 # if nick is given to us, use it. If not, use "Anonymous Player"
674 # with a suffix added for uniqueness.
676 player.nick = nick[:128]
677 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
679 player.nick = "Anonymous Player #{0}".format(player.player_id)
680 player.stripped_nick = player.nick
682 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
684 log.debug("Created player {0} ({2}) with hashkey {1}".format(
685 player.player_id, hashkey, player.nick.encode('utf-8')))
690 def create_default_game_stat(session, game_type_cd):
691 """Creates a blanked-out pgstat record for the given game type"""
693 # this is what we have to do to get partitioned records in - grab the
694 # sequence value first, then insert using the explicit ID (vs autogenerate)
695 seq = Sequence('player_game_stats_player_game_stat_id_seq')
696 pgstat_id = session.execute(seq)
697 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
698 create_dt=datetime.datetime.utcnow())
700 if game_type_cd == 'as':
701 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
703 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
704 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
706 if game_type_cd == 'cq':
707 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
710 if game_type_cd == 'ctf':
711 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
712 pgstat.returns = pgstat.carrier_frags = 0
714 if game_type_cd == 'cts':
717 if game_type_cd == 'dom':
718 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
721 if game_type_cd == 'ft':
722 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
724 if game_type_cd == 'ka':
725 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
726 pgstat.carrier_frags = 0
727 pgstat.time = datetime.timedelta(seconds=0)
729 if game_type_cd == 'kh':
730 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
731 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
732 pgstat.carrier_frags = 0
734 if game_type_cd == 'lms':
735 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
737 if game_type_cd == 'nb':
738 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
741 if game_type_cd == 'rc':
742 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
747 def create_game_stat(session, game, gmap, player, events):
748 """Game stats handler for all game types"""
750 game_type_cd = game.game_type_cd
752 pgstat = create_default_game_stat(session, game_type_cd)
754 # these fields should be on every pgstat record
755 pgstat.game_id = game.game_id
756 pgstat.player_id = player.player_id
757 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
758 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
759 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
760 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
761 pgstat.rank = int(events.get('rank', None))
762 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
766 # gametype-specific stuff is handled here. if passed to us, we store it
767 for (key,value) in events.items():
768 if key == 'wins': wins = True
769 if key == 't': pgstat.team = int(value)
771 if key == 'scoreboard-drops': pgstat.drops = int(value)
772 if key == 'scoreboard-returns': pgstat.returns = int(value)
773 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
774 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
775 if key == 'scoreboard-caps': pgstat.captures = int(value)
776 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
777 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
778 if key == 'scoreboard-kills': pgstat.kills = int(value)
779 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
780 if key == 'scoreboard-objectives': pgstat.collects = int(value)
781 if key == 'scoreboard-captured': pgstat.captures = int(value)
782 if key == 'scoreboard-released': pgstat.drops = int(value)
783 if key == 'scoreboard-fastest':
784 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
785 if key == 'scoreboard-takes': pgstat.pickups = int(value)
786 if key == 'scoreboard-ticks': pgstat.drops = int(value)
787 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
788 if key == 'scoreboard-bctime':
789 pgstat.time = datetime.timedelta(seconds=int(value))
790 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
791 if key == 'scoreboard-losses': pgstat.drops = int(value)
792 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
793 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
794 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
795 if key == 'scoreboard-lives': pgstat.lives = int(value)
796 if key == 'scoreboard-goals': pgstat.captures = int(value)
797 if key == 'scoreboard-faults': pgstat.drops = int(value)
798 if key == 'scoreboard-laps': pgstat.laps = int(value)
800 if key == 'avglatency': pgstat.avg_latency = float(value)
801 if key == 'scoreboard-captime':
802 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
803 if game.game_type_cd == 'ctf':
804 update_fastest_cap(session, player.player_id, game.game_id,
805 gmap.map_id, pgstat.fastest, game.mod)
807 # there is no "winning team" field, so we have to derive it
808 if wins and pgstat.team is not None and game.winner is None:
809 game.winner = pgstat.team
817 def create_anticheats(session, pgstat, game, player, events):
818 """Anticheats handler for all game types"""
822 # all anticheat events are prefixed by "anticheat"
823 for (key,value) in events.items():
824 if key.startswith("anticheat"):
826 ac = PlayerGameAnticheat(
832 anticheats.append(ac)
834 except Exception as e:
835 log.debug("Could not parse value for key %s. Ignoring." % key)
840 def create_default_team_stat(session, game_type_cd):
841 """Creates a blanked-out teamstat record for the given game type"""
843 # this is what we have to do to get partitioned records in - grab the
844 # sequence value first, then insert using the explicit ID (vs autogenerate)
845 seq = Sequence('team_game_stats_team_game_stat_id_seq')
846 teamstat_id = session.execute(seq)
847 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
848 create_dt=datetime.datetime.utcnow())
850 # all team game modes have a score, so we'll zero that out always
853 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
856 if game_type_cd == 'ctf':
862 def create_team_stat(session, game, events):
863 """Team stats handler for all game types"""
866 teamstat = create_default_team_stat(session, game.game_type_cd)
867 teamstat.game_id = game.game_id
869 # we should have a team ID if we have a 'Q' event
870 if re.match(r'^team#\d+$', events.get('Q', '')):
871 team = int(events.get('Q').replace('team#', ''))
874 # gametype-specific stuff is handled here. if passed to us, we store it
875 for (key,value) in events.items():
876 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
877 if key == 'scoreboard-caps': teamstat.caps = int(value)
878 if key == 'scoreboard-goals': teamstat.caps = int(value)
879 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
881 session.add(teamstat)
882 except Exception as e:
888 def create_weapon_stats(session, version, game, player, pgstat, events):
889 """Weapon stats handler for all game types"""
892 # Version 1 of stats submissions doubled the data sent.
893 # To counteract this we divide the data by 2 only for
894 # POSTs coming from version 1.
898 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
904 for (key,value) in events.items():
905 matched = re.search("acc-(.*?)-cnt-fired", key)
907 weapon_cd = matched.group(1)
909 # Weapon names changed for 0.8. We'll convert the old
910 # ones to use the new scheme as well.
911 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
913 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
914 pwstat_id = session.execute(seq)
915 pwstat = PlayerWeaponStat()
916 pwstat.player_weapon_stats_id = pwstat_id
917 pwstat.player_id = player.player_id
918 pwstat.game_id = game.game_id
919 pwstat.player_game_stat_id = pgstat.player_game_stat_id
920 pwstat.weapon_cd = mapped_weapon_cd
923 pwstat.nick = events['n']
925 pwstat.nick = events['P']
927 if 'acc-' + weapon_cd + '-cnt-fired' in events:
928 pwstat.fired = int(round(float(
929 events['acc-' + weapon_cd + '-cnt-fired'])))
930 if 'acc-' + weapon_cd + '-fired' in events:
931 pwstat.max = int(round(float(
932 events['acc-' + weapon_cd + '-fired'])))
933 if 'acc-' + weapon_cd + '-cnt-hit' in events:
934 pwstat.hit = int(round(float(
935 events['acc-' + weapon_cd + '-cnt-hit'])))
936 if 'acc-' + weapon_cd + '-hit' in events:
937 pwstat.actual = int(round(float(
938 events['acc-' + weapon_cd + '-hit'])))
939 if 'acc-' + weapon_cd + '-frags' in events:
940 pwstat.frags = int(round(float(
941 events['acc-' + weapon_cd + '-frags'])))
944 pwstat.fired = pwstat.fired/2
945 pwstat.max = pwstat.max/2
946 pwstat.hit = pwstat.hit/2
947 pwstat.actual = pwstat.actual/2
948 pwstat.frags = pwstat.frags/2
951 pwstats.append(pwstat)
956 def get_ranks(session, player_ids, game_type_cd):
958 Gets the rank entries for all players in the given list, returning a dict
959 of player_id -> PlayerRank instance. The rank entry corresponds to the
960 game type of the parameter passed in as well.
963 for pr in session.query(PlayerRank).\
964 filter(PlayerRank.player_id.in_(player_ids)).\
965 filter(PlayerRank.game_type_cd == game_type_cd).\
967 ranks[pr.player_id] = pr
972 def update_player(session, player, events):
974 Updates a player record using the latest information.
975 :param session: SQLAlchemy session
976 :param player: Player model representing what is in the database right now (before updates)
977 :param events: Dict of player events from the submission
980 nick = events.get('n', 'Anonymous Player')[:128]
981 if nick != player.nick and not nick.startswith("Anonymous Player"):
982 register_new_nick(session, player, nick)
987 def create_player(session, events):
989 Creates a new player from the list of events.
990 :param session: SQLAlchemy session
991 :param events: Dict of player events from the submission
998 nick = events.get('n', None)
1000 player.nick = nick[:128]
1001 player.stripped_nick = strip_colors(qfont_decode(player.nick))
1003 player.nick = "Anonymous Player #{0}".format(player.player_id)
1004 player.stripped_nick = player.nick
1006 hk = Hashkey(player_id=player.player_id, hashkey=events.get('P', None))
1012 def get_or_create_players(session, events_by_hashkey):
1013 hashkeys = set(events_by_hashkey.keys())
1014 players_by_hashkey = {}
1016 bot = session.query(Player).filter(Player.player_id == 1).one()
1017 anon = session.query(Player).filter(Player.player_id == 2).one()
1019 # fill in the bots and anonymous players
1020 for hashkey in events_by_hashkey.keys():
1021 if hashkey.startswith("bot#"):
1022 players_by_hashkey[hashkey] = bot
1023 hashkeys.remove(hashkey)
1024 elif hashkey.startswith("player#"):
1025 players_by_hashkey[hashkey] = anon
1026 hashkeys.remove(hashkey)
1028 # We are left with the "real" players and can now fetch them by their collective hashkeys.
1029 # Those that are returned here are pre-existing players who need to be updated.
1030 for p, hk in session.query(Player, Hashkey)\
1031 .filter(Player.player_id == Hashkey.player_id)\
1032 .filter(Hashkey.hashkey.in_(hashkeys))\
1034 log.debug("Found existing player {} with hashkey {}"
1035 .format(p.player_id, hk.hashkey))
1037 player = update_player(session, p, events_by_hashkey[hk.hashkey])
1038 players_by_hashkey[hk.hashkey] = player
1039 hashkeys.remove(hk.hashkey)
1041 # The remainder are the players we haven't seen before, so we need to create them.
1042 for hashkey in hashkeys:
1043 player = create_player(session, events_by_hashkey[hashkey])
1045 log.debug("Created player {0} ({2}) with hashkey {1}"
1046 .format(player.player_id, hashkey, player.nick.encode('utf-8')))
1048 players_by_hashkey[hashkey] = player
1050 return players_by_hashkey
1053 def create_frag_matrix(session, player_indexes, pgstat, events):
1055 Construct a PlayerFragMatrix object from the events of a given player.
1057 :param session: The DBSession we're adding objects to.
1058 :param player_indexes: The set of player indexes of those that actually played in the game.
1059 :param pgstat: The PlayerGameStat object of the player whose frag matrix we want to create.
1060 :param events: The raw player events of the above player.
1061 :return: PlayerFragMatrix
1063 player_index = int(events.get("i", None))
1066 victim_index = lambda x: int(x.split("-")[1])
1068 matrix = {victim_index(k): int(v) for (k, v) in events.items()
1069 if k.startswith("kills-") and victim_index(k) in player_indexes}
1072 pfm = PlayerGameFragMatrix(pgstat.game_id, pgstat.player_game_stat_id, pgstat.player_id,
1073 player_index, matrix)
1081 def submit_stats(request):
1083 Entry handler for POST stats submissions.
1085 # placeholder for the actual session
1089 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1090 "----- END REQUEST BODY -----\n\n")
1092 (idfp, status) = verify_request(request)
1094 submission = Submission(request.body, request.headers)
1096 msg = "Invalid submission"
1098 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
1100 content_type="text/plain"
1103 do_precondition_checks(request.registry.settings, submission)
1105 #######################################################################
1106 # Actual setup (inserts/updates) below here
1107 #######################################################################
1108 session = DBSession()
1110 # All game types create Game, Server, Map, and Player records
1112 server = get_or_create_server(
1115 name=submission.server_name,
1116 revision=submission.revision,
1117 ip_addr=get_remote_addr(request),
1118 port=submission.port_number,
1119 impure_cvars=submission.impure_cvar_changes
1122 gmap = get_or_create_map(session, submission.map_name)
1126 game_type_cd=submission.game_type_cd,
1128 server_id=server.server_id,
1130 match_id=submission.match_id,
1131 start_dt=datetime.datetime.utcnow(),
1132 duration=submission.duration
1135 events_by_hashkey = {elem["P"]: elem for elem in submission.humans + submission.bots}
1136 players_by_hashkey = get_or_create_players(session, events_by_hashkey)
1141 hashkeys_by_player_id = {}
1142 for hashkey, player in players_by_hashkey.items():
1143 events = events_by_hashkey[hashkey]
1145 pgstat = create_game_stat(session, game, gmap, player, events)
1146 pgstats.append(pgstat)
1148 frag_matrix = create_frag_matrix(session, submission.player_indexes, pgstat, events)
1150 # player ranking opt-out
1151 if 'r' in events and events['r'] != '0':
1152 elo_pgstats.append(pgstat)
1154 if player.player_id > 1:
1155 create_anticheats(session, pgstat, game, player, events)
1157 if player.player_id > 2:
1158 player_ids.append(player.player_id)
1159 hashkeys_by_player_id[player.player_id] = hashkey
1161 if should_do_weapon_stats(submission.game_type_cd) and player.player_id > 1:
1162 create_weapon_stats(session, submission.version, game, player, pgstat, events)
1164 # player_ids for human players get stored directly on games for fast indexing
1165 game.players = player_ids
1167 for events in submission.teams:
1168 create_team_stat(session, game, events)
1170 if server.elo_ind and gametype_elo_eligible(submission.game_type_cd):
1171 ep = EloProcessor(session, game, elo_pgstats)
1178 log.debug('Success! Stats recorded.')
1180 # ranks are fetched after we've done the "real" processing
1181 ranks = get_ranks(session, player_ids, submission.game_type_cd)
1183 # plain text response
1184 request.response.content_type = 'text/plain'
1187 "now": calendar.timegm(datetime.datetime.utcnow().timetuple()),
1191 "player_ids": player_ids,
1192 "hashkeys": hashkeys_by_player_id,
1197 except Exception as e: