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 # Make an exception for CTS since it can be done by individuals and there is no Elo anyway
350 if submission.game_type_cd == "cts":
351 minimum_required_players = 1
353 return len(submission.humans) >= minimum_required_players
356 def do_precondition_checks(settings, submission):
357 """Precondition checks for ALL gametypes. These do not require a database connection."""
358 if not has_required_metadata(submission):
359 msg = "Missing required game metadata"
361 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
363 content_type="text/plain"
366 if submission.version is None:
367 msg = "Invalid or incorrect game metadata provided"
369 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
371 content_type="text/plain"
374 if not is_supported_gametype(submission):
375 msg = "Unsupported game type ({})".format(submission.game_type_cd)
377 raise pyramid.httpexceptions.HTTPOk(
379 content_type="text/plain"
382 if not has_minimum_real_players(settings, submission):
383 msg = "Not enough real players"
385 raise pyramid.httpexceptions.HTTPOk(
387 content_type="text/plain"
390 if is_blank_game(submission):
393 raise pyramid.httpexceptions.HTTPOk(
395 content_type="text/plain"
399 def get_remote_addr(request):
400 """Get the Xonotic server's IP address"""
401 if 'X-Forwarded-For' in request.headers:
402 return request.headers['X-Forwarded-For']
404 return request.remote_addr
407 def should_do_weapon_stats(game_type_cd):
408 """True of the game type should record weapon stats. False otherwise."""
409 return game_type_cd not in {'cts'}
412 def should_do_frag_matrix(game_type_cd):
413 """True if the game type should record frag matrix values. False otherwise."""
414 return game_type_cd in {
415 'as', 'ca', 'ctf', 'dm', 'dom', 'ft', 'freezetag', 'ka', 'kh', 'rune', 'tdm',
419 def gametype_elo_eligible(game_type_cd):
420 """True of the game type should process Elos. False otherwise."""
421 return game_type_cd in {'duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft'}
424 def register_new_nick(session, player, new_nick):
426 Change the player record's nick to the newly found nick. Store the old
427 nick in the player_nicks table for that player.
429 session - SQLAlchemy database session factory
430 player - player record whose nick is changing
431 new_nick - the new nickname
433 # see if that nick already exists
434 stripped_nick = strip_colors(qfont_decode(player.nick))
436 player_nick = session.query(PlayerNick).filter_by(
437 player_id=player.player_id, stripped_nick=stripped_nick).one()
438 except NoResultFound, e:
439 # player_id/stripped_nick not found, create one
440 # but we don't store "Anonymous Player #N"
441 if not re.search('^Anonymous Player #\d+$', player.nick):
442 player_nick = PlayerNick()
443 player_nick.player_id = player.player_id
444 player_nick.stripped_nick = stripped_nick
445 player_nick.nick = player.nick
446 session.add(player_nick)
448 # We change to the new nick regardless
449 player.nick = new_nick
450 player.stripped_nick = strip_colors(qfont_decode(new_nick))
454 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
456 Check the fastest cap time for the player and map. If there isn't
457 one, insert one. If there is, check if the passed time is faster.
460 # we don't record fastest cap times for bots or anonymous players
464 # see if a cap entry exists already
465 # then check to see if the new captime is faster
467 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
468 player_id=player_id, map_id=map_id, mod=mod).one()
470 # current captime is faster, so update
471 if captime < cur_fastest_cap.fastest_cap:
472 cur_fastest_cap.fastest_cap = captime
473 cur_fastest_cap.game_id = game_id
474 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
475 session.add(cur_fastest_cap)
477 except NoResultFound, e:
478 # none exists, so insert
479 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
481 session.add(cur_fastest_cap)
485 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
487 Updates the server in the given DB session, if needed.
489 :param server: The found server instance.
490 :param name: The incoming server name.
491 :param hashkey: The incoming server hashkey.
492 :param ip_addr: The incoming server IP address.
493 :param port: The incoming server port.
494 :param revision: The incoming server revision.
495 :param impure_cvars: The incoming number of impure server cvars.
498 # ensure the two int attributes are actually ints
505 impure_cvars = int(impure_cvars)
510 if name and server.name != name:
513 if hashkey and server.hashkey != hashkey:
514 server.hashkey = hashkey
516 if ip_addr and server.ip_addr != ip_addr:
517 server.ip_addr = ip_addr
519 if port and server.port != port:
522 if revision and server.revision != revision:
523 server.revision = revision
525 if impure_cvars and server.impure_cvars != impure_cvars:
526 server.impure_cvars = impure_cvars
527 server.pure_ind = True if impure_cvars == 0 else False
533 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
535 Find a server by name or create one if not found. Parameters:
537 session - SQLAlchemy database session factory
538 name - server name of the server to be found or created
539 hashkey - server hashkey
540 ip_addr - the IP address of the server
541 revision - the xonotic revision number
542 port - the port number of the server
543 impure_cvars - the number of impure cvar changes
545 servers_q = DBSession.query(Server).filter(Server.active_ind)
548 # if the hashkey is provided, we'll use that
549 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
551 # otherwise, it is just by name
552 servers_q = servers_q.filter(Server.name == name)
554 # order by the hashkey, which means any hashkey match will appear first if there are multiple
555 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
557 if len(servers) == 0:
558 server = Server(name=name, hashkey=hashkey)
561 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
564 if len(servers) == 1:
565 log.info("Found existing server {}.".format(server.server_id))
567 elif len(servers) > 1:
568 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
569 log.warn("Multiple servers found ({})! Using the first one ({})."
570 .format(server_id_list, server.server_id))
572 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
578 def get_or_create_map(session, name):
580 Find a map by name or create one if not found. Parameters:
582 session - SQLAlchemy database session factory
583 name - map name of the map to be found or created
585 maps = session.query(Map).filter_by(name=name).order_by(Map.map_id).all()
587 if maps is None or len(maps) == 0:
588 gmap = Map(name=name)
591 log.debug("Created map id {}: {}".format(gmap.map_id, gmap.name))
594 log.debug("Found map id {}: {}".format(gmap.map_id, gmap.name))
597 map_id_list = ", ".join(["{}".format(m.map_id) for m in maps])
598 log.warn("Multiple maps found for {} ({})! Using the first one.".format(name, map_id_list))
603 def create_game(session, game_type_cd, server_id, map_id, match_id, start_dt, duration, mod,
606 Creates a game. Parameters:
608 session - SQLAlchemy database session factory
609 game_type_cd - the game type of the game being played
610 mod - mods in use during the game
611 server_id - server identifier of the server hosting the game
612 map_id - map on which the game was played
613 match_id - a unique match ID given by the server
614 start_dt - when the game started (datetime object)
615 duration - how long the game lasted
616 winner - the team id of the team that won
618 seq = Sequence('games_game_id_seq')
619 game_id = session.execute(seq)
620 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd, server_id=server_id,
621 map_id=map_id, winner=winner)
622 game.match_id = match_id
625 # There is some drift between start_dt (provided by app) and create_dt
626 # (default in the database), so we'll make them the same until this is
628 game.create_dt = start_dt
630 game.duration = duration
633 session.query(Game).filter(Game.server_id == server_id)\
634 .filter(Game.match_id == match_id).one()
636 log.debug("Error: game with same server and match_id found! Ignoring.")
638 # if a game under the same server_id and match_id exists, this is a duplicate
639 msg = "Duplicate game (pre-existing match_id)"
641 raise pyramid.httpexceptions.HTTPOk(body=msg, content_type="text/plain")
643 except NoResultFound:
644 # server_id/match_id combination not found. game is ok to insert
647 log.debug("Created game id {} on server {}, map {} at {}"
648 .format(game.game_id, server_id, map_id, start_dt))
653 def get_or_create_player(session=None, hashkey=None, nick=None):
655 Finds a player by hashkey or creates a new one (along with a
656 corresponding hashkey entry. Parameters:
658 session - SQLAlchemy database session factory
659 hashkey - hashkey of the player to be found or created
660 nick - nick of the player (in case of a first time create)
663 if re.search('^bot#\d+', hashkey):
664 player = session.query(Player).filter_by(player_id=1).one()
665 # if we have an untracked player
666 elif re.search('^player#\d+$', hashkey):
667 player = session.query(Player).filter_by(player_id=2).one()
668 # else it is a tracked player
670 # see if the player is already in the database
671 # if not, create one and the hashkey along with it
673 hk = session.query(Hashkey).filter_by(
674 hashkey=hashkey).one()
675 player = session.query(Player).filter_by(
676 player_id=hk.player_id).one()
677 log.debug("Found existing player {0} with hashkey {1}".format(
678 player.player_id, hashkey))
684 # if nick is given to us, use it. If not, use "Anonymous Player"
685 # with a suffix added for uniqueness.
687 player.nick = nick[:128]
688 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
690 player.nick = "Anonymous Player #{0}".format(player.player_id)
691 player.stripped_nick = player.nick
693 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
695 log.debug("Created player {0} ({2}) with hashkey {1}".format(
696 player.player_id, hashkey, player.nick.encode('utf-8')))
701 def create_default_game_stat(session, game_type_cd):
702 """Creates a blanked-out pgstat record for the given game type"""
704 # this is what we have to do to get partitioned records in - grab the
705 # sequence value first, then insert using the explicit ID (vs autogenerate)
706 seq = Sequence('player_game_stats_player_game_stat_id_seq')
707 pgstat_id = session.execute(seq)
708 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
709 create_dt=datetime.datetime.utcnow())
711 if game_type_cd == 'as':
712 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
714 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
715 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
717 if game_type_cd == 'cq':
718 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
721 if game_type_cd == 'ctf':
722 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
723 pgstat.returns = pgstat.carrier_frags = 0
725 if game_type_cd == 'cts':
728 if game_type_cd == 'dom':
729 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
732 if game_type_cd == 'ft':
733 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
735 if game_type_cd == 'ka':
736 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
737 pgstat.carrier_frags = 0
738 pgstat.time = datetime.timedelta(seconds=0)
740 if game_type_cd == 'kh':
741 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
742 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
743 pgstat.carrier_frags = 0
745 if game_type_cd == 'lms':
746 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
748 if game_type_cd == 'nb':
749 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
752 if game_type_cd == 'rc':
753 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
758 def create_game_stat(session, game, gmap, player, events):
759 """Game stats handler for all game types"""
761 game_type_cd = game.game_type_cd
763 pgstat = create_default_game_stat(session, game_type_cd)
765 # these fields should be on every pgstat record
766 pgstat.game_id = game.game_id
767 pgstat.player_id = player.player_id
768 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
769 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
770 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
771 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
772 pgstat.rank = int(events.get('rank', None))
773 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
777 # gametype-specific stuff is handled here. if passed to us, we store it
778 for (key,value) in events.items():
779 if key == 'wins': wins = True
780 if key == 't': pgstat.team = int(value)
782 if key == 'scoreboard-drops': pgstat.drops = int(value)
783 if key == 'scoreboard-returns': pgstat.returns = int(value)
784 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
785 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
786 if key == 'scoreboard-caps': pgstat.captures = int(value)
787 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
788 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
789 if key == 'scoreboard-kills': pgstat.kills = int(value)
790 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
791 if key == 'scoreboard-objectives': pgstat.collects = int(value)
792 if key == 'scoreboard-captured': pgstat.captures = int(value)
793 if key == 'scoreboard-released': pgstat.drops = int(value)
794 if key == 'scoreboard-fastest':
795 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
796 if key == 'scoreboard-takes': pgstat.pickups = int(value)
797 if key == 'scoreboard-ticks': pgstat.drops = int(value)
798 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
799 if key == 'scoreboard-bctime':
800 pgstat.time = datetime.timedelta(seconds=int(value))
801 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
802 if key == 'scoreboard-losses': pgstat.drops = int(value)
803 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
804 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
805 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
806 if key == 'scoreboard-lives': pgstat.lives = int(value)
807 if key == 'scoreboard-goals': pgstat.captures = int(value)
808 if key == 'scoreboard-faults': pgstat.drops = int(value)
809 if key == 'scoreboard-laps': pgstat.laps = int(value)
811 if key == 'avglatency': pgstat.avg_latency = float(value)
812 if key == 'scoreboard-captime':
813 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
814 if game.game_type_cd == 'ctf':
815 update_fastest_cap(session, player.player_id, game.game_id,
816 gmap.map_id, pgstat.fastest, game.mod)
818 # there is no "winning team" field, so we have to derive it
819 if wins and pgstat.team is not None and game.winner is None:
820 game.winner = pgstat.team
828 def create_anticheats(session, pgstat, game, player, events):
829 """Anticheats handler for all game types"""
833 # all anticheat events are prefixed by "anticheat"
834 for (key,value) in events.items():
835 if key.startswith("anticheat"):
837 ac = PlayerGameAnticheat(
843 anticheats.append(ac)
845 except Exception as e:
846 log.debug("Could not parse value for key %s. Ignoring." % key)
851 def create_default_team_stat(session, game_type_cd):
852 """Creates a blanked-out teamstat record for the given game type"""
854 # this is what we have to do to get partitioned records in - grab the
855 # sequence value first, then insert using the explicit ID (vs autogenerate)
856 seq = Sequence('team_game_stats_team_game_stat_id_seq')
857 teamstat_id = session.execute(seq)
858 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
859 create_dt=datetime.datetime.utcnow())
861 # all team game modes have a score, so we'll zero that out always
864 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
867 if game_type_cd == 'ctf':
873 def create_team_stat(session, game, events):
874 """Team stats handler for all game types"""
877 teamstat = create_default_team_stat(session, game.game_type_cd)
878 teamstat.game_id = game.game_id
880 # we should have a team ID if we have a 'Q' event
881 if re.match(r'^team#\d+$', events.get('Q', '')):
882 team = int(events.get('Q').replace('team#', ''))
885 # gametype-specific stuff is handled here. if passed to us, we store it
886 for (key,value) in events.items():
887 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
888 if key == 'scoreboard-caps': teamstat.caps = int(value)
889 if key == 'scoreboard-goals': teamstat.caps = int(value)
890 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
892 session.add(teamstat)
893 except Exception as e:
899 def create_weapon_stats(session, version, game, player, pgstat, events):
900 """Weapon stats handler for all game types"""
903 # Version 1 of stats submissions doubled the data sent.
904 # To counteract this we divide the data by 2 only for
905 # POSTs coming from version 1.
909 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
915 for (key,value) in events.items():
916 matched = re.search("acc-(.*?)-cnt-fired", key)
918 weapon_cd = matched.group(1)
920 # Weapon names changed for 0.8. We'll convert the old
921 # ones to use the new scheme as well.
922 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
924 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
925 pwstat_id = session.execute(seq)
926 pwstat = PlayerWeaponStat()
927 pwstat.player_weapon_stats_id = pwstat_id
928 pwstat.player_id = player.player_id
929 pwstat.game_id = game.game_id
930 pwstat.player_game_stat_id = pgstat.player_game_stat_id
931 pwstat.weapon_cd = mapped_weapon_cd
934 pwstat.nick = events['n']
936 pwstat.nick = events['P']
938 if 'acc-' + weapon_cd + '-cnt-fired' in events:
939 pwstat.fired = int(round(float(
940 events['acc-' + weapon_cd + '-cnt-fired'])))
941 if 'acc-' + weapon_cd + '-fired' in events:
942 pwstat.max = int(round(float(
943 events['acc-' + weapon_cd + '-fired'])))
944 if 'acc-' + weapon_cd + '-cnt-hit' in events:
945 pwstat.hit = int(round(float(
946 events['acc-' + weapon_cd + '-cnt-hit'])))
947 if 'acc-' + weapon_cd + '-hit' in events:
948 pwstat.actual = int(round(float(
949 events['acc-' + weapon_cd + '-hit'])))
950 if 'acc-' + weapon_cd + '-frags' in events:
951 pwstat.frags = int(round(float(
952 events['acc-' + weapon_cd + '-frags'])))
955 pwstat.fired = pwstat.fired/2
956 pwstat.max = pwstat.max/2
957 pwstat.hit = pwstat.hit/2
958 pwstat.actual = pwstat.actual/2
959 pwstat.frags = pwstat.frags/2
962 pwstats.append(pwstat)
967 def get_ranks(session, player_ids, game_type_cd):
969 Gets the rank entries for all players in the given list, returning a dict
970 of player_id -> PlayerRank instance. The rank entry corresponds to the
971 game type of the parameter passed in as well.
974 for pr in session.query(PlayerRank).\
975 filter(PlayerRank.player_id.in_(player_ids)).\
976 filter(PlayerRank.game_type_cd == game_type_cd).\
978 ranks[pr.player_id] = pr
983 def update_player(session, player, events):
985 Updates a player record using the latest information.
986 :param session: SQLAlchemy session
987 :param player: Player model representing what is in the database right now (before updates)
988 :param events: Dict of player events from the submission
991 nick = events.get('n', 'Anonymous Player')[:128]
992 if nick != player.nick and not nick.startswith("Anonymous Player"):
993 register_new_nick(session, player, nick)
998 def create_player(session, events):
1000 Creates a new player from the list of events.
1001 :param session: SQLAlchemy session
1002 :param events: Dict of player events from the submission
1009 nick = events.get('n', None)
1011 player.nick = nick[:128]
1012 player.stripped_nick = strip_colors(qfont_decode(player.nick))
1014 player.nick = "Anonymous Player #{0}".format(player.player_id)
1015 player.stripped_nick = player.nick
1017 hk = Hashkey(player_id=player.player_id, hashkey=events.get('P', None))
1023 def get_or_create_players(session, events_by_hashkey):
1024 hashkeys = set(events_by_hashkey.keys())
1025 players_by_hashkey = {}
1027 bot = session.query(Player).filter(Player.player_id == 1).one()
1028 anon = session.query(Player).filter(Player.player_id == 2).one()
1030 # fill in the bots and anonymous players
1031 for hashkey in events_by_hashkey.keys():
1032 if hashkey.startswith("bot#"):
1033 players_by_hashkey[hashkey] = bot
1034 hashkeys.remove(hashkey)
1035 elif hashkey.startswith("player#"):
1036 players_by_hashkey[hashkey] = anon
1037 hashkeys.remove(hashkey)
1039 # We are left with the "real" players and can now fetch them by their collective hashkeys.
1040 # Those that are returned here are pre-existing players who need to be updated.
1041 for p, hk in session.query(Player, Hashkey)\
1042 .filter(Player.player_id == Hashkey.player_id)\
1043 .filter(Hashkey.hashkey.in_(hashkeys))\
1045 log.debug("Found existing player {} with hashkey {}"
1046 .format(p.player_id, hk.hashkey))
1048 player = update_player(session, p, events_by_hashkey[hk.hashkey])
1049 players_by_hashkey[hk.hashkey] = player
1050 hashkeys.remove(hk.hashkey)
1052 # The remainder are the players we haven't seen before, so we need to create them.
1053 for hashkey in hashkeys:
1054 player = create_player(session, events_by_hashkey[hashkey])
1056 log.debug("Created player {0} ({2}) with hashkey {1}"
1057 .format(player.player_id, hashkey, player.nick.encode('utf-8')))
1059 players_by_hashkey[hashkey] = player
1061 return players_by_hashkey
1064 def create_frag_matrix(session, player_indexes, pgstat, events):
1066 Construct a PlayerFragMatrix object from the events of a given player.
1068 :param session: The DBSession we're adding objects to.
1069 :param player_indexes: The set of player indexes of those that actually played in the game.
1070 :param pgstat: The PlayerGameStat object of the player whose frag matrix we want to create.
1071 :param events: The raw player events of the above player.
1072 :return: PlayerFragMatrix
1074 player_index = int(events.get("i", None))
1077 victim_index = lambda x: int(x.split("-")[1])
1079 matrix = {victim_index(k): int(v) for (k, v) in events.items()
1080 if k.startswith("kills-") and victim_index(k) in player_indexes}
1083 pfm = PlayerGameFragMatrix(pgstat.game_id, pgstat.player_game_stat_id, pgstat.player_id,
1084 player_index, matrix)
1092 def submit_stats(request):
1094 Entry handler for POST stats submissions.
1096 # placeholder for the actual session
1100 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1101 "----- END REQUEST BODY -----\n\n")
1103 (idfp, status) = verify_request(request)
1105 submission = Submission(request.body, request.headers)
1107 msg = "Invalid submission"
1109 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
1111 content_type="text/plain"
1114 do_precondition_checks(request.registry.settings, submission)
1116 #######################################################################
1117 # Actual setup (inserts/updates) below here
1118 #######################################################################
1119 session = DBSession()
1121 # All game types create Game, Server, Map, and Player records
1123 server = get_or_create_server(
1126 name=submission.server_name,
1127 revision=submission.revision,
1128 ip_addr=get_remote_addr(request),
1129 port=submission.port_number,
1130 impure_cvars=submission.impure_cvar_changes
1133 gmap = get_or_create_map(session, submission.map_name)
1137 game_type_cd=submission.game_type_cd,
1139 server_id=server.server_id,
1141 match_id=submission.match_id,
1142 start_dt=datetime.datetime.utcnow(),
1143 duration=submission.duration
1146 events_by_hashkey = {elem["P"]: elem for elem in submission.humans + submission.bots}
1147 players_by_hashkey = get_or_create_players(session, events_by_hashkey)
1152 hashkeys_by_player_id = {}
1153 for hashkey, player in players_by_hashkey.items():
1154 events = events_by_hashkey[hashkey]
1156 pgstat = create_game_stat(session, game, gmap, player, events)
1157 pgstats.append(pgstat)
1159 if should_do_frag_matrix(submission.game_type_cd):
1160 create_frag_matrix(session, submission.player_indexes, pgstat, events)
1162 # player ranking opt-out
1163 if 'r' in events and events['r'] == '0':
1164 log.debug("Excluding player {} from ranking calculations (opt-out)"
1165 .format(pgstat.player_id))
1167 elo_pgstats.append(pgstat)
1169 if player.player_id > 1:
1170 create_anticheats(session, pgstat, game, player, events)
1172 if player.player_id > 2:
1173 player_ids.append(player.player_id)
1174 hashkeys_by_player_id[player.player_id] = hashkey
1176 if should_do_weapon_stats(submission.game_type_cd) and player.player_id > 1:
1177 create_weapon_stats(session, submission.version, game, player, pgstat, events)
1179 # player_ids for human players get stored directly on games for fast indexing
1180 game.players = player_ids
1182 for events in submission.teams:
1183 create_team_stat(session, game, events)
1185 if server.elo_ind and gametype_elo_eligible(submission.game_type_cd):
1186 ep = EloProcessor(session, game, elo_pgstats)
1193 log.debug('Success! Stats recorded.')
1195 # ranks are fetched after we've done the "real" processing
1196 ranks = get_ranks(session, player_ids, submission.game_type_cd)
1198 # plain text response
1199 request.response.content_type = 'text/plain'
1202 "now": calendar.timegm(datetime.datetime.utcnow().timetuple()),
1206 "player_ids": player_ids,
1207 "hashkeys": hashkeys_by_player_id,
1212 except Exception as e: