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
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
97 """Returns the next key:value pair off the queue."""
99 items = self.q.popleft().strip().split(' ', 1)
101 # Some keys won't have values, like 'L' records where the server isn't actually
102 # participating in any ladders. These can be safely ignored.
109 def add_weapon_fired(self, sub_key):
110 """Adds a weapon to the set of weapons fired during the match (a set)."""
111 self.weapons.add(sub_key.split("-")[1])
114 def is_human_player(player):
116 Determines if a given set of events correspond with a non-bot
118 return not player['P'].startswith('bot')
121 def played_in_game(player):
123 Determines if a given set of player events correspond with a player who
124 played in the game (matches 1 and scoreboardvalid 1)
126 return 'matches' in player and 'scoreboardvalid' in player
128 def parse_player(self, key, pid):
129 """Construct a player events listing from the submission."""
131 # all of the keys related to player records
132 player_keys = ['i', 'n', 't', 'r', 'e']
136 player_fired_weapon = False
137 player_nonzero_score = False
138 player_fastest = False
140 # Consume all following 'i' 'n' 't' 'e' records
141 while len(self.q) > 0:
142 (key, value) = self.next_item()
143 if key is None and value is None:
146 (sub_key, sub_value) = value.split(' ', 1)
147 player[sub_key] = sub_value
149 if sub_key.endswith("cnt-fired"):
150 player_fired_weapon = True
151 self.add_weapon_fired(sub_key)
152 elif sub_key == 'scoreboard-score' and int(round(float(sub_value))) != 0:
153 player_nonzero_score = True
154 elif sub_key == 'scoreboard-fastest':
155 player_fastest = True
157 player[key] = unicode(value, 'utf-8')
158 elif key in player_keys:
161 # something we didn't expect - put it back on the deque
162 self.q.appendleft("{} {}".format(key, value))
165 played = self.played_in_game(player)
166 human = self.is_human_player(player)
169 self.humans.append(player)
171 if player_fired_weapon:
172 self.human_fired_weapon = True
174 if player_nonzero_score:
175 self.human_nonzero_score = True
178 self.human_fastest = True
180 elif played and not human:
181 self.bots.append(player)
183 self.players.append(player)
185 def parse_team(self, key, tid):
186 """Construct a team events listing from the submission."""
189 # Consume all following 'e' records
190 while len(self.q) > 0 and self.q[0].startswith('e'):
191 (_, value) = self.next_item()
192 (sub_key, sub_value) = value.split(' ', 1)
193 team[sub_key] = sub_value
195 self.teams.append(team)
198 """Parses the request body into instance variables."""
199 while len(self.q) > 0:
200 (key, value) = self.next_item()
201 if key is None and value is None:
206 self.revision = value
208 self.game_type_cd = value
212 self.map_name = value
214 self.match_id = value
216 self.server_name = unicode(value, 'utf-8')
218 self.impure_cvar_changes = int(value)
220 self.port_number = int(value)
222 self.duration = datetime.timedelta(seconds=int(round(float(value))))
226 self.parse_team(key, value)
228 self.parse_player(key, value)
230 raise Exception("Invalid submission")
235 """Debugging representation of a submission."""
236 return "game_type_cd: {}, mod: {}, players: {}, humans: {}, bots: {}, weapons: {}".format(
237 self.game_type_cd, self.mod, len(self.players), len(self.humans), len(self.bots),
241 def elo_submission_category(submission):
242 """Determines the Elo category purely by what is in the submission data."""
245 vanilla_allowed_weapons = {"shotgun", "devastator", "blaster", "mortar", "vortex", "electro",
246 "arc", "hagar", "crylink", "machinegun"}
247 insta_allowed_weapons = {"vaporizer", "blaster"}
248 overkill_allowed_weapons = {"hmg", "vortex", "shotgun", "blaster", "machinegun", "rpc"}
251 if len(submission.weapons - vanilla_allowed_weapons) == 0:
253 elif mod == "InstaGib":
254 if len(submission.weapons - insta_allowed_weapons) == 0:
256 elif mod == "Overkill":
257 if len(submission.weapons - overkill_allowed_weapons) == 0:
265 def is_blank_game(submission):
267 Determine if this is a blank game or not. A blank game is either:
269 1) a match that ended in the warmup stage, where accuracy events are not
270 present (for non-CTS games)
272 2) a match in which no player made a positive or negative score AND was
275 ... or for CTS, which doesn't record accuracy events
277 1) a match in which no player made a fastest lap AND was
280 ... or for NB, in which not all maps have weapons
282 1) a match in which no player made a positive or negative score
284 if submission.game_type_cd == 'cts':
285 return not submission.human_fastest
286 elif submission.game_type_cd == 'nb':
287 return not submission.human_nonzero_score
289 return not (submission.human_nonzero_score and submission.human_fired_weapon)
292 def has_required_metadata(submission):
293 """Determines if a submission has all the required metadata fields."""
294 return (submission.game_type_cd is not None
295 and submission.map_name is not None
296 and submission.match_id is not None
297 and submission.server_name is not None)
300 def is_supported_gametype(submission):
301 """Determines if a submission is of a valid and supported game type."""
303 # if the type can be supported, but with version constraints, uncomment
304 # here and add the restriction for a specific version below
305 supported_game_types = (
324 is_supported = submission.game_type_cd in supported_game_types
326 # some game types were buggy before revisions, thus this additional filter
327 if submission.game_type_cd == 'ca' and submission.version <= 5:
333 def has_minimum_real_players(settings, submission):
335 Determines if the submission has enough human players to store in the database. The minimum
336 setting comes from the config file under the setting xonstat.minimum_real_players.
339 minimum_required_players = int(settings.get("xonstat.minimum_required_players"))
341 minimum_required_players = 2
343 return len(submission.humans) >= minimum_required_players
346 def do_precondition_checks(settings, submission):
347 """Precondition checks for ALL gametypes. These do not require a database connection."""
348 if not has_required_metadata(submission):
349 msg = "Missing required game metadata"
351 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
353 content_type="text/plain"
356 if submission.version is None:
357 msg = "Invalid or incorrect game metadata provided"
359 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
361 content_type="text/plain"
364 if not is_supported_gametype(submission):
365 msg = "Unsupported game type ({})".format(submission.game_type_cd)
367 raise pyramid.httpexceptions.HTTPOk(
369 content_type="text/plain"
372 if not has_minimum_real_players(settings, submission):
373 msg = "Not enough real players"
375 raise pyramid.httpexceptions.HTTPOk(
377 content_type="text/plain"
380 if is_blank_game(submission):
383 raise pyramid.httpexceptions.HTTPOk(
385 content_type="text/plain"
389 def get_remote_addr(request):
390 """Get the Xonotic server's IP address"""
391 if 'X-Forwarded-For' in request.headers:
392 return request.headers['X-Forwarded-For']
394 return request.remote_addr
397 def should_do_weapon_stats(game_type_cd):
398 """True of the game type should record weapon stats. False otherwise."""
399 return game_type_cd not in {'cts'}
402 def gametype_elo_eligible(game_type_cd):
403 """True of the game type should process Elos. False otherwise."""
404 return game_type_cd in {'duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft'}
407 def register_new_nick(session, player, new_nick):
409 Change the player record's nick to the newly found nick. Store the old
410 nick in the player_nicks table for that player.
412 session - SQLAlchemy database session factory
413 player - player record whose nick is changing
414 new_nick - the new nickname
416 # see if that nick already exists
417 stripped_nick = strip_colors(qfont_decode(player.nick))
419 player_nick = session.query(PlayerNick).filter_by(
420 player_id=player.player_id, stripped_nick=stripped_nick).one()
421 except NoResultFound, e:
422 # player_id/stripped_nick not found, create one
423 # but we don't store "Anonymous Player #N"
424 if not re.search('^Anonymous Player #\d+$', player.nick):
425 player_nick = PlayerNick()
426 player_nick.player_id = player.player_id
427 player_nick.stripped_nick = stripped_nick
428 player_nick.nick = player.nick
429 session.add(player_nick)
431 # We change to the new nick regardless
432 player.nick = new_nick
433 player.stripped_nick = strip_colors(qfont_decode(new_nick))
437 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
439 Check the fastest cap time for the player and map. If there isn't
440 one, insert one. If there is, check if the passed time is faster.
443 # we don't record fastest cap times for bots or anonymous players
447 # see if a cap entry exists already
448 # then check to see if the new captime is faster
450 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
451 player_id=player_id, map_id=map_id, mod=mod).one()
453 # current captime is faster, so update
454 if captime < cur_fastest_cap.fastest_cap:
455 cur_fastest_cap.fastest_cap = captime
456 cur_fastest_cap.game_id = game_id
457 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
458 session.add(cur_fastest_cap)
460 except NoResultFound, e:
461 # none exists, so insert
462 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
464 session.add(cur_fastest_cap)
468 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
470 Updates the server in the given DB session, if needed.
472 :param server: The found server instance.
473 :param name: The incoming server name.
474 :param hashkey: The incoming server hashkey.
475 :param ip_addr: The incoming server IP address.
476 :param port: The incoming server port.
477 :param revision: The incoming server revision.
478 :param impure_cvars: The incoming number of impure server cvars.
481 # ensure the two int attributes are actually ints
488 impure_cvars = int(impure_cvars)
493 if name and server.name != name:
496 if hashkey and server.hashkey != hashkey:
497 server.hashkey = hashkey
499 if ip_addr and server.ip_addr != ip_addr:
500 server.ip_addr = ip_addr
502 if port and server.port != port:
505 if revision and server.revision != revision:
506 server.revision = revision
508 if impure_cvars and server.impure_cvars != impure_cvars:
509 server.impure_cvars = impure_cvars
510 server.pure_ind = True if impure_cvars == 0 else False
516 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
518 Find a server by name or create one if not found. Parameters:
520 session - SQLAlchemy database session factory
521 name - server name of the server to be found or created
522 hashkey - server hashkey
523 ip_addr - the IP address of the server
524 revision - the xonotic revision number
525 port - the port number of the server
526 impure_cvars - the number of impure cvar changes
528 servers_q = DBSession.query(Server).filter(Server.active_ind)
531 # if the hashkey is provided, we'll use that
532 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
534 # otherwise, it is just by name
535 servers_q = servers_q.filter(Server.name == name)
537 # order by the hashkey, which means any hashkey match will appear first if there are multiple
538 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
540 if len(servers) == 0:
541 server = Server(name=name, hashkey=hashkey)
544 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
547 if len(servers) == 1:
548 log.info("Found existing server {}.".format(server.server_id))
550 elif len(servers) > 1:
551 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
552 log.warn("Multiple servers found ({})! Using the first one ({})."
553 .format(server_id_list, server.server_id))
555 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
561 def get_or_create_map(session, name):
563 Find a map by name or create one if not found. Parameters:
565 session - SQLAlchemy database session factory
566 name - map name of the map to be found or created
568 maps = session.query(Map).filter_by(name=name).order_by(Map.map_id).all()
570 if maps is None or len(maps) == 0:
571 gmap = Map(name=name)
574 log.debug("Created map id {}: {}".format(gmap.map_id, gmap.name))
577 log.debug("Found map id {}: {}".format(gmap.map_id, gmap.name))
580 map_id_list = ", ".join(["{}".format(m.map_id) for m in maps])
581 log.warn("Multiple maps found for {} ({})! Using the first one.".format(name, map_id_list))
586 def create_game(session, game_type_cd, server_id, map_id, match_id, start_dt, duration, mod,
589 Creates a game. Parameters:
591 session - SQLAlchemy database session factory
592 game_type_cd - the game type of the game being played
593 mod - mods in use during the game
594 server_id - server identifier of the server hosting the game
595 map_id - map on which the game was played
596 match_id - a unique match ID given by the server
597 start_dt - when the game started (datetime object)
598 duration - how long the game lasted
599 winner - the team id of the team that won
601 seq = Sequence('games_game_id_seq')
602 game_id = session.execute(seq)
603 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd, server_id=server_id,
604 map_id=map_id, winner=winner)
605 game.match_id = match_id
608 # There is some drift between start_dt (provided by app) and create_dt
609 # (default in the database), so we'll make them the same until this is
611 game.create_dt = start_dt
613 game.duration = duration
616 session.query(Game).filter(Game.server_id == server_id)\
617 .filter(Game.match_id == match_id).one()
619 log.debug("Error: game with same server and match_id found! Ignoring.")
621 # if a game under the same server_id and match_id exists, this is a duplicate
622 msg = "Duplicate game (pre-existing match_id)"
624 raise pyramid.httpexceptions.HTTPOk(body=msg, content_type="text/plain")
626 except NoResultFound:
627 # server_id/match_id combination not found. game is ok to insert
630 log.debug("Created game id {} on server {}, map {} at {}"
631 .format(game.game_id, server_id, map_id, start_dt))
636 def get_or_create_player(session=None, hashkey=None, nick=None):
638 Finds a player by hashkey or creates a new one (along with a
639 corresponding hashkey entry. Parameters:
641 session - SQLAlchemy database session factory
642 hashkey - hashkey of the player to be found or created
643 nick - nick of the player (in case of a first time create)
646 if re.search('^bot#\d+', hashkey):
647 player = session.query(Player).filter_by(player_id=1).one()
648 # if we have an untracked player
649 elif re.search('^player#\d+$', hashkey):
650 player = session.query(Player).filter_by(player_id=2).one()
651 # else it is a tracked player
653 # see if the player is already in the database
654 # if not, create one and the hashkey along with it
656 hk = session.query(Hashkey).filter_by(
657 hashkey=hashkey).one()
658 player = session.query(Player).filter_by(
659 player_id=hk.player_id).one()
660 log.debug("Found existing player {0} with hashkey {1}".format(
661 player.player_id, hashkey))
667 # if nick is given to us, use it. If not, use "Anonymous Player"
668 # with a suffix added for uniqueness.
670 player.nick = nick[:128]
671 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
673 player.nick = "Anonymous Player #{0}".format(player.player_id)
674 player.stripped_nick = player.nick
676 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
678 log.debug("Created player {0} ({2}) with hashkey {1}".format(
679 player.player_id, hashkey, player.nick.encode('utf-8')))
684 def create_default_game_stat(session, game_type_cd):
685 """Creates a blanked-out pgstat record for the given game type"""
687 # this is what we have to do to get partitioned records in - grab the
688 # sequence value first, then insert using the explicit ID (vs autogenerate)
689 seq = Sequence('player_game_stats_player_game_stat_id_seq')
690 pgstat_id = session.execute(seq)
691 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
692 create_dt=datetime.datetime.utcnow())
694 if game_type_cd == 'as':
695 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
697 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
698 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
700 if game_type_cd == 'cq':
701 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
704 if game_type_cd == 'ctf':
705 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
706 pgstat.returns = pgstat.carrier_frags = 0
708 if game_type_cd == 'cts':
711 if game_type_cd == 'dom':
712 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
715 if game_type_cd == 'ft':
716 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
718 if game_type_cd == 'ka':
719 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
720 pgstat.carrier_frags = 0
721 pgstat.time = datetime.timedelta(seconds=0)
723 if game_type_cd == 'kh':
724 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
725 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
726 pgstat.carrier_frags = 0
728 if game_type_cd == 'lms':
729 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
731 if game_type_cd == 'nb':
732 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
735 if game_type_cd == 'rc':
736 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
741 def create_game_stat(session, game, gmap, player, events):
742 """Game stats handler for all game types"""
744 game_type_cd = game.game_type_cd
746 pgstat = create_default_game_stat(session, game_type_cd)
748 # these fields should be on every pgstat record
749 pgstat.game_id = game.game_id
750 pgstat.player_id = player.player_id
751 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
752 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
753 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
754 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
755 pgstat.rank = int(events.get('rank', None))
756 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
760 # gametype-specific stuff is handled here. if passed to us, we store it
761 for (key,value) in events.items():
762 if key == 'wins': wins = True
763 if key == 't': pgstat.team = int(value)
765 if key == 'scoreboard-drops': pgstat.drops = int(value)
766 if key == 'scoreboard-returns': pgstat.returns = int(value)
767 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
768 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
769 if key == 'scoreboard-caps': pgstat.captures = int(value)
770 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
771 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
772 if key == 'scoreboard-kills': pgstat.kills = int(value)
773 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
774 if key == 'scoreboard-objectives': pgstat.collects = int(value)
775 if key == 'scoreboard-captured': pgstat.captures = int(value)
776 if key == 'scoreboard-released': pgstat.drops = int(value)
777 if key == 'scoreboard-fastest':
778 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
779 if key == 'scoreboard-takes': pgstat.pickups = int(value)
780 if key == 'scoreboard-ticks': pgstat.drops = int(value)
781 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
782 if key == 'scoreboard-bctime':
783 pgstat.time = datetime.timedelta(seconds=int(value))
784 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
785 if key == 'scoreboard-losses': pgstat.drops = int(value)
786 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
787 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
788 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
789 if key == 'scoreboard-lives': pgstat.lives = int(value)
790 if key == 'scoreboard-goals': pgstat.captures = int(value)
791 if key == 'scoreboard-faults': pgstat.drops = int(value)
792 if key == 'scoreboard-laps': pgstat.laps = int(value)
794 if key == 'avglatency': pgstat.avg_latency = float(value)
795 if key == 'scoreboard-captime':
796 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
797 if game.game_type_cd == 'ctf':
798 update_fastest_cap(session, player.player_id, game.game_id,
799 gmap.map_id, pgstat.fastest, game.mod)
801 # there is no "winning team" field, so we have to derive it
802 if wins and pgstat.team is not None and game.winner is None:
803 game.winner = pgstat.team
811 def create_anticheats(session, pgstat, game, player, events):
812 """Anticheats handler for all game types"""
816 # all anticheat events are prefixed by "anticheat"
817 for (key,value) in events.items():
818 if key.startswith("anticheat"):
820 ac = PlayerGameAnticheat(
826 anticheats.append(ac)
828 except Exception as e:
829 log.debug("Could not parse value for key %s. Ignoring." % key)
834 def create_default_team_stat(session, game_type_cd):
835 """Creates a blanked-out teamstat record for the given game type"""
837 # this is what we have to do to get partitioned records in - grab the
838 # sequence value first, then insert using the explicit ID (vs autogenerate)
839 seq = Sequence('team_game_stats_team_game_stat_id_seq')
840 teamstat_id = session.execute(seq)
841 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
842 create_dt=datetime.datetime.utcnow())
844 # all team game modes have a score, so we'll zero that out always
847 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
850 if game_type_cd == 'ctf':
856 def create_team_stat(session, game, events):
857 """Team stats handler for all game types"""
860 teamstat = create_default_team_stat(session, game.game_type_cd)
861 teamstat.game_id = game.game_id
863 # we should have a team ID if we have a 'Q' event
864 if re.match(r'^team#\d+$', events.get('Q', '')):
865 team = int(events.get('Q').replace('team#', ''))
868 # gametype-specific stuff is handled here. if passed to us, we store it
869 for (key,value) in events.items():
870 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
871 if key == 'scoreboard-caps': teamstat.caps = int(value)
872 if key == 'scoreboard-goals': teamstat.caps = int(value)
873 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
875 session.add(teamstat)
876 except Exception as e:
882 def create_weapon_stats(session, version, game, player, pgstat, events):
883 """Weapon stats handler for all game types"""
886 # Version 1 of stats submissions doubled the data sent.
887 # To counteract this we divide the data by 2 only for
888 # POSTs coming from version 1.
892 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
898 for (key,value) in events.items():
899 matched = re.search("acc-(.*?)-cnt-fired", key)
901 weapon_cd = matched.group(1)
903 # Weapon names changed for 0.8. We'll convert the old
904 # ones to use the new scheme as well.
905 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
907 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
908 pwstat_id = session.execute(seq)
909 pwstat = PlayerWeaponStat()
910 pwstat.player_weapon_stats_id = pwstat_id
911 pwstat.player_id = player.player_id
912 pwstat.game_id = game.game_id
913 pwstat.player_game_stat_id = pgstat.player_game_stat_id
914 pwstat.weapon_cd = mapped_weapon_cd
917 pwstat.nick = events['n']
919 pwstat.nick = events['P']
921 if 'acc-' + weapon_cd + '-cnt-fired' in events:
922 pwstat.fired = int(round(float(
923 events['acc-' + weapon_cd + '-cnt-fired'])))
924 if 'acc-' + weapon_cd + '-fired' in events:
925 pwstat.max = int(round(float(
926 events['acc-' + weapon_cd + '-fired'])))
927 if 'acc-' + weapon_cd + '-cnt-hit' in events:
928 pwstat.hit = int(round(float(
929 events['acc-' + weapon_cd + '-cnt-hit'])))
930 if 'acc-' + weapon_cd + '-hit' in events:
931 pwstat.actual = int(round(float(
932 events['acc-' + weapon_cd + '-hit'])))
933 if 'acc-' + weapon_cd + '-frags' in events:
934 pwstat.frags = int(round(float(
935 events['acc-' + weapon_cd + '-frags'])))
938 pwstat.fired = pwstat.fired/2
939 pwstat.max = pwstat.max/2
940 pwstat.hit = pwstat.hit/2
941 pwstat.actual = pwstat.actual/2
942 pwstat.frags = pwstat.frags/2
945 pwstats.append(pwstat)
950 def get_ranks(session, player_ids, game_type_cd):
952 Gets the rank entries for all players in the given list, returning a dict
953 of player_id -> PlayerRank instance. The rank entry corresponds to the
954 game type of the parameter passed in as well.
957 for pr in session.query(PlayerRank).\
958 filter(PlayerRank.player_id.in_(player_ids)).\
959 filter(PlayerRank.game_type_cd == game_type_cd).\
961 ranks[pr.player_id] = pr
966 def update_player(session, player, events):
968 Updates a player record using the latest information.
969 :param session: SQLAlchemy session
970 :param player: Player model representing what is in the database right now (before updates)
971 :param events: Dict of player events from the submission
974 nick = events.get('n', 'Anonymous Player')[:128]
975 if nick != player.nick and not nick.startswith("Anonymous Player"):
976 register_new_nick(session, player, nick)
981 def create_player(session, events):
983 Creates a new player from the list of events.
984 :param session: SQLAlchemy session
985 :param events: Dict of player events from the submission
992 nick = events.get('n', None)
994 player.nick = nick[:128]
995 player.stripped_nick = strip_colors(qfont_decode(player.nick))
997 player.nick = "Anonymous Player #{0}".format(player.player_id)
998 player.stripped_nick = player.nick
1000 hk = Hashkey(player_id=player.player_id, hashkey=events.get('P', None))
1006 def get_or_create_players(session, events_by_hashkey):
1007 hashkeys = set(events_by_hashkey.keys())
1008 players_by_hashkey = {}
1010 bot = session.query(Player).filter(Player.player_id == 1).one()
1011 anon = session.query(Player).filter(Player.player_id == 2).one()
1013 # fill in the bots and anonymous players
1014 for hashkey in events_by_hashkey.keys():
1015 if hashkey.startswith("bot#"):
1016 players_by_hashkey[hashkey] = bot
1017 hashkeys.remove(hashkey)
1018 elif hashkey.startswith("player#"):
1019 players_by_hashkey[hashkey] = anon
1020 hashkeys.remove(hashkey)
1022 # We are left with the "real" players and can now fetch them by their collective hashkeys.
1023 # Those that are returned here are pre-existing players who need to be updated.
1024 for p, hk in session.query(Player, Hashkey)\
1025 .filter(Player.player_id == Hashkey.player_id)\
1026 .filter(Hashkey.hashkey.in_(hashkeys))\
1028 log.debug("Found existing player {} with hashkey {}"
1029 .format(p.player_id, hk.hashkey))
1031 player = update_player(session, p, events_by_hashkey[hk.hashkey])
1032 players_by_hashkey[hk.hashkey] = player
1033 hashkeys.remove(hk.hashkey)
1035 # The remainder are the players we haven't seen before, so we need to create them.
1036 for hashkey in hashkeys:
1037 player = create_player(session, events_by_hashkey[hashkey])
1039 log.debug("Created player {0} ({2}) with hashkey {1}"
1040 .format(player.player_id, hashkey, player.nick.encode('utf-8')))
1042 players_by_hashkey[hashkey] = player
1044 return players_by_hashkey
1047 def submit_stats(request):
1049 Entry handler for POST stats submissions.
1051 # placeholder for the actual session
1055 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1056 "----- END REQUEST BODY -----\n\n")
1058 (idfp, status) = verify_request(request)
1060 submission = Submission(request.body, request.headers)
1062 msg = "Invalid submission"
1064 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
1066 content_type="text/plain"
1069 do_precondition_checks(request.registry.settings, submission)
1071 #######################################################################
1072 # Actual setup (inserts/updates) below here
1073 #######################################################################
1074 session = DBSession()
1076 # All game types create Game, Server, Map, and Player records
1078 server = get_or_create_server(
1081 name=submission.server_name,
1082 revision=submission.revision,
1083 ip_addr=get_remote_addr(request),
1084 port=submission.port_number,
1085 impure_cvars=submission.impure_cvar_changes
1088 gmap = get_or_create_map(session, submission.map_name)
1092 game_type_cd=submission.game_type_cd,
1094 server_id=server.server_id,
1096 match_id=submission.match_id,
1097 start_dt=datetime.datetime.utcnow(),
1098 duration=submission.duration
1101 events_by_hashkey = {elem["P"]: elem for elem in submission.humans + submission.bots}
1102 players_by_hashkey = get_or_create_players(session, events_by_hashkey)
1107 hashkeys_by_player_id = {}
1108 for hashkey, player in players_by_hashkey.items():
1109 events = events_by_hashkey[hashkey]
1110 pgstat = create_game_stat(session, game, gmap, player, events)
1111 pgstats.append(pgstat)
1113 # player ranking opt-out
1114 if 'r' in events and events['r'] != '0':
1115 elo_pgstats.append(pgstat)
1117 if player.player_id > 1:
1118 create_anticheats(session, pgstat, game, player, events)
1120 if player.player_id > 2:
1121 player_ids.append(player.player_id)
1122 hashkeys_by_player_id[player.player_id] = hashkey
1124 if should_do_weapon_stats(submission.game_type_cd) and player.player_id > 1:
1125 create_weapon_stats(session, submission.version, game, player, pgstat, events)
1127 # player_ids for human players get stored directly on games for fast indexing
1128 game.players = player_ids
1130 for events in submission.teams:
1131 create_team_stat(session, game, events)
1133 if server.elo_ind and gametype_elo_eligible(submission.game_type_cd):
1134 ep = EloProcessor(session, game, elo_pgstats)
1141 log.debug('Success! Stats recorded.')
1143 # ranks are fetched after we've done the "real" processing
1144 ranks = get_ranks(session, player_ids, submission.game_type_cd)
1146 # plain text response
1147 request.response.content_type = 'text/plain'
1150 "now": calendar.timegm(datetime.datetime.utcnow().timetuple()),
1154 "player_ids": player_ids,
1155 "hashkeys": hashkeys_by_player_id,
1160 except Exception as e: