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.glicko import GlickoProcessor
12 from xonstat.models import DBSession, Server, Map, Game, PlayerGameStat, PlayerWeaponStat
13 from xonstat.models import PlayerRank, PlayerCaptime, PlayerGameFragMatrix
14 from xonstat.models import TeamGameStat, PlayerGameAnticheat, Player, Hashkey, PlayerNick
15 from xonstat.util import strip_colors, qfont_decode, verify_request, weapon_map
17 log = logging.getLogger(__name__)
20 class Submission(object):
21 """Parses an incoming POST request for stats submissions."""
23 def __init__(self, body, headers):
24 # a copy of the HTTP headers
25 self.headers = headers
27 # a copy of the HTTP POST body
30 # the submission code version (from the server)
33 # the revision string of the server
36 # the game type played
37 self.game_type_cd = None
42 # the name of the map played
45 # unique identifier (string) for a match on a given server
48 # the name of the server
49 self.server_name = None
51 # the number of cvars that were changed to be different than default
52 self.impure_cvar_changes = None
54 # the port number the game server is listening on
55 self.port_number = None
57 # how long the game lasted
60 # which ladder is being used, if any
63 # players involved in the match (humans, bots, and spectators)
69 # the parsing deque (we use this to allow peeking)
70 self.q = collections.deque(self.body.split("\n"))
72 ############################################################################################
73 # Below this point are fields useful in determining if the submission is valid or
74 # performance optimizations that save us from looping over the events over and over again.
75 ############################################################################################
77 # humans who played in the match
80 # bots who played in the match
83 # player indexes for those who played
84 self.player_indexes = set()
86 # distinct weapons that we have seen fired
89 # has a human player fired a shot?
90 self.human_fired_weapon = False
92 # does any human have a non-zero score?
93 self.human_nonzero_score = False
95 # does any human have a fastest cap?
96 self.human_fastest = False
101 """Returns the next key:value pair off the queue."""
103 items = self.q.popleft().strip().split(' ', 1)
105 # Some keys won't have values, like 'L' records where the server isn't actually
106 # participating in any ladders. These can be safely ignored.
113 def add_weapon_fired(self, sub_key):
114 """Adds a weapon to the set of weapons fired during the match (a set)."""
115 self.weapons.add(sub_key.split("-")[1])
118 def is_human_player(player):
120 Determines if a given set of events correspond with a non-bot
122 return not player['P'].startswith('bot')
125 def played_in_game(player):
127 Determines if a given set of player events correspond with a player who
128 played in the game (matches 1 and scoreboardvalid 1)
130 return 'matches' in player and 'scoreboardvalid' in player
132 def parse_player(self, key, pid):
133 """Construct a player events listing from the submission."""
135 # all of the keys related to player records
136 player_keys = ['i', 'n', 't', 'r', 'e']
140 player_fired_weapon = False
141 player_nonzero_score = False
142 player_fastest = False
144 # Consume all following 'i' 'n' 't' 'e' records
145 while len(self.q) > 0:
146 (key, value) = self.next_item()
147 if key is None and value is None:
150 (sub_key, sub_value) = value.split(' ', 1)
151 player[sub_key] = sub_value
153 if sub_key.endswith("cnt-fired"):
154 player_fired_weapon = True
155 self.add_weapon_fired(sub_key)
156 elif sub_key == 'scoreboard-score' and int(round(float(sub_value))) != 0:
157 player_nonzero_score = True
158 elif sub_key == 'scoreboard-fastest':
159 player_fastest = True
161 player[key] = unicode(value, 'utf-8')
162 elif key in player_keys:
165 # something we didn't expect - put it back on the deque
166 self.q.appendleft("{} {}".format(key, value))
169 played = self.played_in_game(player)
170 human = self.is_human_player(player)
173 self.player_indexes.add(int(player["i"]))
176 self.humans.append(player)
178 if player_fired_weapon:
179 self.human_fired_weapon = True
181 if player_nonzero_score:
182 self.human_nonzero_score = True
185 self.human_fastest = True
187 elif played and not human:
188 self.bots.append(player)
190 self.players.append(player)
192 def parse_team(self, key, tid):
193 """Construct a team events listing from the submission."""
196 # Consume all following 'e' records
197 while len(self.q) > 0 and self.q[0].startswith('e'):
198 (_, value) = self.next_item()
199 (sub_key, sub_value) = value.split(' ', 1)
200 team[sub_key] = sub_value
202 self.teams.append(team)
205 """Parses the request body into instance variables."""
206 while len(self.q) > 0:
207 (key, value) = self.next_item()
208 if key is None and value is None:
213 self.revision = value
215 self.game_type_cd = value
219 self.map_name = value
221 self.match_id = value
223 self.server_name = unicode(value, 'utf-8')
225 self.impure_cvar_changes = int(value)
227 self.port_number = int(value)
229 self.duration = datetime.timedelta(seconds=int(round(float(value))))
233 self.parse_team(key, value)
235 self.parse_player(key, value)
237 raise Exception("Invalid submission")
242 """Debugging representation of a submission."""
243 return "game_type_cd: {}, mod: {}, players: {}, humans: {}, bots: {}, weapons: {}".format(
244 self.game_type_cd, self.mod, len(self.players), len(self.humans), len(self.bots),
248 def game_category(submission):
249 """Determines the game's category purely by what is in the submission data."""
252 vanilla_allowed_weapons = {"shotgun", "devastator", "blaster", "mortar", "vortex", "electro",
253 "arc", "hagar", "crylink", "machinegun"}
254 insta_allowed_weapons = {"vaporizer", "blaster"}
255 overkill_allowed_weapons = {"hmg", "vortex", "shotgun", "blaster", "machinegun", "rpc"}
258 if len(submission.weapons - vanilla_allowed_weapons) == 0:
260 elif mod == "InstaGib":
261 if len(submission.weapons - insta_allowed_weapons) == 0:
263 elif mod == "Overkill":
264 if len(submission.weapons - overkill_allowed_weapons) == 0:
272 def is_blank_game(submission):
274 Determine if this is a blank game or not. A blank game is either:
276 1) a match that ended in the warmup stage, where accuracy events are not
277 present (for non-CTS games)
279 2) a match in which no player made a positive or negative score AND was
282 ... or for CTS, which doesn't record accuracy events
284 1) a match in which no player made a fastest lap AND was
287 ... or for NB, in which not all maps have weapons
289 1) a match in which no player made a positive or negative score
291 if submission.game_type_cd == 'cts':
292 return not submission.human_fastest
293 elif submission.game_type_cd == 'nb':
294 return not submission.human_nonzero_score
296 return not (submission.human_nonzero_score and submission.human_fired_weapon)
299 def has_required_metadata(submission):
300 """Determines if a submission has all the required metadata fields."""
301 return (submission.game_type_cd is not None
302 and submission.map_name is not None
303 and submission.match_id is not None
304 and submission.server_name is not None)
307 def is_supported_gametype(submission):
308 """Determines if a submission is of a valid and supported game type."""
310 # if the type can be supported, but with version constraints, uncomment
311 # here and add the restriction for a specific version below
312 supported_game_types = (
331 is_supported = submission.game_type_cd in supported_game_types
333 # some game types were buggy before revisions, thus this additional filter
334 if submission.game_type_cd == 'ca' and submission.version <= 5:
340 def has_minimum_real_players(settings, submission):
342 Determines if the submission has enough human players to store in the database. The minimum
343 setting comes from the config file under the setting xonstat.minimum_real_players.
346 minimum_required_players = int(settings.get("xonstat.minimum_required_players"))
348 minimum_required_players = 2
350 return len(submission.humans) >= minimum_required_players
353 def do_precondition_checks(settings, submission):
354 """Precondition checks for ALL gametypes. These do not require a database connection."""
355 if not has_required_metadata(submission):
356 msg = "Missing required game metadata"
358 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
360 content_type="text/plain"
363 if submission.version is None:
364 msg = "Invalid or incorrect game metadata provided"
366 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
368 content_type="text/plain"
371 if not is_supported_gametype(submission):
372 msg = "Unsupported game type ({})".format(submission.game_type_cd)
374 raise pyramid.httpexceptions.HTTPOk(
376 content_type="text/plain"
379 if not has_minimum_real_players(settings, submission):
380 msg = "Not enough real players"
382 raise pyramid.httpexceptions.HTTPOk(
384 content_type="text/plain"
387 if is_blank_game(submission):
390 raise pyramid.httpexceptions.HTTPOk(
392 content_type="text/plain"
396 def get_remote_addr(request):
397 """Get the Xonotic server's IP address"""
398 if 'X-Forwarded-For' in request.headers:
399 return request.headers['X-Forwarded-For']
401 return request.remote_addr
404 def should_do_weapon_stats(game_type_cd):
405 """True of the game type should record weapon stats. False otherwise."""
406 return game_type_cd not in {'cts'}
409 def gametype_rating_eligible(game_type_cd):
410 """True of the game type should process ratings (Elo/Glicko). False otherwise."""
411 return game_type_cd in {'duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft'}
414 def register_new_nick(session, player, new_nick):
416 Change the player record's nick to the newly found nick. Store the old
417 nick in the player_nicks table for that player.
419 session - SQLAlchemy database session factory
420 player - player record whose nick is changing
421 new_nick - the new nickname
423 # see if that nick already exists
424 stripped_nick = strip_colors(qfont_decode(player.nick))
426 player_nick = session.query(PlayerNick).filter_by(
427 player_id=player.player_id, stripped_nick=stripped_nick).one()
428 except NoResultFound, e:
429 # player_id/stripped_nick not found, create one
430 # but we don't store "Anonymous Player #N"
431 if not re.search('^Anonymous Player #\d+$', player.nick):
432 player_nick = PlayerNick()
433 player_nick.player_id = player.player_id
434 player_nick.stripped_nick = stripped_nick
435 player_nick.nick = player.nick
436 session.add(player_nick)
438 # We change to the new nick regardless
439 player.nick = new_nick
440 player.stripped_nick = strip_colors(qfont_decode(new_nick))
444 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
446 Check the fastest cap time for the player and map. If there isn't
447 one, insert one. If there is, check if the passed time is faster.
450 # we don't record fastest cap times for bots or anonymous players
454 # see if a cap entry exists already
455 # then check to see if the new captime is faster
457 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
458 player_id=player_id, map_id=map_id, mod=mod).one()
460 # current captime is faster, so update
461 if captime < cur_fastest_cap.fastest_cap:
462 cur_fastest_cap.fastest_cap = captime
463 cur_fastest_cap.game_id = game_id
464 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
465 session.add(cur_fastest_cap)
467 except NoResultFound, e:
468 # none exists, so insert
469 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
471 session.add(cur_fastest_cap)
475 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
477 Updates the server in the given DB session, if needed.
479 :param server: The found server instance.
480 :param name: The incoming server name.
481 :param hashkey: The incoming server hashkey.
482 :param ip_addr: The incoming server IP address.
483 :param port: The incoming server port.
484 :param revision: The incoming server revision.
485 :param impure_cvars: The incoming number of impure server cvars.
488 # ensure the two int attributes are actually ints
495 impure_cvars = int(impure_cvars)
500 if name and server.name != name:
503 if hashkey and server.hashkey != hashkey:
504 server.hashkey = hashkey
506 if ip_addr and server.ip_addr != ip_addr:
507 server.ip_addr = ip_addr
509 if port and server.port != port:
512 if revision and server.revision != revision:
513 server.revision = revision
515 if impure_cvars and server.impure_cvars != impure_cvars:
516 server.impure_cvars = impure_cvars
517 server.pure_ind = True if impure_cvars == 0 else False
523 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
525 Find a server by name or create one if not found. Parameters:
527 session - SQLAlchemy database session factory
528 name - server name of the server to be found or created
529 hashkey - server hashkey
530 ip_addr - the IP address of the server
531 revision - the xonotic revision number
532 port - the port number of the server
533 impure_cvars - the number of impure cvar changes
535 servers_q = DBSession.query(Server).filter(Server.active_ind)
538 # if the hashkey is provided, we'll use that
539 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
541 # otherwise, it is just by name
542 servers_q = servers_q.filter(Server.name == name)
544 # order by the hashkey, which means any hashkey match will appear first if there are multiple
545 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
547 if len(servers) == 0:
548 server = Server(name=name, hashkey=hashkey)
551 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
554 if len(servers) == 1:
555 log.info("Found existing server {}.".format(server.server_id))
557 elif len(servers) > 1:
558 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
559 log.warn("Multiple servers found ({})! Using the first one ({})."
560 .format(server_id_list, server.server_id))
562 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
568 def get_or_create_map(session, name):
570 Find a map by name or create one if not found. Parameters:
572 session - SQLAlchemy database session factory
573 name - map name of the map to be found or created
575 maps = session.query(Map).filter_by(name=name).order_by(Map.map_id).all()
577 if maps is None or len(maps) == 0:
578 gmap = Map(name=name)
581 log.debug("Created map id {}: {}".format(gmap.map_id, gmap.name))
584 log.debug("Found map id {}: {}".format(gmap.map_id, gmap.name))
587 map_id_list = ", ".join(["{}".format(m.map_id) for m in maps])
588 log.warn("Multiple maps found for {} ({})! Using the first one.".format(name, map_id_list))
593 def create_game(session, game_type_cd, server_id, map_id, match_id, start_dt, duration, mod,
594 winner=None, category=None):
596 Creates a game. Parameters:
598 session - SQLAlchemy database session factory
599 game_type_cd - the game type of the game being played
600 mod - mods in use during the game
601 server_id - server identifier of the server hosting the game
602 map_id - map on which the game was played
603 match_id - a unique match ID given by the server
604 start_dt - when the game started (datetime object)
605 duration - how long the game lasted
606 winner - the team id of the team that won
607 category - the category of the game
609 seq = Sequence('games_game_id_seq')
610 game_id = session.execute(seq)
611 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd, server_id=server_id,
612 map_id=map_id, winner=winner)
613 game.match_id = match_id
616 # There is some drift between start_dt (provided by app) and create_dt
617 # (default in the database), so we'll make them the same until this is
619 game.create_dt = start_dt
621 game.duration = duration
623 game.category = category
626 session.query(Game).filter(Game.server_id == server_id)\
627 .filter(Game.match_id == match_id).one()
629 log.debug("Error: game with same server and match_id found! Ignoring.")
631 # if a game under the same server_id and match_id exists, this is a duplicate
632 msg = "Duplicate game (pre-existing match_id)"
634 raise pyramid.httpexceptions.HTTPOk(body=msg, content_type="text/plain")
636 except NoResultFound:
637 # server_id/match_id combination not found. game is ok to insert
640 log.debug("Created game id {} on server {}, map {} at {}"
641 .format(game.game_id, server_id, map_id, start_dt))
646 def get_or_create_player(session=None, hashkey=None, nick=None):
648 Finds a player by hashkey or creates a new one (along with a
649 corresponding hashkey entry. Parameters:
651 session - SQLAlchemy database session factory
652 hashkey - hashkey of the player to be found or created
653 nick - nick of the player (in case of a first time create)
656 if re.search('^bot#\d+', hashkey):
657 player = session.query(Player).filter_by(player_id=1).one()
658 # if we have an untracked player
659 elif re.search('^player#\d+$', hashkey):
660 player = session.query(Player).filter_by(player_id=2).one()
661 # else it is a tracked player
663 # see if the player is already in the database
664 # if not, create one and the hashkey along with it
666 hk = session.query(Hashkey).filter_by(
667 hashkey=hashkey).one()
668 player = session.query(Player).filter_by(
669 player_id=hk.player_id).one()
670 log.debug("Found existing player {0} with hashkey {1}".format(
671 player.player_id, hashkey))
677 # if nick is given to us, use it. If not, use "Anonymous Player"
678 # with a suffix added for uniqueness.
680 player.nick = nick[:128]
681 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
683 player.nick = "Anonymous Player #{0}".format(player.player_id)
684 player.stripped_nick = player.nick
686 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
688 log.debug("Created player {0} ({2}) with hashkey {1}".format(
689 player.player_id, hashkey, player.nick.encode('utf-8')))
694 def create_default_game_stat(session, game_type_cd):
695 """Creates a blanked-out pgstat record for the given game type"""
697 # this is what we have to do to get partitioned records in - grab the
698 # sequence value first, then insert using the explicit ID (vs autogenerate)
699 seq = Sequence('player_game_stats_player_game_stat_id_seq')
700 pgstat_id = session.execute(seq)
701 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
702 create_dt=datetime.datetime.utcnow())
704 if game_type_cd == 'as':
705 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
707 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
708 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
710 if game_type_cd == 'cq':
711 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
714 if game_type_cd == 'ctf':
715 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
716 pgstat.returns = pgstat.carrier_frags = 0
718 if game_type_cd == 'cts':
721 if game_type_cd == 'dom':
722 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
725 if game_type_cd == 'ft':
726 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
728 if game_type_cd == 'ka':
729 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
730 pgstat.carrier_frags = 0
731 pgstat.time = datetime.timedelta(seconds=0)
733 if game_type_cd == 'kh':
734 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
735 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
736 pgstat.carrier_frags = 0
738 if game_type_cd == 'lms':
739 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
741 if game_type_cd == 'nb':
742 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
745 if game_type_cd == 'rc':
746 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
751 def create_game_stat(session, game, gmap, player, events):
752 """Game stats handler for all game types"""
754 game_type_cd = game.game_type_cd
756 pgstat = create_default_game_stat(session, game_type_cd)
758 # these fields should be on every pgstat record
759 pgstat.game_id = game.game_id
760 pgstat.player_id = player.player_id
761 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
762 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
763 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
764 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
765 pgstat.rank = int(events.get('rank', None))
766 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
770 # gametype-specific stuff is handled here. if passed to us, we store it
771 for (key,value) in events.items():
772 if key == 'wins': wins = True
773 if key == 't': pgstat.team = int(value)
775 if key == 'scoreboard-drops': pgstat.drops = int(value)
776 if key == 'scoreboard-returns': pgstat.returns = int(value)
777 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
778 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
779 if key == 'scoreboard-caps': pgstat.captures = int(value)
780 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
781 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
782 if key == 'scoreboard-kills': pgstat.kills = int(value)
783 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
784 if key == 'scoreboard-objectives': pgstat.collects = int(value)
785 if key == 'scoreboard-captured': pgstat.captures = int(value)
786 if key == 'scoreboard-released': pgstat.drops = int(value)
787 if key == 'scoreboard-fastest':
788 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
789 if key == 'scoreboard-takes': pgstat.pickups = int(value)
790 if key == 'scoreboard-ticks': pgstat.drops = int(value)
791 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
792 if key == 'scoreboard-bctime':
793 pgstat.time = datetime.timedelta(seconds=int(value))
794 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
795 if key == 'scoreboard-losses': pgstat.drops = int(value)
796 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
797 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
798 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
799 if key == 'scoreboard-lives': pgstat.lives = int(value)
800 if key == 'scoreboard-goals': pgstat.captures = int(value)
801 if key == 'scoreboard-faults': pgstat.drops = int(value)
802 if key == 'scoreboard-laps': pgstat.laps = int(value)
804 if key == 'avglatency': pgstat.avg_latency = float(value)
805 if key == 'scoreboard-captime':
806 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
807 if game.game_type_cd == 'ctf':
808 update_fastest_cap(session, player.player_id, game.game_id,
809 gmap.map_id, pgstat.fastest, game.mod)
811 # there is no "winning team" field, so we have to derive it
812 if wins and pgstat.team is not None and game.winner is None:
813 game.winner = pgstat.team
821 def create_anticheats(session, pgstat, game, player, events):
822 """Anticheats handler for all game types"""
826 # all anticheat events are prefixed by "anticheat"
827 for (key,value) in events.items():
828 if key.startswith("anticheat"):
830 ac = PlayerGameAnticheat(
836 anticheats.append(ac)
838 except Exception as e:
839 log.debug("Could not parse value for key %s. Ignoring." % key)
844 def create_default_team_stat(session, game_type_cd):
845 """Creates a blanked-out teamstat record for the given game type"""
847 # this is what we have to do to get partitioned records in - grab the
848 # sequence value first, then insert using the explicit ID (vs autogenerate)
849 seq = Sequence('team_game_stats_team_game_stat_id_seq')
850 teamstat_id = session.execute(seq)
851 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
852 create_dt=datetime.datetime.utcnow())
854 # all team game modes have a score, so we'll zero that out always
857 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
860 if game_type_cd == 'ctf':
866 def create_team_stat(session, game, events):
867 """Team stats handler for all game types"""
870 teamstat = create_default_team_stat(session, game.game_type_cd)
871 teamstat.game_id = game.game_id
873 # we should have a team ID if we have a 'Q' event
874 if re.match(r'^team#\d+$', events.get('Q', '')):
875 team = int(events.get('Q').replace('team#', ''))
878 # gametype-specific stuff is handled here. if passed to us, we store it
879 for (key,value) in events.items():
880 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
881 if key == 'scoreboard-caps': teamstat.caps = int(value)
882 if key == 'scoreboard-goals': teamstat.caps = int(value)
883 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
885 session.add(teamstat)
886 except Exception as e:
892 def create_weapon_stats(session, version, game, player, pgstat, events):
893 """Weapon stats handler for all game types"""
896 # Version 1 of stats submissions doubled the data sent.
897 # To counteract this we divide the data by 2 only for
898 # POSTs coming from version 1.
902 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
908 for (key,value) in events.items():
909 matched = re.search("acc-(.*?)-cnt-fired", key)
911 weapon_cd = matched.group(1)
913 # Weapon names changed for 0.8. We'll convert the old
914 # ones to use the new scheme as well.
915 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
917 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
918 pwstat_id = session.execute(seq)
919 pwstat = PlayerWeaponStat()
920 pwstat.player_weapon_stats_id = pwstat_id
921 pwstat.player_id = player.player_id
922 pwstat.game_id = game.game_id
923 pwstat.player_game_stat_id = pgstat.player_game_stat_id
924 pwstat.weapon_cd = mapped_weapon_cd
927 pwstat.nick = events['n']
929 pwstat.nick = events['P']
931 if 'acc-' + weapon_cd + '-cnt-fired' in events:
932 pwstat.fired = int(round(float(
933 events['acc-' + weapon_cd + '-cnt-fired'])))
934 if 'acc-' + weapon_cd + '-fired' in events:
935 pwstat.max = int(round(float(
936 events['acc-' + weapon_cd + '-fired'])))
937 if 'acc-' + weapon_cd + '-cnt-hit' in events:
938 pwstat.hit = int(round(float(
939 events['acc-' + weapon_cd + '-cnt-hit'])))
940 if 'acc-' + weapon_cd + '-hit' in events:
941 pwstat.actual = int(round(float(
942 events['acc-' + weapon_cd + '-hit'])))
943 if 'acc-' + weapon_cd + '-frags' in events:
944 pwstat.frags = int(round(float(
945 events['acc-' + weapon_cd + '-frags'])))
948 pwstat.fired = pwstat.fired/2
949 pwstat.max = pwstat.max/2
950 pwstat.hit = pwstat.hit/2
951 pwstat.actual = pwstat.actual/2
952 pwstat.frags = pwstat.frags/2
955 pwstats.append(pwstat)
960 def get_ranks(session, player_ids, game_type_cd):
962 Gets the rank entries for all players in the given list, returning a dict
963 of player_id -> PlayerRank instance. The rank entry corresponds to the
964 game type of the parameter passed in as well.
967 for pr in session.query(PlayerRank).\
968 filter(PlayerRank.player_id.in_(player_ids)).\
969 filter(PlayerRank.game_type_cd == game_type_cd).\
971 ranks[pr.player_id] = pr
976 def update_player(session, player, events):
978 Updates a player record using the latest information.
979 :param session: SQLAlchemy session
980 :param player: Player model representing what is in the database right now (before updates)
981 :param events: Dict of player events from the submission
984 nick = events.get('n', 'Anonymous Player')[:128]
985 if nick != player.nick and not nick.startswith("Anonymous Player"):
986 register_new_nick(session, player, nick)
991 def create_player(session, events):
993 Creates a new player from the list of events.
994 :param session: SQLAlchemy session
995 :param events: Dict of player events from the submission
1002 nick = events.get('n', None)
1004 player.nick = nick[:128]
1005 player.stripped_nick = strip_colors(qfont_decode(player.nick))
1007 player.nick = "Anonymous Player #{0}".format(player.player_id)
1008 player.stripped_nick = player.nick
1010 hk = Hashkey(player_id=player.player_id, hashkey=events.get('P', None))
1016 def get_or_create_players(session, events_by_hashkey):
1017 hashkeys = set(events_by_hashkey.keys())
1018 players_by_hashkey = {}
1020 bot = session.query(Player).filter(Player.player_id == 1).one()
1021 anon = session.query(Player).filter(Player.player_id == 2).one()
1023 # fill in the bots and anonymous players
1024 for hashkey in events_by_hashkey.keys():
1025 if hashkey.startswith("bot#"):
1026 players_by_hashkey[hashkey] = bot
1027 hashkeys.remove(hashkey)
1028 elif hashkey.startswith("player#"):
1029 players_by_hashkey[hashkey] = anon
1030 hashkeys.remove(hashkey)
1032 # We are left with the "real" players and can now fetch them by their collective hashkeys.
1033 # Those that are returned here are pre-existing players who need to be updated.
1034 for p, hk in session.query(Player, Hashkey)\
1035 .filter(Player.player_id == Hashkey.player_id)\
1036 .filter(Hashkey.hashkey.in_(hashkeys))\
1038 log.debug("Found existing player {} with hashkey {}"
1039 .format(p.player_id, hk.hashkey))
1041 player = update_player(session, p, events_by_hashkey[hk.hashkey])
1042 players_by_hashkey[hk.hashkey] = player
1043 hashkeys.remove(hk.hashkey)
1045 # The remainder are the players we haven't seen before, so we need to create them.
1046 for hashkey in hashkeys:
1047 player = create_player(session, events_by_hashkey[hashkey])
1049 log.debug("Created player {0} ({2}) with hashkey {1}"
1050 .format(player.player_id, hashkey, player.nick.encode('utf-8')))
1052 players_by_hashkey[hashkey] = player
1054 return players_by_hashkey
1057 def create_frag_matrix(session, player_indexes, pgstat, events):
1059 Construct a PlayerFragMatrix object from the events of a given player.
1061 :param session: The DBSession we're adding objects to.
1062 :param player_indexes: The set of player indexes of those that actually played in the game.
1063 :param pgstat: The PlayerGameStat object of the player whose frag matrix we want to create.
1064 :param events: The raw player events of the above player.
1065 :return: PlayerFragMatrix
1067 player_index = int(events.get("i", None))
1070 victim_index = lambda x: int(x.split("-")[1])
1072 matrix = {victim_index(k): int(v) for (k, v) in events.items()
1073 if k.startswith("kills-") and victim_index(k) in player_indexes}
1076 pfm = PlayerGameFragMatrix(pgstat.game_id, pgstat.player_game_stat_id, pgstat.player_id,
1077 player_index, matrix)
1085 def submit_stats(request):
1087 Entry handler for POST stats submissions.
1089 # placeholder for the actual session
1093 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1094 "----- END REQUEST BODY -----\n\n")
1096 (idfp, status) = verify_request(request)
1098 submission = Submission(request.body, request.headers)
1100 msg = "Invalid submission"
1102 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
1104 content_type="text/plain"
1107 do_precondition_checks(request.registry.settings, submission)
1109 #######################################################################
1110 # Actual setup (inserts/updates) below here
1111 #######################################################################
1112 session = DBSession()
1114 # All game types create Game, Server, Map, and Player records
1116 server = get_or_create_server(
1119 name=submission.server_name,
1120 revision=submission.revision,
1121 ip_addr=get_remote_addr(request),
1122 port=submission.port_number,
1123 impure_cvars=submission.impure_cvar_changes
1126 gmap = get_or_create_map(session, submission.map_name)
1130 game_type_cd=submission.game_type_cd,
1132 server_id=server.server_id,
1134 match_id=submission.match_id,
1135 start_dt=datetime.datetime.utcnow(),
1136 duration=submission.duration,
1137 category=game_category(submission)
1140 events_by_hashkey = {elem["P"]: elem for elem in submission.humans + submission.bots}
1141 players_by_hashkey = get_or_create_players(session, events_by_hashkey)
1146 hashkeys_by_player_id = {}
1147 for hashkey, player in players_by_hashkey.items():
1148 events = events_by_hashkey[hashkey]
1150 pgstat = create_game_stat(session, game, gmap, player, events)
1151 pgstats.append(pgstat)
1153 frag_matrix = create_frag_matrix(session, submission.player_indexes, pgstat, events)
1155 # player rating opt-out
1156 if 'r' in events and events['r'] == '0':
1157 log.debug("Excluding player {} from rating calculations (opt-out)"
1158 .format(pgstat.player_id))
1159 elif pgstat.player_id > 2:
1160 rating_pgstats.append(pgstat)
1162 if player.player_id > 1:
1163 create_anticheats(session, pgstat, game, player, events)
1165 if player.player_id > 2:
1166 player_ids.append(player.player_id)
1167 hashkeys_by_player_id[player.player_id] = hashkey
1169 if should_do_weapon_stats(submission.game_type_cd) and player.player_id > 1:
1170 create_weapon_stats(session, submission.version, game, player, pgstat, events)
1172 # player_ids for human players get stored directly on games for fast indexing
1173 game.players = player_ids
1175 for events in submission.teams:
1176 create_team_stat(session, game, events)
1178 rating_eligible = gametype_rating_eligible(submission.game_type_cd)
1179 if rating_eligible and server.elo_ind and len(rating_pgstats) > 1:
1180 # calculate Elo ratings
1181 ep = EloProcessor(session, game, rating_pgstats)
1185 # calculate Glicko ratings
1186 gp = GlickoProcessor(session)
1187 gp.load(game.game_id, game, rating_pgstats)
1194 log.debug('Success! Stats recorded.')
1196 # ranks are fetched after we've done the "real" processing
1197 ranks = get_ranks(session, player_ids, submission.game_type_cd)
1199 # plain text response
1200 request.response.content_type = 'text/plain'
1203 "now": calendar.timegm(datetime.datetime.utcnow().timetuple()),
1207 "player_ids": player_ids,
1208 "hashkeys": hashkeys_by_player_id,
1213 except Exception as e: