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
50 # the submission code version (from the server)
53 # the revision string of the server
56 # the game type played
57 self.game_type_cd = None
62 # the name of the map played
65 # unique identifier (string) for a match on a given server
68 # the name of the server
69 self.server_name = None
71 # the number of cvars that were changed to be different than default
72 self.impure_cvar_changes = None
74 # the port number the game server is listening on
75 self.port_number = None
77 # how long the game lasted
80 # which ladder is being used, if any
83 # players involved in the match (humans, bots, and spectators)
89 # the parsing deque (we use this to allow peeking)
90 self.q = collections.deque(self.body.split("\n"))
92 ############################################################################################
93 # Below this point are fields useful in determining if the submission is valid or
94 # performance optimizations that save us from looping over the events over and over again.
95 ############################################################################################
97 # humans who played in the match
100 # bots who played in the match
103 # distinct weapons that we have seen fired
106 # has a human player fired a shot?
107 self.human_fired_weapon = 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 check_for_new_weapon_fired(self, sub_key):
123 """Checks if a given weapon fired event is a new one for the match."""
124 weapon = sub_key.split("-")[1]
125 if weapon not in self.weapons:
126 self.weapons.add(weapon)
128 def parse_player(self, key, pid):
129 """Construct a player events listing from the submission."""
131 # all of the keys related to player records
132 player_keys = ['i', 'n', 't', 'e']
136 player_fired_weapon = 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.check_for_new_weapon_fired(sub_key)
151 player[key] = unicode(value, 'utf-8')
152 elif key in player_keys:
155 # something we didn't expect - put it back on the deque
156 self.q.appendleft("{} {}".format(key, value))
159 played = played_in_game(player)
160 human = is_real_player(player)
163 self.humans.append(player)
165 if player_fired_weapon:
166 self.human_fired_weapon = True
167 elif played and not human:
168 self.bots.append(player)
170 self.players.append(player)
172 def parse_team(self, key, tid):
173 """Construct a team events listing from the submission."""
176 # Consume all following 'e' records
177 while len(self.q) > 0 and self.q[0].startswith('e'):
178 (_, value) = self.next_item()
179 (sub_key, sub_value) = value.split(' ', 1)
180 team[sub_key] = sub_value
182 self.teams.append(team)
185 """Parses the request body into instance variables."""
186 while len(self.q) > 0:
187 (key, value) = self.next_item()
188 if key is None and value is None:
193 self.revision = value
195 self.game_type_cd = value
199 self.map_name = value
201 self.match_id = value
203 self.server_name = unicode(value, 'utf-8')
205 self.impure_cvar_changes = int(value)
207 self.port_number = int(value)
209 self.duration = datetime.timedelta(seconds=int(round(float(value))))
213 self.parse_team(key, value)
215 self.parse_player(key, value)
217 raise Exception("Invalid submission")
222 def elo_submission_category(submission):
223 """Determines the Elo category purely by what is in the submission data."""
224 mod = submission.meta.get("O", "None")
226 vanilla_allowed_weapons = {"shotgun", "devastator", "blaster", "mortar", "vortex", "electro",
227 "arc", "hagar", "crylink", "machinegun"}
228 insta_allowed_weapons = {"vaporizer", "blaster"}
229 overkill_allowed_weapons = {"hmg", "vortex", "shotgun", "blaster", "machinegun", "rpc"}
232 if len(submission.weapons - vanilla_allowed_weapons) == 0:
234 elif mod == "InstaGib":
235 if len(submission.weapons - insta_allowed_weapons) == 0:
237 elif mod == "Overkill":
238 if len(submission.weapons - overkill_allowed_weapons) == 0:
246 def parse_stats_submission(body):
248 Parses the POST request body for a stats submission
250 # storage vars for the request body
256 # we're not in either stanza to start
259 for line in body.split('\n'):
261 (key, value) = line.strip().split(' ', 1)
263 # Server (S) and Nick (n) fields can have international characters.
265 value = unicode(value, 'utf-8')
267 if key not in 'P' 'Q' 'n' 'e' 't' 'i':
268 game_meta[key] = value
270 if key == 'Q' or key == 'P':
271 #log.debug('Found a {0}'.format(key))
272 #log.debug('in_Q: {0}'.format(in_Q))
273 #log.debug('in_P: {0}'.format(in_P))
274 #log.debug('events: {0}'.format(events))
276 # check where we were before and append events accordingly
277 if in_Q and len(events) > 0:
278 #log.debug('creating a team (Q) entry')
281 elif in_P and len(events) > 0:
282 #log.debug('creating a player (P) entry')
283 players.append(events)
287 #log.debug('key == P')
291 #log.debug('key == Q')
298 (subkey, subvalue) = value.split(' ', 1)
299 events[subkey] = subvalue
305 # no key/value pair - move on to the next line
308 # add the last entity we were working on
309 if in_P and len(events) > 0:
310 players.append(events)
311 elif in_Q and len(events) > 0:
314 return (game_meta, players, teams)
317 def is_blank_game(gametype, players):
318 """Determine if this is a blank game or not. A blank game is either:
320 1) a match that ended in the warmup stage, where accuracy events are not
321 present (for non-CTS games)
323 2) a match in which no player made a positive or negative score AND was
326 ... or for CTS, which doesn't record accuracy events
328 1) a match in which no player made a fastest lap AND was
331 ... or for NB, in which not all maps have weapons
333 1) a match in which no player made a positive or negative score
335 r = re.compile(r'acc-.*-cnt-fired')
336 flg_nonzero_score = False
337 flg_acc_events = False
338 flg_fastest_lap = False
340 for events in players:
341 if is_real_player(events) and played_in_game(events):
342 for (key,value) in events.items():
343 if key == 'scoreboard-score' and value != 0:
344 flg_nonzero_score = True
346 flg_acc_events = True
347 if key == 'scoreboard-fastest':
348 flg_fastest_lap = True
350 if gametype == 'cts':
351 return not flg_fastest_lap
352 elif gametype == 'nb':
353 return not flg_nonzero_score
355 return not (flg_nonzero_score and flg_acc_events)
358 def get_remote_addr(request):
359 """Get the Xonotic server's IP address"""
360 if 'X-Forwarded-For' in request.headers:
361 return request.headers['X-Forwarded-For']
363 return request.remote_addr
366 def is_supported_gametype(gametype, version):
367 """Whether a gametype is supported or not"""
370 # if the type can be supported, but with version constraints, uncomment
371 # here and add the restriction for a specific version below
372 supported_game_types = (
391 if gametype in supported_game_types:
396 # some game types were buggy before revisions, thus this additional filter
397 if gametype == 'ca' and version <= 5:
403 def do_precondition_checks(request, game_meta, raw_players):
404 """Precondition checks for ALL gametypes.
405 These do not require a database connection."""
406 if not has_required_metadata(game_meta):
407 msg = "Missing required game metadata"
409 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
411 content_type="text/plain"
415 version = int(game_meta['V'])
417 msg = "Invalid or incorrect game metadata provided"
419 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
421 content_type="text/plain"
424 if not is_supported_gametype(game_meta['G'], version):
425 msg = "Unsupported game type ({})".format(game_meta['G'])
427 raise pyramid.httpexceptions.HTTPOk(
429 content_type="text/plain"
432 if not has_minimum_real_players(request.registry.settings, raw_players):
433 msg = "Not enough real players"
435 raise pyramid.httpexceptions.HTTPOk(
437 content_type="text/plain"
440 if is_blank_game(game_meta['G'], raw_players):
443 raise pyramid.httpexceptions.HTTPOk(
445 content_type="text/plain"
449 def num_real_players(player_events):
451 Returns the number of real players (those who played
452 and are on the scoreboard).
456 for events in player_events:
457 if is_real_player(events) and played_in_game(events):
463 def has_minimum_real_players(settings, player_events):
465 Determines if the collection of player events has enough "real" players
466 to store in the database. The minimum setting comes from the config file
467 under the setting xonstat.minimum_real_players.
469 flg_has_min_real_players = True
472 minimum_required_players = int(
473 settings['xonstat.minimum_required_players'])
475 minimum_required_players = 2
477 real_players = num_real_players(player_events)
479 if real_players < minimum_required_players:
480 flg_has_min_real_players = False
482 return flg_has_min_real_players
485 def has_required_metadata(metadata):
487 Determines if a give set of metadata has enough data to create a game,
488 server, and map with.
490 flg_has_req_metadata = True
492 if 'G' not in metadata or\
493 'M' not in metadata or\
494 'I' not in metadata or\
496 flg_has_req_metadata = False
498 return flg_has_req_metadata
501 def should_do_weapon_stats(game_type_cd):
502 """True of the game type should record weapon stats. False otherwise."""
503 if game_type_cd in 'cts':
509 def gametype_elo_eligible(game_type_cd):
510 """True of the game type should process Elos. False otherwise."""
511 elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft')
513 if game_type_cd in elo_game_types:
519 def register_new_nick(session, player, new_nick):
521 Change the player record's nick to the newly found nick. Store the old
522 nick in the player_nicks table for that player.
524 session - SQLAlchemy database session factory
525 player - player record whose nick is changing
526 new_nick - the new nickname
528 # see if that nick already exists
529 stripped_nick = strip_colors(qfont_decode(player.nick))
531 player_nick = session.query(PlayerNick).filter_by(
532 player_id=player.player_id, stripped_nick=stripped_nick).one()
533 except NoResultFound, e:
534 # player_id/stripped_nick not found, create one
535 # but we don't store "Anonymous Player #N"
536 if not re.search('^Anonymous Player #\d+$', player.nick):
537 player_nick = PlayerNick()
538 player_nick.player_id = player.player_id
539 player_nick.stripped_nick = stripped_nick
540 player_nick.nick = player.nick
541 session.add(player_nick)
543 # We change to the new nick regardless
544 player.nick = new_nick
545 player.stripped_nick = strip_colors(qfont_decode(new_nick))
549 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
551 Check the fastest cap time for the player and map. If there isn't
552 one, insert one. If there is, check if the passed time is faster.
555 # we don't record fastest cap times for bots or anonymous players
559 # see if a cap entry exists already
560 # then check to see if the new captime is faster
562 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
563 player_id=player_id, map_id=map_id, mod=mod).one()
565 # current captime is faster, so update
566 if captime < cur_fastest_cap.fastest_cap:
567 cur_fastest_cap.fastest_cap = captime
568 cur_fastest_cap.game_id = game_id
569 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
570 session.add(cur_fastest_cap)
572 except NoResultFound, e:
573 # none exists, so insert
574 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
576 session.add(cur_fastest_cap)
580 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
582 Updates the server in the given DB session, if needed.
584 :param server: The found server instance.
585 :param name: The incoming server name.
586 :param hashkey: The incoming server hashkey.
587 :param ip_addr: The incoming server IP address.
588 :param port: The incoming server port.
589 :param revision: The incoming server revision.
590 :param impure_cvars: The incoming number of impure server cvars.
593 # ensure the two int attributes are actually ints
600 impure_cvars = int(impure_cvars)
605 if name and server.name != name:
608 if hashkey and server.hashkey != hashkey:
609 server.hashkey = hashkey
611 if ip_addr and server.ip_addr != ip_addr:
612 server.ip_addr = ip_addr
614 if port and server.port != port:
617 if revision and server.revision != revision:
618 server.revision = revision
620 if impure_cvars and server.impure_cvars != impure_cvars:
621 server.impure_cvars = impure_cvars
622 server.pure_ind = True if impure_cvars == 0 else False
628 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
630 Find a server by name or create one if not found. Parameters:
632 session - SQLAlchemy database session factory
633 name - server name of the server to be found or created
634 hashkey - server hashkey
635 ip_addr - the IP address of the server
636 revision - the xonotic revision number
637 port - the port number of the server
638 impure_cvars - the number of impure cvar changes
640 servers_q = DBSession.query(Server).filter(Server.active_ind)
643 # if the hashkey is provided, we'll use that
644 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
646 # otherwise, it is just by name
647 servers_q = servers_q.filter(Server.name == name)
649 # order by the hashkey, which means any hashkey match will appear first if there are multiple
650 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
652 if len(servers) == 0:
653 server = Server(name=name, hashkey=hashkey)
656 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
659 if len(servers) == 1:
660 log.info("Found existing server {}.".format(server.server_id))
662 elif len(servers) > 1:
663 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
664 log.warn("Multiple servers found ({})! Using the first one ({})."
665 .format(server_id_list, server.server_id))
667 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
673 def get_or_create_map(session=None, name=None):
675 Find a map by name or create one if not found. Parameters:
677 session - SQLAlchemy database session factory
678 name - map name of the map to be found or created
681 # find one by the name, if it exists
682 gmap = session.query(Map).filter_by(name=name).one()
683 log.debug("Found map id {0}: {1}".format(gmap.map_id,
685 except NoResultFound, e:
686 gmap = Map(name=name)
689 log.debug("Created map id {0}: {1}".format(gmap.map_id,
691 except MultipleResultsFound, e:
692 # multiple found, so use the first one but warn
694 gmaps = session.query(Map).filter_by(name=name).order_by(
697 log.debug("Found map id {0}: {1} but found \
698 multiple".format(gmap.map_id, gmap.name))
703 def create_game(session, start_dt, game_type_cd, server_id, map_id,
704 match_id, duration, mod, winner=None):
706 Creates a game. Parameters:
708 session - SQLAlchemy database session factory
709 start_dt - when the game started (datetime object)
710 game_type_cd - the game type of the game being played
711 server_id - server identifier of the server hosting the game
712 map_id - map on which the game was played
713 winner - the team id of the team that won
714 duration - how long the game lasted
715 mod - mods in use during the game
717 seq = Sequence('games_game_id_seq')
718 game_id = session.execute(seq)
719 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
720 server_id=server_id, map_id=map_id, winner=winner)
721 game.match_id = match_id
724 # There is some drift between start_dt (provided by app) and create_dt
725 # (default in the database), so we'll make them the same until this is
727 game.create_dt = start_dt
730 game.duration = datetime.timedelta(seconds=int(round(float(duration))))
735 session.query(Game).filter(Game.server_id==server_id).\
736 filter(Game.match_id==match_id).one()
738 log.debug("Error: game with same server and match_id found! Ignoring.")
740 # if a game under the same server and match_id found,
741 # this is a duplicate game and can be ignored
742 raise pyramid.httpexceptions.HTTPOk('OK')
743 except NoResultFound, e:
744 # server_id/match_id combination not found. game is ok to insert
747 log.debug("Created game id {0} on server {1}, map {2} at \
748 {3}".format(game.game_id,
749 server_id, map_id, start_dt))
754 def get_or_create_player(session=None, hashkey=None, nick=None):
756 Finds a player by hashkey or creates a new one (along with a
757 corresponding hashkey entry. Parameters:
759 session - SQLAlchemy database session factory
760 hashkey - hashkey of the player to be found or created
761 nick - nick of the player (in case of a first time create)
764 if re.search('^bot#\d+', hashkey):
765 player = session.query(Player).filter_by(player_id=1).one()
766 # if we have an untracked player
767 elif re.search('^player#\d+$', hashkey):
768 player = session.query(Player).filter_by(player_id=2).one()
769 # else it is a tracked player
771 # see if the player is already in the database
772 # if not, create one and the hashkey along with it
774 hk = session.query(Hashkey).filter_by(
775 hashkey=hashkey).one()
776 player = session.query(Player).filter_by(
777 player_id=hk.player_id).one()
778 log.debug("Found existing player {0} with hashkey {1}".format(
779 player.player_id, hashkey))
785 # if nick is given to us, use it. If not, use "Anonymous Player"
786 # with a suffix added for uniqueness.
788 player.nick = nick[:128]
789 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
791 player.nick = "Anonymous Player #{0}".format(player.player_id)
792 player.stripped_nick = player.nick
794 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
796 log.debug("Created player {0} ({2}) with hashkey {1}".format(
797 player.player_id, hashkey, player.nick.encode('utf-8')))
802 def create_default_game_stat(session, game_type_cd):
803 """Creates a blanked-out pgstat record for the given game type"""
805 # this is what we have to do to get partitioned records in - grab the
806 # sequence value first, then insert using the explicit ID (vs autogenerate)
807 seq = Sequence('player_game_stats_player_game_stat_id_seq')
808 pgstat_id = session.execute(seq)
809 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
810 create_dt=datetime.datetime.utcnow())
812 if game_type_cd == 'as':
813 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
815 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
816 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
818 if game_type_cd == 'cq':
819 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
822 if game_type_cd == 'ctf':
823 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
824 pgstat.returns = pgstat.carrier_frags = 0
826 if game_type_cd == 'cts':
829 if game_type_cd == 'dom':
830 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
833 if game_type_cd == 'ft':
834 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
836 if game_type_cd == 'ka':
837 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
838 pgstat.carrier_frags = 0
839 pgstat.time = datetime.timedelta(seconds=0)
841 if game_type_cd == 'kh':
842 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
843 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
844 pgstat.carrier_frags = 0
846 if game_type_cd == 'lms':
847 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
849 if game_type_cd == 'nb':
850 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
853 if game_type_cd == 'rc':
854 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
859 def create_game_stat(session, game_meta, game, server, gmap, player, events):
860 """Game stats handler for all game types"""
862 game_type_cd = game.game_type_cd
864 pgstat = create_default_game_stat(session, game_type_cd)
866 # these fields should be on every pgstat record
867 pgstat.game_id = game.game_id
868 pgstat.player_id = player.player_id
869 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
870 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
871 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
872 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
873 pgstat.rank = int(events.get('rank', None))
874 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
876 if pgstat.nick != player.nick \
877 and player.player_id > 2 \
878 and pgstat.nick != 'Anonymous Player':
879 register_new_nick(session, player, pgstat.nick)
883 # gametype-specific stuff is handled here. if passed to us, we store it
884 for (key,value) in events.items():
885 if key == 'wins': wins = True
886 if key == 't': pgstat.team = int(value)
888 if key == 'scoreboard-drops': pgstat.drops = int(value)
889 if key == 'scoreboard-returns': pgstat.returns = int(value)
890 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
891 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
892 if key == 'scoreboard-caps': pgstat.captures = int(value)
893 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
894 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
895 if key == 'scoreboard-kills': pgstat.kills = int(value)
896 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
897 if key == 'scoreboard-objectives': pgstat.collects = int(value)
898 if key == 'scoreboard-captured': pgstat.captures = int(value)
899 if key == 'scoreboard-released': pgstat.drops = int(value)
900 if key == 'scoreboard-fastest':
901 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
902 if key == 'scoreboard-takes': pgstat.pickups = int(value)
903 if key == 'scoreboard-ticks': pgstat.drops = int(value)
904 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
905 if key == 'scoreboard-bctime':
906 pgstat.time = datetime.timedelta(seconds=int(value))
907 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
908 if key == 'scoreboard-losses': pgstat.drops = int(value)
909 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
910 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
911 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
912 if key == 'scoreboard-lives': pgstat.lives = int(value)
913 if key == 'scoreboard-goals': pgstat.captures = int(value)
914 if key == 'scoreboard-faults': pgstat.drops = int(value)
915 if key == 'scoreboard-laps': pgstat.laps = int(value)
917 if key == 'avglatency': pgstat.avg_latency = float(value)
918 if key == 'scoreboard-captime':
919 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
920 if game.game_type_cd == 'ctf':
921 update_fastest_cap(session, player.player_id, game.game_id,
922 gmap.map_id, pgstat.fastest, game.mod)
924 # there is no "winning team" field, so we have to derive it
925 if wins and pgstat.team is not None and game.winner is None:
926 game.winner = pgstat.team
934 def create_anticheats(session, pgstat, game, player, events):
935 """Anticheats handler for all game types"""
939 # all anticheat events are prefixed by "anticheat"
940 for (key,value) in events.items():
941 if key.startswith("anticheat"):
943 ac = PlayerGameAnticheat(
949 anticheats.append(ac)
951 except Exception as e:
952 log.debug("Could not parse value for key %s. Ignoring." % key)
957 def create_default_team_stat(session, game_type_cd):
958 """Creates a blanked-out teamstat record for the given game type"""
960 # this is what we have to do to get partitioned records in - grab the
961 # sequence value first, then insert using the explicit ID (vs autogenerate)
962 seq = Sequence('team_game_stats_team_game_stat_id_seq')
963 teamstat_id = session.execute(seq)
964 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
965 create_dt=datetime.datetime.utcnow())
967 # all team game modes have a score, so we'll zero that out always
970 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
973 if game_type_cd == 'ctf':
979 def create_team_stat(session, game, events):
980 """Team stats handler for all game types"""
983 teamstat = create_default_team_stat(session, game.game_type_cd)
984 teamstat.game_id = game.game_id
986 # we should have a team ID if we have a 'Q' event
987 if re.match(r'^team#\d+$', events.get('Q', '')):
988 team = int(events.get('Q').replace('team#', ''))
991 # gametype-specific stuff is handled here. if passed to us, we store it
992 for (key,value) in events.items():
993 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
994 if key == 'scoreboard-caps': teamstat.caps = int(value)
995 if key == 'scoreboard-goals': teamstat.caps = int(value)
996 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
998 session.add(teamstat)
999 except Exception as e:
1005 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
1006 """Weapon stats handler for all game types"""
1009 # Version 1 of stats submissions doubled the data sent.
1010 # To counteract this we divide the data by 2 only for
1011 # POSTs coming from version 1.
1013 version = int(game_meta['V'])
1016 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
1022 for (key,value) in events.items():
1023 matched = re.search("acc-(.*?)-cnt-fired", key)
1025 weapon_cd = matched.group(1)
1027 # Weapon names changed for 0.8. We'll convert the old
1028 # ones to use the new scheme as well.
1029 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
1031 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
1032 pwstat_id = session.execute(seq)
1033 pwstat = PlayerWeaponStat()
1034 pwstat.player_weapon_stats_id = pwstat_id
1035 pwstat.player_id = player.player_id
1036 pwstat.game_id = game.game_id
1037 pwstat.player_game_stat_id = pgstat.player_game_stat_id
1038 pwstat.weapon_cd = mapped_weapon_cd
1041 pwstat.nick = events['n']
1043 pwstat.nick = events['P']
1045 if 'acc-' + weapon_cd + '-cnt-fired' in events:
1046 pwstat.fired = int(round(float(
1047 events['acc-' + weapon_cd + '-cnt-fired'])))
1048 if 'acc-' + weapon_cd + '-fired' in events:
1049 pwstat.max = int(round(float(
1050 events['acc-' + weapon_cd + '-fired'])))
1051 if 'acc-' + weapon_cd + '-cnt-hit' in events:
1052 pwstat.hit = int(round(float(
1053 events['acc-' + weapon_cd + '-cnt-hit'])))
1054 if 'acc-' + weapon_cd + '-hit' in events:
1055 pwstat.actual = int(round(float(
1056 events['acc-' + weapon_cd + '-hit'])))
1057 if 'acc-' + weapon_cd + '-frags' in events:
1058 pwstat.frags = int(round(float(
1059 events['acc-' + weapon_cd + '-frags'])))
1062 pwstat.fired = pwstat.fired/2
1063 pwstat.max = pwstat.max/2
1064 pwstat.hit = pwstat.hit/2
1065 pwstat.actual = pwstat.actual/2
1066 pwstat.frags = pwstat.frags/2
1069 pwstats.append(pwstat)
1074 def get_ranks(session, player_ids, game_type_cd):
1076 Gets the rank entries for all players in the given list, returning a dict
1077 of player_id -> PlayerRank instance. The rank entry corresponds to the
1078 game type of the parameter passed in as well.
1081 for pr in session.query(PlayerRank).\
1082 filter(PlayerRank.player_id.in_(player_ids)).\
1083 filter(PlayerRank.game_type_cd == game_type_cd).\
1085 ranks[pr.player_id] = pr
1090 def submit_stats(request):
1092 Entry handler for POST stats submissions.
1095 # placeholder for the actual session
1098 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1099 "----- END REQUEST BODY -----\n\n")
1101 (idfp, status) = verify_request(request)
1102 (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
1103 revision = game_meta.get('R', 'unknown')
1104 duration = game_meta.get('D', None)
1106 # only players present at the end of the match are eligible for stats
1107 raw_players = filter(played_in_game, raw_players)
1109 do_precondition_checks(request, game_meta, raw_players)
1111 # the "duel" gametype is fake
1112 if len(raw_players) == 2 \
1113 and num_real_players(raw_players) == 2 \
1114 and game_meta['G'] == 'dm':
1115 game_meta['G'] = 'duel'
1117 #----------------------------------------------------------------------
1118 # Actual setup (inserts/updates) below here
1119 #----------------------------------------------------------------------
1120 session = DBSession()
1122 game_type_cd = game_meta['G']
1124 # All game types create Game, Server, Map, and Player records
1126 server = get_or_create_server(
1129 name = game_meta['S'],
1130 revision = revision,
1131 ip_addr = get_remote_addr(request),
1132 port = game_meta.get('U', None),
1133 impure_cvars = game_meta.get('C', 0))
1135 gmap = get_or_create_map(
1137 name = game_meta['M'])
1141 start_dt = datetime.datetime.utcnow(),
1142 server_id = server.server_id,
1143 game_type_cd = game_type_cd,
1144 map_id = gmap.map_id,
1145 match_id = game_meta['I'],
1146 duration = duration,
1147 mod = game_meta.get('O', None))
1149 # keep track of the players we've seen
1153 for events in raw_players:
1154 player = get_or_create_player(
1156 hashkey = events['P'],
1157 nick = events.get('n', None))
1159 pgstat = create_game_stat(session, game_meta, game, server,
1160 gmap, player, events)
1161 pgstats.append(pgstat)
1163 if player.player_id > 1:
1164 anticheats = create_anticheats(session, pgstat, game, player, events)
1166 if player.player_id > 2:
1167 player_ids.append(player.player_id)
1168 hashkeys[player.player_id] = events['P']
1170 if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
1171 pwstats = create_weapon_stats(session, game_meta, game, player,
1174 # store them on games for easy access
1175 game.players = player_ids
1177 for events in raw_teams:
1179 teamstat = create_team_stat(session, game, events)
1180 except Exception as e:
1183 if server.elo_ind and gametype_elo_eligible(game_type_cd):
1184 ep = EloProcessor(session, game, pgstats)
1188 log.debug('Success! Stats recorded.')
1190 # ranks are fetched after we've done the "real" processing
1191 ranks = get_ranks(session, player_ids, game_type_cd)
1193 # plain text response
1194 request.response.content_type = 'text/plain'
1197 "now" : calendar.timegm(datetime.datetime.utcnow().timetuple()),
1201 "player_ids" : player_ids,
1202 "hashkeys" : hashkeys,
1207 except Exception as e: