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 if not events['P'].startswith('bot'):
29 def played_in_game(events):
31 Determines if a given set of player events correspond with a player who
32 played in the game (matches 1 and scoreboardvalid 1)
34 if 'matches' in events and 'scoreboardvalid' in events:
40 class Submission(object):
41 """Parses an incoming POST request for stats submissions."""
43 def __init__(self, body, headers):
44 # a copy of the HTTP headers
45 self.headers = headers
47 # a copy of the HTTP POST body
53 # humans and bots in the match (including spectators)
56 # humans who played in the match
59 # bots who played in the match
65 # distinct weapons that we have seen fired
68 # has a human player fired a shot?
69 self.human_fired_weapon = False
71 # the parsing deque (we use this to allow peeking)
72 self.q = collections.deque(self.body.split("\n"))
75 """Returns the next key:value pair off the queue."""
77 items = self.q.popleft().strip().split(' ', 1)
79 # Some keys won't have values, like 'L' records where the server isn't actually
80 # participating in any ladders. These can be safely ignored.
87 def check_for_new_weapon_fired(self, sub_key):
88 """Checks if a given weapon fired event is a new one for the match."""
89 weapon = sub_key.split("-")[1]
90 if weapon not in self.weapons:
91 self.weapons.add(weapon)
93 def parse_player(self, key, pid):
94 """Construct a player events listing from the submission."""
96 # all of the keys related to player records
97 player_keys = ['i', 'n', 't', 'e']
101 player_fired_weapon = False
103 # Consume all following 'i' 'n' 't' 'e' records
104 while len(self.q) > 0:
105 (key, value) = self.next_item()
106 if key is None and value is None:
109 (sub_key, sub_value) = value.split(' ', 1)
110 player[sub_key] = sub_value
112 if sub_key.endswith("cnt-fired"):
113 player_fired_weapon = True
114 self.check_for_new_weapon_fired(sub_key)
116 player[key] = unicode(value, 'utf-8')
117 elif key in player_keys:
120 # something we didn't expect - put it back on the deque
121 self.q.appendleft("{} {}".format(key, value))
124 played = played_in_game(player)
125 human = is_real_player(player)
128 self.humans.append(player)
130 if player_fired_weapon:
131 self.human_fired_weapon = True
132 elif played and not human:
133 self.bots.append(player)
135 self.players.append(player)
137 def parse_team(self, key, tid):
138 """Construct a team events listing from the submission."""
141 # Consume all following 'e' records
142 while len(self.q) > 0 and self.q[0].startswith('e'):
143 (_, value) = self.next_item()
144 (sub_key, sub_value) = value.split(' ', 1)
145 team[sub_key] = sub_value
147 self.teams.append(team)
150 """Parses the request body into instance variables."""
151 while len(self.q) > 0:
152 (key, value) = self.next_item()
153 if key is None and value is None:
156 self.meta[key] = unicode(value, 'utf-8')
158 self.parse_player(key, value)
160 self.parse_team(key, value)
162 self.meta[key] = value
167 def elo_submission_category(submission):
168 """Determines the Elo category purely by what is in the submission data."""
169 mod = submission.meta.get("O", "None")
171 vanilla_allowed_weapons = {"shotgun", "devastator", "blaster", "mortar", "vortex", "electro",
172 "arc", "hagar", "crylink", "machinegun"}
173 insta_allowed_weapons = {"vaporizer", "blaster"}
174 overkill_allowed_weapons = {"hmg", "vortex", "shotgun", "blaster", "machinegun", "rpc"}
177 if len(submission.weapons - vanilla_allowed_weapons) == 0:
179 elif mod == "InstaGib":
180 if len(submission.weapons - insta_allowed_weapons) == 0:
182 elif mod == "Overkill":
183 if len(submission.weapons - overkill_allowed_weapons) == 0:
191 def parse_stats_submission(body):
193 Parses the POST request body for a stats submission
195 # storage vars for the request body
201 # we're not in either stanza to start
204 for line in body.split('\n'):
206 (key, value) = line.strip().split(' ', 1)
208 # Server (S) and Nick (n) fields can have international characters.
210 value = unicode(value, 'utf-8')
212 if key not in 'P' 'Q' 'n' 'e' 't' 'i':
213 game_meta[key] = value
215 if key == 'Q' or key == 'P':
216 #log.debug('Found a {0}'.format(key))
217 #log.debug('in_Q: {0}'.format(in_Q))
218 #log.debug('in_P: {0}'.format(in_P))
219 #log.debug('events: {0}'.format(events))
221 # check where we were before and append events accordingly
222 if in_Q and len(events) > 0:
223 #log.debug('creating a team (Q) entry')
226 elif in_P and len(events) > 0:
227 #log.debug('creating a player (P) entry')
228 players.append(events)
232 #log.debug('key == P')
236 #log.debug('key == Q')
243 (subkey, subvalue) = value.split(' ', 1)
244 events[subkey] = subvalue
250 # no key/value pair - move on to the next line
253 # add the last entity we were working on
254 if in_P and len(events) > 0:
255 players.append(events)
256 elif in_Q and len(events) > 0:
259 return (game_meta, players, teams)
262 def is_blank_game(gametype, players):
263 """Determine if this is a blank game or not. A blank game is either:
265 1) a match that ended in the warmup stage, where accuracy events are not
266 present (for non-CTS games)
268 2) a match in which no player made a positive or negative score AND was
271 ... or for CTS, which doesn't record accuracy events
273 1) a match in which no player made a fastest lap AND was
276 ... or for NB, in which not all maps have weapons
278 1) a match in which no player made a positive or negative score
280 r = re.compile(r'acc-.*-cnt-fired')
281 flg_nonzero_score = False
282 flg_acc_events = False
283 flg_fastest_lap = False
285 for events in players:
286 if is_real_player(events) and played_in_game(events):
287 for (key,value) in events.items():
288 if key == 'scoreboard-score' and value != 0:
289 flg_nonzero_score = True
291 flg_acc_events = True
292 if key == 'scoreboard-fastest':
293 flg_fastest_lap = True
295 if gametype == 'cts':
296 return not flg_fastest_lap
297 elif gametype == 'nb':
298 return not flg_nonzero_score
300 return not (flg_nonzero_score and flg_acc_events)
303 def get_remote_addr(request):
304 """Get the Xonotic server's IP address"""
305 if 'X-Forwarded-For' in request.headers:
306 return request.headers['X-Forwarded-For']
308 return request.remote_addr
311 def is_supported_gametype(gametype, version):
312 """Whether a gametype is supported or not"""
315 # if the type can be supported, but with version constraints, uncomment
316 # here and add the restriction for a specific version below
317 supported_game_types = (
336 if gametype in supported_game_types:
341 # some game types were buggy before revisions, thus this additional filter
342 if gametype == 'ca' and version <= 5:
348 def do_precondition_checks(request, game_meta, raw_players):
349 """Precondition checks for ALL gametypes.
350 These do not require a database connection."""
351 if not has_required_metadata(game_meta):
352 msg = "Missing required game metadata"
354 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
356 content_type="text/plain"
360 version = int(game_meta['V'])
362 msg = "Invalid or incorrect game metadata provided"
364 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
366 content_type="text/plain"
369 if not is_supported_gametype(game_meta['G'], version):
370 msg = "Unsupported game type ({})".format(game_meta['G'])
372 raise pyramid.httpexceptions.HTTPOk(
374 content_type="text/plain"
377 if not has_minimum_real_players(request.registry.settings, raw_players):
378 msg = "Not enough real players"
380 raise pyramid.httpexceptions.HTTPOk(
382 content_type="text/plain"
385 if is_blank_game(game_meta['G'], raw_players):
388 raise pyramid.httpexceptions.HTTPOk(
390 content_type="text/plain"
394 def num_real_players(player_events):
396 Returns the number of real players (those who played
397 and are on the scoreboard).
401 for events in player_events:
402 if is_real_player(events) and played_in_game(events):
408 def has_minimum_real_players(settings, player_events):
410 Determines if the collection of player events has enough "real" players
411 to store in the database. The minimum setting comes from the config file
412 under the setting xonstat.minimum_real_players.
414 flg_has_min_real_players = True
417 minimum_required_players = int(
418 settings['xonstat.minimum_required_players'])
420 minimum_required_players = 2
422 real_players = num_real_players(player_events)
424 if real_players < minimum_required_players:
425 flg_has_min_real_players = False
427 return flg_has_min_real_players
430 def has_required_metadata(metadata):
432 Determines if a give set of metadata has enough data to create a game,
433 server, and map with.
435 flg_has_req_metadata = True
437 if 'G' not in metadata or\
438 'M' not in metadata or\
439 'I' not in metadata or\
441 flg_has_req_metadata = False
443 return flg_has_req_metadata
446 def should_do_weapon_stats(game_type_cd):
447 """True of the game type should record weapon stats. False otherwise."""
448 if game_type_cd in 'cts':
454 def gametype_elo_eligible(game_type_cd):
455 """True of the game type should process Elos. False otherwise."""
456 elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft')
458 if game_type_cd in elo_game_types:
464 def register_new_nick(session, player, new_nick):
466 Change the player record's nick to the newly found nick. Store the old
467 nick in the player_nicks table for that player.
469 session - SQLAlchemy database session factory
470 player - player record whose nick is changing
471 new_nick - the new nickname
473 # see if that nick already exists
474 stripped_nick = strip_colors(qfont_decode(player.nick))
476 player_nick = session.query(PlayerNick).filter_by(
477 player_id=player.player_id, stripped_nick=stripped_nick).one()
478 except NoResultFound, e:
479 # player_id/stripped_nick not found, create one
480 # but we don't store "Anonymous Player #N"
481 if not re.search('^Anonymous Player #\d+$', player.nick):
482 player_nick = PlayerNick()
483 player_nick.player_id = player.player_id
484 player_nick.stripped_nick = stripped_nick
485 player_nick.nick = player.nick
486 session.add(player_nick)
488 # We change to the new nick regardless
489 player.nick = new_nick
490 player.stripped_nick = strip_colors(qfont_decode(new_nick))
494 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
496 Check the fastest cap time for the player and map. If there isn't
497 one, insert one. If there is, check if the passed time is faster.
500 # we don't record fastest cap times for bots or anonymous players
504 # see if a cap entry exists already
505 # then check to see if the new captime is faster
507 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
508 player_id=player_id, map_id=map_id, mod=mod).one()
510 # current captime is faster, so update
511 if captime < cur_fastest_cap.fastest_cap:
512 cur_fastest_cap.fastest_cap = captime
513 cur_fastest_cap.game_id = game_id
514 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
515 session.add(cur_fastest_cap)
517 except NoResultFound, e:
518 # none exists, so insert
519 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
521 session.add(cur_fastest_cap)
525 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
527 Updates the server in the given DB session, if needed.
529 :param server: The found server instance.
530 :param name: The incoming server name.
531 :param hashkey: The incoming server hashkey.
532 :param ip_addr: The incoming server IP address.
533 :param port: The incoming server port.
534 :param revision: The incoming server revision.
535 :param impure_cvars: The incoming number of impure server cvars.
538 # ensure the two int attributes are actually ints
545 impure_cvars = int(impure_cvars)
550 if name and server.name != name:
553 if hashkey and server.hashkey != hashkey:
554 server.hashkey = hashkey
556 if ip_addr and server.ip_addr != ip_addr:
557 server.ip_addr = ip_addr
559 if port and server.port != port:
562 if revision and server.revision != revision:
563 server.revision = revision
565 if impure_cvars and server.impure_cvars != impure_cvars:
566 server.impure_cvars = impure_cvars
567 server.pure_ind = True if impure_cvars == 0 else False
573 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
575 Find a server by name or create one if not found. Parameters:
577 session - SQLAlchemy database session factory
578 name - server name of the server to be found or created
579 hashkey - server hashkey
580 ip_addr - the IP address of the server
581 revision - the xonotic revision number
582 port - the port number of the server
583 impure_cvars - the number of impure cvar changes
585 servers_q = DBSession.query(Server).filter(Server.active_ind)
588 # if the hashkey is provided, we'll use that
589 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
591 # otherwise, it is just by name
592 servers_q = servers_q.filter(Server.name == name)
594 # order by the hashkey, which means any hashkey match will appear first if there are multiple
595 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
597 if len(servers) == 0:
598 server = Server(name=name, hashkey=hashkey)
601 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
604 if len(servers) == 1:
605 log.info("Found existing server {}.".format(server.server_id))
607 elif len(servers) > 1:
608 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
609 log.warn("Multiple servers found ({})! Using the first one ({})."
610 .format(server_id_list, server.server_id))
612 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
618 def get_or_create_map(session=None, name=None):
620 Find a map by name or create one if not found. Parameters:
622 session - SQLAlchemy database session factory
623 name - map name of the map to be found or created
626 # find one by the name, if it exists
627 gmap = session.query(Map).filter_by(name=name).one()
628 log.debug("Found map id {0}: {1}".format(gmap.map_id,
630 except NoResultFound, e:
631 gmap = Map(name=name)
634 log.debug("Created map id {0}: {1}".format(gmap.map_id,
636 except MultipleResultsFound, e:
637 # multiple found, so use the first one but warn
639 gmaps = session.query(Map).filter_by(name=name).order_by(
642 log.debug("Found map id {0}: {1} but found \
643 multiple".format(gmap.map_id, gmap.name))
648 def create_game(session, start_dt, game_type_cd, server_id, map_id,
649 match_id, duration, mod, winner=None):
651 Creates a game. Parameters:
653 session - SQLAlchemy database session factory
654 start_dt - when the game started (datetime object)
655 game_type_cd - the game type of the game being played
656 server_id - server identifier of the server hosting the game
657 map_id - map on which the game was played
658 winner - the team id of the team that won
659 duration - how long the game lasted
660 mod - mods in use during the game
662 seq = Sequence('games_game_id_seq')
663 game_id = session.execute(seq)
664 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
665 server_id=server_id, map_id=map_id, winner=winner)
666 game.match_id = match_id
669 # There is some drift between start_dt (provided by app) and create_dt
670 # (default in the database), so we'll make them the same until this is
672 game.create_dt = start_dt
675 game.duration = datetime.timedelta(seconds=int(round(float(duration))))
680 session.query(Game).filter(Game.server_id==server_id).\
681 filter(Game.match_id==match_id).one()
683 log.debug("Error: game with same server and match_id found! Ignoring.")
685 # if a game under the same server and match_id found,
686 # this is a duplicate game and can be ignored
687 raise pyramid.httpexceptions.HTTPOk('OK')
688 except NoResultFound, e:
689 # server_id/match_id combination not found. game is ok to insert
692 log.debug("Created game id {0} on server {1}, map {2} at \
693 {3}".format(game.game_id,
694 server_id, map_id, start_dt))
699 def get_or_create_player(session=None, hashkey=None, nick=None):
701 Finds a player by hashkey or creates a new one (along with a
702 corresponding hashkey entry. Parameters:
704 session - SQLAlchemy database session factory
705 hashkey - hashkey of the player to be found or created
706 nick - nick of the player (in case of a first time create)
709 if re.search('^bot#\d+', hashkey):
710 player = session.query(Player).filter_by(player_id=1).one()
711 # if we have an untracked player
712 elif re.search('^player#\d+$', hashkey):
713 player = session.query(Player).filter_by(player_id=2).one()
714 # else it is a tracked player
716 # see if the player is already in the database
717 # if not, create one and the hashkey along with it
719 hk = session.query(Hashkey).filter_by(
720 hashkey=hashkey).one()
721 player = session.query(Player).filter_by(
722 player_id=hk.player_id).one()
723 log.debug("Found existing player {0} with hashkey {1}".format(
724 player.player_id, hashkey))
730 # if nick is given to us, use it. If not, use "Anonymous Player"
731 # with a suffix added for uniqueness.
733 player.nick = nick[:128]
734 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
736 player.nick = "Anonymous Player #{0}".format(player.player_id)
737 player.stripped_nick = player.nick
739 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
741 log.debug("Created player {0} ({2}) with hashkey {1}".format(
742 player.player_id, hashkey, player.nick.encode('utf-8')))
747 def create_default_game_stat(session, game_type_cd):
748 """Creates a blanked-out pgstat record for the given game type"""
750 # this is what we have to do to get partitioned records in - grab the
751 # sequence value first, then insert using the explicit ID (vs autogenerate)
752 seq = Sequence('player_game_stats_player_game_stat_id_seq')
753 pgstat_id = session.execute(seq)
754 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
755 create_dt=datetime.datetime.utcnow())
757 if game_type_cd == 'as':
758 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
760 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
761 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
763 if game_type_cd == 'cq':
764 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
767 if game_type_cd == 'ctf':
768 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
769 pgstat.returns = pgstat.carrier_frags = 0
771 if game_type_cd == 'cts':
774 if game_type_cd == 'dom':
775 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
778 if game_type_cd == 'ft':
779 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
781 if game_type_cd == 'ka':
782 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
783 pgstat.carrier_frags = 0
784 pgstat.time = datetime.timedelta(seconds=0)
786 if game_type_cd == 'kh':
787 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
788 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
789 pgstat.carrier_frags = 0
791 if game_type_cd == 'lms':
792 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
794 if game_type_cd == 'nb':
795 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
798 if game_type_cd == 'rc':
799 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
804 def create_game_stat(session, game_meta, game, server, gmap, player, events):
805 """Game stats handler for all game types"""
807 game_type_cd = game.game_type_cd
809 pgstat = create_default_game_stat(session, game_type_cd)
811 # these fields should be on every pgstat record
812 pgstat.game_id = game.game_id
813 pgstat.player_id = player.player_id
814 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
815 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
816 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
817 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
818 pgstat.rank = int(events.get('rank', None))
819 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
821 if pgstat.nick != player.nick \
822 and player.player_id > 2 \
823 and pgstat.nick != 'Anonymous Player':
824 register_new_nick(session, player, pgstat.nick)
828 # gametype-specific stuff is handled here. if passed to us, we store it
829 for (key,value) in events.items():
830 if key == 'wins': wins = True
831 if key == 't': pgstat.team = int(value)
833 if key == 'scoreboard-drops': pgstat.drops = int(value)
834 if key == 'scoreboard-returns': pgstat.returns = int(value)
835 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
836 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
837 if key == 'scoreboard-caps': pgstat.captures = int(value)
838 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
839 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
840 if key == 'scoreboard-kills': pgstat.kills = int(value)
841 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
842 if key == 'scoreboard-objectives': pgstat.collects = int(value)
843 if key == 'scoreboard-captured': pgstat.captures = int(value)
844 if key == 'scoreboard-released': pgstat.drops = int(value)
845 if key == 'scoreboard-fastest':
846 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
847 if key == 'scoreboard-takes': pgstat.pickups = int(value)
848 if key == 'scoreboard-ticks': pgstat.drops = int(value)
849 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
850 if key == 'scoreboard-bctime':
851 pgstat.time = datetime.timedelta(seconds=int(value))
852 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
853 if key == 'scoreboard-losses': pgstat.drops = int(value)
854 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
855 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
856 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
857 if key == 'scoreboard-lives': pgstat.lives = int(value)
858 if key == 'scoreboard-goals': pgstat.captures = int(value)
859 if key == 'scoreboard-faults': pgstat.drops = int(value)
860 if key == 'scoreboard-laps': pgstat.laps = int(value)
862 if key == 'avglatency': pgstat.avg_latency = float(value)
863 if key == 'scoreboard-captime':
864 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
865 if game.game_type_cd == 'ctf':
866 update_fastest_cap(session, player.player_id, game.game_id,
867 gmap.map_id, pgstat.fastest, game.mod)
869 # there is no "winning team" field, so we have to derive it
870 if wins and pgstat.team is not None and game.winner is None:
871 game.winner = pgstat.team
879 def create_anticheats(session, pgstat, game, player, events):
880 """Anticheats handler for all game types"""
884 # all anticheat events are prefixed by "anticheat"
885 for (key,value) in events.items():
886 if key.startswith("anticheat"):
888 ac = PlayerGameAnticheat(
894 anticheats.append(ac)
896 except Exception as e:
897 log.debug("Could not parse value for key %s. Ignoring." % key)
902 def create_default_team_stat(session, game_type_cd):
903 """Creates a blanked-out teamstat record for the given game type"""
905 # this is what we have to do to get partitioned records in - grab the
906 # sequence value first, then insert using the explicit ID (vs autogenerate)
907 seq = Sequence('team_game_stats_team_game_stat_id_seq')
908 teamstat_id = session.execute(seq)
909 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
910 create_dt=datetime.datetime.utcnow())
912 # all team game modes have a score, so we'll zero that out always
915 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
918 if game_type_cd == 'ctf':
924 def create_team_stat(session, game, events):
925 """Team stats handler for all game types"""
928 teamstat = create_default_team_stat(session, game.game_type_cd)
929 teamstat.game_id = game.game_id
931 # we should have a team ID if we have a 'Q' event
932 if re.match(r'^team#\d+$', events.get('Q', '')):
933 team = int(events.get('Q').replace('team#', ''))
936 # gametype-specific stuff is handled here. if passed to us, we store it
937 for (key,value) in events.items():
938 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
939 if key == 'scoreboard-caps': teamstat.caps = int(value)
940 if key == 'scoreboard-goals': teamstat.caps = int(value)
941 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
943 session.add(teamstat)
944 except Exception as e:
950 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
951 """Weapon stats handler for all game types"""
954 # Version 1 of stats submissions doubled the data sent.
955 # To counteract this we divide the data by 2 only for
956 # POSTs coming from version 1.
958 version = int(game_meta['V'])
961 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
967 for (key,value) in events.items():
968 matched = re.search("acc-(.*?)-cnt-fired", key)
970 weapon_cd = matched.group(1)
972 # Weapon names changed for 0.8. We'll convert the old
973 # ones to use the new scheme as well.
974 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
976 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
977 pwstat_id = session.execute(seq)
978 pwstat = PlayerWeaponStat()
979 pwstat.player_weapon_stats_id = pwstat_id
980 pwstat.player_id = player.player_id
981 pwstat.game_id = game.game_id
982 pwstat.player_game_stat_id = pgstat.player_game_stat_id
983 pwstat.weapon_cd = mapped_weapon_cd
986 pwstat.nick = events['n']
988 pwstat.nick = events['P']
990 if 'acc-' + weapon_cd + '-cnt-fired' in events:
991 pwstat.fired = int(round(float(
992 events['acc-' + weapon_cd + '-cnt-fired'])))
993 if 'acc-' + weapon_cd + '-fired' in events:
994 pwstat.max = int(round(float(
995 events['acc-' + weapon_cd + '-fired'])))
996 if 'acc-' + weapon_cd + '-cnt-hit' in events:
997 pwstat.hit = int(round(float(
998 events['acc-' + weapon_cd + '-cnt-hit'])))
999 if 'acc-' + weapon_cd + '-hit' in events:
1000 pwstat.actual = int(round(float(
1001 events['acc-' + weapon_cd + '-hit'])))
1002 if 'acc-' + weapon_cd + '-frags' in events:
1003 pwstat.frags = int(round(float(
1004 events['acc-' + weapon_cd + '-frags'])))
1007 pwstat.fired = pwstat.fired/2
1008 pwstat.max = pwstat.max/2
1009 pwstat.hit = pwstat.hit/2
1010 pwstat.actual = pwstat.actual/2
1011 pwstat.frags = pwstat.frags/2
1014 pwstats.append(pwstat)
1019 def get_ranks(session, player_ids, game_type_cd):
1021 Gets the rank entries for all players in the given list, returning a dict
1022 of player_id -> PlayerRank instance. The rank entry corresponds to the
1023 game type of the parameter passed in as well.
1026 for pr in session.query(PlayerRank).\
1027 filter(PlayerRank.player_id.in_(player_ids)).\
1028 filter(PlayerRank.game_type_cd == game_type_cd).\
1030 ranks[pr.player_id] = pr
1035 def submit_stats(request):
1037 Entry handler for POST stats submissions.
1040 # placeholder for the actual session
1043 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1044 "----- END REQUEST BODY -----\n\n")
1046 (idfp, status) = verify_request(request)
1047 (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
1048 revision = game_meta.get('R', 'unknown')
1049 duration = game_meta.get('D', None)
1051 # only players present at the end of the match are eligible for stats
1052 raw_players = filter(played_in_game, raw_players)
1054 do_precondition_checks(request, game_meta, raw_players)
1056 # the "duel" gametype is fake
1057 if len(raw_players) == 2 \
1058 and num_real_players(raw_players) == 2 \
1059 and game_meta['G'] == 'dm':
1060 game_meta['G'] = 'duel'
1062 #----------------------------------------------------------------------
1063 # Actual setup (inserts/updates) below here
1064 #----------------------------------------------------------------------
1065 session = DBSession()
1067 game_type_cd = game_meta['G']
1069 # All game types create Game, Server, Map, and Player records
1071 server = get_or_create_server(
1074 name = game_meta['S'],
1075 revision = revision,
1076 ip_addr = get_remote_addr(request),
1077 port = game_meta.get('U', None),
1078 impure_cvars = game_meta.get('C', 0))
1080 gmap = get_or_create_map(
1082 name = game_meta['M'])
1086 start_dt = datetime.datetime.utcnow(),
1087 server_id = server.server_id,
1088 game_type_cd = game_type_cd,
1089 map_id = gmap.map_id,
1090 match_id = game_meta['I'],
1091 duration = duration,
1092 mod = game_meta.get('O', None))
1094 # keep track of the players we've seen
1098 for events in raw_players:
1099 player = get_or_create_player(
1101 hashkey = events['P'],
1102 nick = events.get('n', None))
1104 pgstat = create_game_stat(session, game_meta, game, server,
1105 gmap, player, events)
1106 pgstats.append(pgstat)
1108 if player.player_id > 1:
1109 anticheats = create_anticheats(session, pgstat, game, player, events)
1111 if player.player_id > 2:
1112 player_ids.append(player.player_id)
1113 hashkeys[player.player_id] = events['P']
1115 if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
1116 pwstats = create_weapon_stats(session, game_meta, game, player,
1119 # store them on games for easy access
1120 game.players = player_ids
1122 for events in raw_teams:
1124 teamstat = create_team_stat(session, game, events)
1125 except Exception as e:
1128 if server.elo_ind and gametype_elo_eligible(game_type_cd):
1129 ep = EloProcessor(session, game, pgstats)
1133 log.debug('Success! Stats recorded.')
1135 # ranks are fetched after we've done the "real" processing
1136 ranks = get_ranks(session, player_ids, game_type_cd)
1138 # plain text response
1139 request.response.content_type = 'text/plain'
1142 "now" : calendar.timegm(datetime.datetime.utcnow().timetuple()),
1146 "player_ids" : player_ids,
1147 "hashkeys" : hashkeys,
1152 except Exception as e: