7 import pyramid.httpexceptions
8 from sqlalchemy import Sequence
9 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
10 from xonstat.elo import EloProcessor
11 from xonstat.models import DBSession, Server, Map, Game, PlayerGameStat, PlayerWeaponStat
12 from xonstat.models import PlayerRank, PlayerCaptime
13 from xonstat.models import TeamGameStat, PlayerGameAnticheat, Player, Hashkey, PlayerNick
14 from xonstat.util import strip_colors, qfont_decode, verify_request, weapon_map
16 log = logging.getLogger(__name__)
19 def is_real_player(events):
21 Determines if a given set of events correspond with a non-bot
23 return not events['P'].startswith('bot')
26 def played_in_game(events):
28 Determines if a given set of player events correspond with a player who
29 played in the game (matches 1 and scoreboardvalid 1)
31 return 'matches' in events and 'scoreboardvalid' in events
34 class Submission(object):
35 """Parses an incoming POST request for stats submissions."""
37 def __init__(self, body, headers):
38 # a copy of the HTTP headers
39 self.headers = headers
41 # a copy of the HTTP POST body
44 # the submission code version (from the server)
47 # the revision string of the server
50 # the game type played
51 self.game_type_cd = None
56 # the name of the map played
59 # unique identifier (string) for a match on a given server
62 # the name of the server
63 self.server_name = None
65 # the number of cvars that were changed to be different than default
66 self.impure_cvar_changes = None
68 # the port number the game server is listening on
69 self.port_number = None
71 # how long the game lasted
74 # which ladder is being used, if any
77 # players involved in the match (humans, bots, and spectators)
83 # the parsing deque (we use this to allow peeking)
84 self.q = collections.deque(self.body.split("\n"))
86 ############################################################################################
87 # Below this point are fields useful in determining if the submission is valid or
88 # performance optimizations that save us from looping over the events over and over again.
89 ############################################################################################
91 # humans who played in the match
94 # bots who played in the match
97 # distinct weapons that we have seen fired
100 # has a human player fired a shot?
101 self.human_fired_weapon = False
103 # does any human have a non-zero score?
104 self.human_nonzero_score = False
106 # does any human have a fastest cap?
107 self.human_fastest = False
110 """Returns the next key:value pair off the queue."""
112 items = self.q.popleft().strip().split(' ', 1)
114 # Some keys won't have values, like 'L' records where the server isn't actually
115 # participating in any ladders. These can be safely ignored.
122 def add_weapon_fired(self, sub_key):
123 """Adds a weapon to the set of weapons fired during the match (a set)."""
124 self.weapons.add(sub_key.split("-")[1])
126 def parse_player(self, key, pid):
127 """Construct a player events listing from the submission."""
129 # all of the keys related to player records
130 player_keys = ['i', 'n', 't', 'e']
134 player_fired_weapon = False
135 player_nonzero_score = False
136 player_fastest = False
138 # Consume all following 'i' 'n' 't' 'e' records
139 while len(self.q) > 0:
140 (key, value) = self.next_item()
141 if key is None and value is None:
144 (sub_key, sub_value) = value.split(' ', 1)
145 player[sub_key] = sub_value
147 if sub_key.endswith("cnt-fired"):
148 player_fired_weapon = True
149 self.add_weapon_fired(sub_key)
150 elif sub_key == 'scoreboard-score' and int(sub_value) != 0:
151 player_nonzero_score = True
152 elif sub_key == 'scoreboard-fastest':
153 player_fastest = True
155 player[key] = unicode(value, 'utf-8')
156 elif key in player_keys:
159 # something we didn't expect - put it back on the deque
160 self.q.appendleft("{} {}".format(key, value))
163 played = played_in_game(player)
164 human = is_real_player(player)
167 self.humans.append(player)
169 if player_fired_weapon:
170 self.human_fired_weapon = True
172 if player_nonzero_score:
173 self.human_nonzero_score = True
176 self.human_fastest = True
178 elif played and not human:
179 self.bots.append(player)
181 self.players.append(player)
183 def parse_team(self, key, tid):
184 """Construct a team events listing from the submission."""
187 # Consume all following 'e' records
188 while len(self.q) > 0 and self.q[0].startswith('e'):
189 (_, value) = self.next_item()
190 (sub_key, sub_value) = value.split(' ', 1)
191 team[sub_key] = sub_value
193 self.teams.append(team)
196 """Parses the request body into instance variables."""
197 while len(self.q) > 0:
198 (key, value) = self.next_item()
199 if key is None and value is None:
204 self.revision = value
206 self.game_type_cd = value
210 self.map_name = value
212 self.match_id = value
214 self.server_name = unicode(value, 'utf-8')
216 self.impure_cvar_changes = int(value)
218 self.port_number = int(value)
220 self.duration = datetime.timedelta(seconds=int(round(float(value))))
224 self.parse_team(key, value)
226 self.parse_player(key, value)
228 raise Exception("Invalid submission")
233 def elo_submission_category(submission):
234 """Determines the Elo category purely by what is in the submission data."""
235 mod = submission.meta.get("O", "None")
237 vanilla_allowed_weapons = {"shotgun", "devastator", "blaster", "mortar", "vortex", "electro",
238 "arc", "hagar", "crylink", "machinegun"}
239 insta_allowed_weapons = {"vaporizer", "blaster"}
240 overkill_allowed_weapons = {"hmg", "vortex", "shotgun", "blaster", "machinegun", "rpc"}
243 if len(submission.weapons - vanilla_allowed_weapons) == 0:
245 elif mod == "InstaGib":
246 if len(submission.weapons - insta_allowed_weapons) == 0:
248 elif mod == "Overkill":
249 if len(submission.weapons - overkill_allowed_weapons) == 0:
257 def parse_stats_submission(body):
259 Parses the POST request body for a stats submission
261 # storage vars for the request body
267 # we're not in either stanza to start
270 for line in body.split('\n'):
272 (key, value) = line.strip().split(' ', 1)
274 # Server (S) and Nick (n) fields can have international characters.
276 value = unicode(value, 'utf-8')
278 if key not in 'P' 'Q' 'n' 'e' 't' 'i':
279 game_meta[key] = value
281 if key == 'Q' or key == 'P':
282 #log.debug('Found a {0}'.format(key))
283 #log.debug('in_Q: {0}'.format(in_Q))
284 #log.debug('in_P: {0}'.format(in_P))
285 #log.debug('events: {0}'.format(events))
287 # check where we were before and append events accordingly
288 if in_Q and len(events) > 0:
289 #log.debug('creating a team (Q) entry')
292 elif in_P and len(events) > 0:
293 #log.debug('creating a player (P) entry')
294 players.append(events)
298 #log.debug('key == P')
302 #log.debug('key == Q')
309 (subkey, subvalue) = value.split(' ', 1)
310 events[subkey] = subvalue
316 # no key/value pair - move on to the next line
319 # add the last entity we were working on
320 if in_P and len(events) > 0:
321 players.append(events)
322 elif in_Q and len(events) > 0:
325 return (game_meta, players, teams)
328 def is_blank_game(gametype, players):
329 """Determine if this is a blank game or not. A blank game is either:
331 1) a match that ended in the warmup stage, where accuracy events are not
332 present (for non-CTS games)
334 2) a match in which no player made a positive or negative score AND was
337 ... or for CTS, which doesn't record accuracy events
339 1) a match in which no player made a fastest lap AND was
342 ... or for NB, in which not all maps have weapons
344 1) a match in which no player made a positive or negative score
346 r = re.compile(r'acc-.*-cnt-fired')
347 flg_nonzero_score = False
348 flg_acc_events = False
349 flg_fastest_lap = False
351 for events in players:
352 if is_real_player(events) and played_in_game(events):
353 for (key,value) in events.items():
354 if key == 'scoreboard-score' and value != 0:
355 flg_nonzero_score = True
357 flg_acc_events = True
358 if key == 'scoreboard-fastest':
359 flg_fastest_lap = True
361 if gametype == 'cts':
362 return not flg_fastest_lap
363 elif gametype == 'nb':
364 return not flg_nonzero_score
366 return not (flg_nonzero_score and flg_acc_events)
369 def get_remote_addr(request):
370 """Get the Xonotic server's IP address"""
371 if 'X-Forwarded-For' in request.headers:
372 return request.headers['X-Forwarded-For']
374 return request.remote_addr
377 def is_supported_gametype(gametype, version):
378 """Whether a gametype is supported or not"""
381 # if the type can be supported, but with version constraints, uncomment
382 # here and add the restriction for a specific version below
383 supported_game_types = (
402 if gametype in supported_game_types:
407 # some game types were buggy before revisions, thus this additional filter
408 if gametype == 'ca' and version <= 5:
414 def do_precondition_checks(request, game_meta, raw_players):
415 """Precondition checks for ALL gametypes.
416 These do not require a database connection."""
417 if not has_required_metadata(game_meta):
418 msg = "Missing required game metadata"
420 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
422 content_type="text/plain"
426 version = int(game_meta['V'])
428 msg = "Invalid or incorrect game metadata provided"
430 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
432 content_type="text/plain"
435 if not is_supported_gametype(game_meta['G'], version):
436 msg = "Unsupported game type ({})".format(game_meta['G'])
438 raise pyramid.httpexceptions.HTTPOk(
440 content_type="text/plain"
443 if not has_minimum_real_players(request.registry.settings, raw_players):
444 msg = "Not enough real players"
446 raise pyramid.httpexceptions.HTTPOk(
448 content_type="text/plain"
451 if is_blank_game(game_meta['G'], raw_players):
454 raise pyramid.httpexceptions.HTTPOk(
456 content_type="text/plain"
460 def num_real_players(player_events):
462 Returns the number of real players (those who played
463 and are on the scoreboard).
467 for events in player_events:
468 if is_real_player(events) and played_in_game(events):
474 def has_minimum_real_players(settings, player_events):
476 Determines if the collection of player events has enough "real" players
477 to store in the database. The minimum setting comes from the config file
478 under the setting xonstat.minimum_real_players.
480 flg_has_min_real_players = True
483 minimum_required_players = int(
484 settings['xonstat.minimum_required_players'])
486 minimum_required_players = 2
488 real_players = num_real_players(player_events)
490 if real_players < minimum_required_players:
491 flg_has_min_real_players = False
493 return flg_has_min_real_players
496 def has_required_metadata(metadata):
498 Determines if a give set of metadata has enough data to create a game,
499 server, and map with.
501 flg_has_req_metadata = True
503 if 'G' not in metadata or\
504 'M' not in metadata or\
505 'I' not in metadata or\
507 flg_has_req_metadata = False
509 return flg_has_req_metadata
512 def should_do_weapon_stats(game_type_cd):
513 """True of the game type should record weapon stats. False otherwise."""
514 if game_type_cd in 'cts':
520 def gametype_elo_eligible(game_type_cd):
521 """True of the game type should process Elos. False otherwise."""
522 elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft')
524 if game_type_cd in elo_game_types:
530 def register_new_nick(session, player, new_nick):
532 Change the player record's nick to the newly found nick. Store the old
533 nick in the player_nicks table for that player.
535 session - SQLAlchemy database session factory
536 player - player record whose nick is changing
537 new_nick - the new nickname
539 # see if that nick already exists
540 stripped_nick = strip_colors(qfont_decode(player.nick))
542 player_nick = session.query(PlayerNick).filter_by(
543 player_id=player.player_id, stripped_nick=stripped_nick).one()
544 except NoResultFound, e:
545 # player_id/stripped_nick not found, create one
546 # but we don't store "Anonymous Player #N"
547 if not re.search('^Anonymous Player #\d+$', player.nick):
548 player_nick = PlayerNick()
549 player_nick.player_id = player.player_id
550 player_nick.stripped_nick = stripped_nick
551 player_nick.nick = player.nick
552 session.add(player_nick)
554 # We change to the new nick regardless
555 player.nick = new_nick
556 player.stripped_nick = strip_colors(qfont_decode(new_nick))
560 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
562 Check the fastest cap time for the player and map. If there isn't
563 one, insert one. If there is, check if the passed time is faster.
566 # we don't record fastest cap times for bots or anonymous players
570 # see if a cap entry exists already
571 # then check to see if the new captime is faster
573 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
574 player_id=player_id, map_id=map_id, mod=mod).one()
576 # current captime is faster, so update
577 if captime < cur_fastest_cap.fastest_cap:
578 cur_fastest_cap.fastest_cap = captime
579 cur_fastest_cap.game_id = game_id
580 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
581 session.add(cur_fastest_cap)
583 except NoResultFound, e:
584 # none exists, so insert
585 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
587 session.add(cur_fastest_cap)
591 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
593 Updates the server in the given DB session, if needed.
595 :param server: The found server instance.
596 :param name: The incoming server name.
597 :param hashkey: The incoming server hashkey.
598 :param ip_addr: The incoming server IP address.
599 :param port: The incoming server port.
600 :param revision: The incoming server revision.
601 :param impure_cvars: The incoming number of impure server cvars.
604 # ensure the two int attributes are actually ints
611 impure_cvars = int(impure_cvars)
616 if name and server.name != name:
619 if hashkey and server.hashkey != hashkey:
620 server.hashkey = hashkey
622 if ip_addr and server.ip_addr != ip_addr:
623 server.ip_addr = ip_addr
625 if port and server.port != port:
628 if revision and server.revision != revision:
629 server.revision = revision
631 if impure_cvars and server.impure_cvars != impure_cvars:
632 server.impure_cvars = impure_cvars
633 server.pure_ind = True if impure_cvars == 0 else False
639 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
641 Find a server by name or create one if not found. Parameters:
643 session - SQLAlchemy database session factory
644 name - server name of the server to be found or created
645 hashkey - server hashkey
646 ip_addr - the IP address of the server
647 revision - the xonotic revision number
648 port - the port number of the server
649 impure_cvars - the number of impure cvar changes
651 servers_q = DBSession.query(Server).filter(Server.active_ind)
654 # if the hashkey is provided, we'll use that
655 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
657 # otherwise, it is just by name
658 servers_q = servers_q.filter(Server.name == name)
660 # order by the hashkey, which means any hashkey match will appear first if there are multiple
661 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
663 if len(servers) == 0:
664 server = Server(name=name, hashkey=hashkey)
667 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
670 if len(servers) == 1:
671 log.info("Found existing server {}.".format(server.server_id))
673 elif len(servers) > 1:
674 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
675 log.warn("Multiple servers found ({})! Using the first one ({})."
676 .format(server_id_list, server.server_id))
678 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
684 def get_or_create_map(session=None, name=None):
686 Find a map by name or create one if not found. Parameters:
688 session - SQLAlchemy database session factory
689 name - map name of the map to be found or created
692 # find one by the name, if it exists
693 gmap = session.query(Map).filter_by(name=name).one()
694 log.debug("Found map id {0}: {1}".format(gmap.map_id,
696 except NoResultFound, e:
697 gmap = Map(name=name)
700 log.debug("Created map id {0}: {1}".format(gmap.map_id,
702 except MultipleResultsFound, e:
703 # multiple found, so use the first one but warn
705 gmaps = session.query(Map).filter_by(name=name).order_by(
708 log.debug("Found map id {0}: {1} but found \
709 multiple".format(gmap.map_id, gmap.name))
714 def create_game(session, start_dt, game_type_cd, server_id, map_id,
715 match_id, duration, mod, winner=None):
717 Creates a game. Parameters:
719 session - SQLAlchemy database session factory
720 start_dt - when the game started (datetime object)
721 game_type_cd - the game type of the game being played
722 server_id - server identifier of the server hosting the game
723 map_id - map on which the game was played
724 winner - the team id of the team that won
725 duration - how long the game lasted
726 mod - mods in use during the game
728 seq = Sequence('games_game_id_seq')
729 game_id = session.execute(seq)
730 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
731 server_id=server_id, map_id=map_id, winner=winner)
732 game.match_id = match_id
735 # There is some drift between start_dt (provided by app) and create_dt
736 # (default in the database), so we'll make them the same until this is
738 game.create_dt = start_dt
741 game.duration = datetime.timedelta(seconds=int(round(float(duration))))
746 session.query(Game).filter(Game.server_id==server_id).\
747 filter(Game.match_id==match_id).one()
749 log.debug("Error: game with same server and match_id found! Ignoring.")
751 # if a game under the same server and match_id found,
752 # this is a duplicate game and can be ignored
753 raise pyramid.httpexceptions.HTTPOk('OK')
754 except NoResultFound, e:
755 # server_id/match_id combination not found. game is ok to insert
758 log.debug("Created game id {0} on server {1}, map {2} at \
759 {3}".format(game.game_id,
760 server_id, map_id, start_dt))
765 def get_or_create_player(session=None, hashkey=None, nick=None):
767 Finds a player by hashkey or creates a new one (along with a
768 corresponding hashkey entry. Parameters:
770 session - SQLAlchemy database session factory
771 hashkey - hashkey of the player to be found or created
772 nick - nick of the player (in case of a first time create)
775 if re.search('^bot#\d+', hashkey):
776 player = session.query(Player).filter_by(player_id=1).one()
777 # if we have an untracked player
778 elif re.search('^player#\d+$', hashkey):
779 player = session.query(Player).filter_by(player_id=2).one()
780 # else it is a tracked player
782 # see if the player is already in the database
783 # if not, create one and the hashkey along with it
785 hk = session.query(Hashkey).filter_by(
786 hashkey=hashkey).one()
787 player = session.query(Player).filter_by(
788 player_id=hk.player_id).one()
789 log.debug("Found existing player {0} with hashkey {1}".format(
790 player.player_id, hashkey))
796 # if nick is given to us, use it. If not, use "Anonymous Player"
797 # with a suffix added for uniqueness.
799 player.nick = nick[:128]
800 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
802 player.nick = "Anonymous Player #{0}".format(player.player_id)
803 player.stripped_nick = player.nick
805 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
807 log.debug("Created player {0} ({2}) with hashkey {1}".format(
808 player.player_id, hashkey, player.nick.encode('utf-8')))
813 def create_default_game_stat(session, game_type_cd):
814 """Creates a blanked-out pgstat record for the given game type"""
816 # this is what we have to do to get partitioned records in - grab the
817 # sequence value first, then insert using the explicit ID (vs autogenerate)
818 seq = Sequence('player_game_stats_player_game_stat_id_seq')
819 pgstat_id = session.execute(seq)
820 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
821 create_dt=datetime.datetime.utcnow())
823 if game_type_cd == 'as':
824 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
826 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
827 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
829 if game_type_cd == 'cq':
830 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
833 if game_type_cd == 'ctf':
834 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
835 pgstat.returns = pgstat.carrier_frags = 0
837 if game_type_cd == 'cts':
840 if game_type_cd == 'dom':
841 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
844 if game_type_cd == 'ft':
845 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
847 if game_type_cd == 'ka':
848 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
849 pgstat.carrier_frags = 0
850 pgstat.time = datetime.timedelta(seconds=0)
852 if game_type_cd == 'kh':
853 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
854 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
855 pgstat.carrier_frags = 0
857 if game_type_cd == 'lms':
858 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
860 if game_type_cd == 'nb':
861 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
864 if game_type_cd == 'rc':
865 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
870 def create_game_stat(session, game_meta, game, server, gmap, player, events):
871 """Game stats handler for all game types"""
873 game_type_cd = game.game_type_cd
875 pgstat = create_default_game_stat(session, game_type_cd)
877 # these fields should be on every pgstat record
878 pgstat.game_id = game.game_id
879 pgstat.player_id = player.player_id
880 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
881 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
882 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
883 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
884 pgstat.rank = int(events.get('rank', None))
885 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
887 if pgstat.nick != player.nick \
888 and player.player_id > 2 \
889 and pgstat.nick != 'Anonymous Player':
890 register_new_nick(session, player, pgstat.nick)
894 # gametype-specific stuff is handled here. if passed to us, we store it
895 for (key,value) in events.items():
896 if key == 'wins': wins = True
897 if key == 't': pgstat.team = int(value)
899 if key == 'scoreboard-drops': pgstat.drops = int(value)
900 if key == 'scoreboard-returns': pgstat.returns = int(value)
901 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
902 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
903 if key == 'scoreboard-caps': pgstat.captures = int(value)
904 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
905 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
906 if key == 'scoreboard-kills': pgstat.kills = int(value)
907 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
908 if key == 'scoreboard-objectives': pgstat.collects = int(value)
909 if key == 'scoreboard-captured': pgstat.captures = int(value)
910 if key == 'scoreboard-released': pgstat.drops = int(value)
911 if key == 'scoreboard-fastest':
912 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
913 if key == 'scoreboard-takes': pgstat.pickups = int(value)
914 if key == 'scoreboard-ticks': pgstat.drops = int(value)
915 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
916 if key == 'scoreboard-bctime':
917 pgstat.time = datetime.timedelta(seconds=int(value))
918 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
919 if key == 'scoreboard-losses': pgstat.drops = int(value)
920 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
921 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
922 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
923 if key == 'scoreboard-lives': pgstat.lives = int(value)
924 if key == 'scoreboard-goals': pgstat.captures = int(value)
925 if key == 'scoreboard-faults': pgstat.drops = int(value)
926 if key == 'scoreboard-laps': pgstat.laps = int(value)
928 if key == 'avglatency': pgstat.avg_latency = float(value)
929 if key == 'scoreboard-captime':
930 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
931 if game.game_type_cd == 'ctf':
932 update_fastest_cap(session, player.player_id, game.game_id,
933 gmap.map_id, pgstat.fastest, game.mod)
935 # there is no "winning team" field, so we have to derive it
936 if wins and pgstat.team is not None and game.winner is None:
937 game.winner = pgstat.team
945 def create_anticheats(session, pgstat, game, player, events):
946 """Anticheats handler for all game types"""
950 # all anticheat events are prefixed by "anticheat"
951 for (key,value) in events.items():
952 if key.startswith("anticheat"):
954 ac = PlayerGameAnticheat(
960 anticheats.append(ac)
962 except Exception as e:
963 log.debug("Could not parse value for key %s. Ignoring." % key)
968 def create_default_team_stat(session, game_type_cd):
969 """Creates a blanked-out teamstat record for the given game type"""
971 # this is what we have to do to get partitioned records in - grab the
972 # sequence value first, then insert using the explicit ID (vs autogenerate)
973 seq = Sequence('team_game_stats_team_game_stat_id_seq')
974 teamstat_id = session.execute(seq)
975 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
976 create_dt=datetime.datetime.utcnow())
978 # all team game modes have a score, so we'll zero that out always
981 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
984 if game_type_cd == 'ctf':
990 def create_team_stat(session, game, events):
991 """Team stats handler for all game types"""
994 teamstat = create_default_team_stat(session, game.game_type_cd)
995 teamstat.game_id = game.game_id
997 # we should have a team ID if we have a 'Q' event
998 if re.match(r'^team#\d+$', events.get('Q', '')):
999 team = int(events.get('Q').replace('team#', ''))
1000 teamstat.team = team
1002 # gametype-specific stuff is handled here. if passed to us, we store it
1003 for (key,value) in events.items():
1004 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
1005 if key == 'scoreboard-caps': teamstat.caps = int(value)
1006 if key == 'scoreboard-goals': teamstat.caps = int(value)
1007 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
1009 session.add(teamstat)
1010 except Exception as e:
1016 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
1017 """Weapon stats handler for all game types"""
1020 # Version 1 of stats submissions doubled the data sent.
1021 # To counteract this we divide the data by 2 only for
1022 # POSTs coming from version 1.
1024 version = int(game_meta['V'])
1027 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
1033 for (key,value) in events.items():
1034 matched = re.search("acc-(.*?)-cnt-fired", key)
1036 weapon_cd = matched.group(1)
1038 # Weapon names changed for 0.8. We'll convert the old
1039 # ones to use the new scheme as well.
1040 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
1042 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
1043 pwstat_id = session.execute(seq)
1044 pwstat = PlayerWeaponStat()
1045 pwstat.player_weapon_stats_id = pwstat_id
1046 pwstat.player_id = player.player_id
1047 pwstat.game_id = game.game_id
1048 pwstat.player_game_stat_id = pgstat.player_game_stat_id
1049 pwstat.weapon_cd = mapped_weapon_cd
1052 pwstat.nick = events['n']
1054 pwstat.nick = events['P']
1056 if 'acc-' + weapon_cd + '-cnt-fired' in events:
1057 pwstat.fired = int(round(float(
1058 events['acc-' + weapon_cd + '-cnt-fired'])))
1059 if 'acc-' + weapon_cd + '-fired' in events:
1060 pwstat.max = int(round(float(
1061 events['acc-' + weapon_cd + '-fired'])))
1062 if 'acc-' + weapon_cd + '-cnt-hit' in events:
1063 pwstat.hit = int(round(float(
1064 events['acc-' + weapon_cd + '-cnt-hit'])))
1065 if 'acc-' + weapon_cd + '-hit' in events:
1066 pwstat.actual = int(round(float(
1067 events['acc-' + weapon_cd + '-hit'])))
1068 if 'acc-' + weapon_cd + '-frags' in events:
1069 pwstat.frags = int(round(float(
1070 events['acc-' + weapon_cd + '-frags'])))
1073 pwstat.fired = pwstat.fired/2
1074 pwstat.max = pwstat.max/2
1075 pwstat.hit = pwstat.hit/2
1076 pwstat.actual = pwstat.actual/2
1077 pwstat.frags = pwstat.frags/2
1080 pwstats.append(pwstat)
1085 def get_ranks(session, player_ids, game_type_cd):
1087 Gets the rank entries for all players in the given list, returning a dict
1088 of player_id -> PlayerRank instance. The rank entry corresponds to the
1089 game type of the parameter passed in as well.
1092 for pr in session.query(PlayerRank).\
1093 filter(PlayerRank.player_id.in_(player_ids)).\
1094 filter(PlayerRank.game_type_cd == game_type_cd).\
1096 ranks[pr.player_id] = pr
1101 def submit_stats(request):
1103 Entry handler for POST stats submissions.
1106 # placeholder for the actual session
1109 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1110 "----- END REQUEST BODY -----\n\n")
1112 (idfp, status) = verify_request(request)
1113 (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
1114 revision = game_meta.get('R', 'unknown')
1115 duration = game_meta.get('D', None)
1117 # only players present at the end of the match are eligible for stats
1118 raw_players = filter(played_in_game, raw_players)
1120 do_precondition_checks(request, game_meta, raw_players)
1122 # the "duel" gametype is fake
1123 if len(raw_players) == 2 \
1124 and num_real_players(raw_players) == 2 \
1125 and game_meta['G'] == 'dm':
1126 game_meta['G'] = 'duel'
1128 #----------------------------------------------------------------------
1129 # Actual setup (inserts/updates) below here
1130 #----------------------------------------------------------------------
1131 session = DBSession()
1133 game_type_cd = game_meta['G']
1135 # All game types create Game, Server, Map, and Player records
1137 server = get_or_create_server(
1140 name = game_meta['S'],
1141 revision = revision,
1142 ip_addr = get_remote_addr(request),
1143 port = game_meta.get('U', None),
1144 impure_cvars = game_meta.get('C', 0))
1146 gmap = get_or_create_map(
1148 name = game_meta['M'])
1152 start_dt = datetime.datetime.utcnow(),
1153 server_id = server.server_id,
1154 game_type_cd = game_type_cd,
1155 map_id = gmap.map_id,
1156 match_id = game_meta['I'],
1157 duration = duration,
1158 mod = game_meta.get('O', None))
1160 # keep track of the players we've seen
1164 for events in raw_players:
1165 player = get_or_create_player(
1167 hashkey = events['P'],
1168 nick = events.get('n', None))
1170 pgstat = create_game_stat(session, game_meta, game, server,
1171 gmap, player, events)
1172 pgstats.append(pgstat)
1174 if player.player_id > 1:
1175 anticheats = create_anticheats(session, pgstat, game, player, events)
1177 if player.player_id > 2:
1178 player_ids.append(player.player_id)
1179 hashkeys[player.player_id] = events['P']
1181 if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
1182 pwstats = create_weapon_stats(session, game_meta, game, player,
1185 # store them on games for easy access
1186 game.players = player_ids
1188 for events in raw_teams:
1190 teamstat = create_team_stat(session, game, events)
1191 except Exception as e:
1194 if server.elo_ind and gametype_elo_eligible(game_type_cd):
1195 ep = EloProcessor(session, game, pgstats)
1199 log.debug('Success! Stats recorded.')
1201 # ranks are fetched after we've done the "real" processing
1202 ranks = get_ranks(session, player_ids, game_type_cd)
1204 # plain text response
1205 request.response.content_type = 'text/plain'
1208 "now" : calendar.timegm(datetime.datetime.utcnow().timetuple()),
1212 "player_ids" : player_ids,
1213 "hashkeys" : hashkeys,
1218 except Exception as e: