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])
127 def is_human_player(player):
129 Determines if a given set of events correspond with a non-bot
131 return not player['P'].startswith('bot')
134 def played_in_game(player):
136 Determines if a given set of player events correspond with a player who
137 played in the game (matches 1 and scoreboardvalid 1)
139 return 'matches' in player and 'scoreboardvalid' in player
141 def parse_player(self, key, pid):
142 """Construct a player events listing from the submission."""
144 # all of the keys related to player records
145 player_keys = ['i', 'n', 't', 'e']
149 player_fired_weapon = False
150 player_nonzero_score = False
151 player_fastest = False
153 # Consume all following 'i' 'n' 't' 'e' records
154 while len(self.q) > 0:
155 (key, value) = self.next_item()
156 if key is None and value is None:
159 (sub_key, sub_value) = value.split(' ', 1)
160 player[sub_key] = sub_value
162 if sub_key.endswith("cnt-fired"):
163 player_fired_weapon = True
164 self.add_weapon_fired(sub_key)
165 elif sub_key == 'scoreboard-score' and int(sub_value) != 0:
166 player_nonzero_score = True
167 elif sub_key == 'scoreboard-fastest':
168 player_fastest = True
170 player[key] = unicode(value, 'utf-8')
171 elif key in player_keys:
174 # something we didn't expect - put it back on the deque
175 self.q.appendleft("{} {}".format(key, value))
178 played = self.played_in_game(player)
179 human = self.is_human_player(player)
182 self.humans.append(player)
184 if player_fired_weapon:
185 self.human_fired_weapon = True
187 if player_nonzero_score:
188 self.human_nonzero_score = True
191 self.human_fastest = True
193 elif played and not human:
194 self.bots.append(player)
196 self.players.append(player)
198 def parse_team(self, key, tid):
199 """Construct a team events listing from the submission."""
202 # Consume all following 'e' records
203 while len(self.q) > 0 and self.q[0].startswith('e'):
204 (_, value) = self.next_item()
205 (sub_key, sub_value) = value.split(' ', 1)
206 team[sub_key] = sub_value
208 self.teams.append(team)
211 """Parses the request body into instance variables."""
212 while len(self.q) > 0:
213 (key, value) = self.next_item()
214 if key is None and value is None:
219 self.revision = value
221 self.game_type_cd = value
225 self.map_name = value
227 self.match_id = value
229 self.server_name = unicode(value, 'utf-8')
231 self.impure_cvar_changes = int(value)
233 self.port_number = int(value)
235 self.duration = datetime.timedelta(seconds=int(round(float(value))))
239 self.parse_team(key, value)
241 self.parse_player(key, value)
243 raise Exception("Invalid submission")
248 """Debugging representation of a submission."""
249 return "game_type_cd: {}, mod: {}, players: {}, humans: {}, bots: {}, weapons: {}".format(
250 self.game_type_cd, self.mod, len(self.players), len(self.humans), len(self.bots),
254 def elo_submission_category(submission):
255 """Determines the Elo category purely by what is in the submission data."""
258 vanilla_allowed_weapons = {"shotgun", "devastator", "blaster", "mortar", "vortex", "electro",
259 "arc", "hagar", "crylink", "machinegun"}
260 insta_allowed_weapons = {"vaporizer", "blaster"}
261 overkill_allowed_weapons = {"hmg", "vortex", "shotgun", "blaster", "machinegun", "rpc"}
264 if len(submission.weapons - vanilla_allowed_weapons) == 0:
266 elif mod == "InstaGib":
267 if len(submission.weapons - insta_allowed_weapons) == 0:
269 elif mod == "Overkill":
270 if len(submission.weapons - overkill_allowed_weapons) == 0:
278 def parse_stats_submission(body):
280 Parses the POST request body for a stats submission
282 # storage vars for the request body
288 # we're not in either stanza to start
291 for line in body.split('\n'):
293 (key, value) = line.strip().split(' ', 1)
295 # Server (S) and Nick (n) fields can have international characters.
297 value = unicode(value, 'utf-8')
299 if key not in 'P' 'Q' 'n' 'e' 't' 'i':
300 game_meta[key] = value
302 if key == 'Q' or key == 'P':
303 #log.debug('Found a {0}'.format(key))
304 #log.debug('in_Q: {0}'.format(in_Q))
305 #log.debug('in_P: {0}'.format(in_P))
306 #log.debug('events: {0}'.format(events))
308 # check where we were before and append events accordingly
309 if in_Q and len(events) > 0:
310 #log.debug('creating a team (Q) entry')
313 elif in_P and len(events) > 0:
314 #log.debug('creating a player (P) entry')
315 players.append(events)
319 #log.debug('key == P')
323 #log.debug('key == Q')
330 (subkey, subvalue) = value.split(' ', 1)
331 events[subkey] = subvalue
337 # no key/value pair - move on to the next line
340 # add the last entity we were working on
341 if in_P and len(events) > 0:
342 players.append(events)
343 elif in_Q and len(events) > 0:
346 return (game_meta, players, teams)
349 def is_blank_game(gametype, players):
350 """Determine if this is a blank game or not. A blank game is either:
352 1) a match that ended in the warmup stage, where accuracy events are not
353 present (for non-CTS games)
355 2) a match in which no player made a positive or negative score AND was
358 ... or for CTS, which doesn't record accuracy events
360 1) a match in which no player made a fastest lap AND was
363 ... or for NB, in which not all maps have weapons
365 1) a match in which no player made a positive or negative score
367 r = re.compile(r'acc-.*-cnt-fired')
368 flg_nonzero_score = False
369 flg_acc_events = False
370 flg_fastest_lap = False
372 for events in players:
373 if is_real_player(events) and played_in_game(events):
374 for (key,value) in events.items():
375 if key == 'scoreboard-score' and value != 0:
376 flg_nonzero_score = True
378 flg_acc_events = True
379 if key == 'scoreboard-fastest':
380 flg_fastest_lap = True
382 if gametype == 'cts':
383 return not flg_fastest_lap
384 elif gametype == 'nb':
385 return not flg_nonzero_score
387 return not (flg_nonzero_score and flg_acc_events)
390 def get_remote_addr(request):
391 """Get the Xonotic server's IP address"""
392 if 'X-Forwarded-For' in request.headers:
393 return request.headers['X-Forwarded-For']
395 return request.remote_addr
398 def is_supported_gametype(gametype, version):
399 """Whether a gametype is supported or not"""
402 # if the type can be supported, but with version constraints, uncomment
403 # here and add the restriction for a specific version below
404 supported_game_types = (
423 if gametype in supported_game_types:
428 # some game types were buggy before revisions, thus this additional filter
429 if gametype == 'ca' and version <= 5:
435 def do_precondition_checks(request, game_meta, raw_players):
436 """Precondition checks for ALL gametypes.
437 These do not require a database connection."""
438 if not has_required_metadata(game_meta):
439 msg = "Missing required game metadata"
441 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
443 content_type="text/plain"
447 version = int(game_meta['V'])
449 msg = "Invalid or incorrect game metadata provided"
451 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
453 content_type="text/plain"
456 if not is_supported_gametype(game_meta['G'], version):
457 msg = "Unsupported game type ({})".format(game_meta['G'])
459 raise pyramid.httpexceptions.HTTPOk(
461 content_type="text/plain"
464 if not has_minimum_real_players(request.registry.settings, raw_players):
465 msg = "Not enough real players"
467 raise pyramid.httpexceptions.HTTPOk(
469 content_type="text/plain"
472 if is_blank_game(game_meta['G'], raw_players):
475 raise pyramid.httpexceptions.HTTPOk(
477 content_type="text/plain"
481 def num_real_players(player_events):
483 Returns the number of real players (those who played
484 and are on the scoreboard).
488 for events in player_events:
489 if is_real_player(events) and played_in_game(events):
495 def has_minimum_real_players(settings, player_events):
497 Determines if the collection of player events has enough "real" players
498 to store in the database. The minimum setting comes from the config file
499 under the setting xonstat.minimum_real_players.
501 flg_has_min_real_players = True
504 minimum_required_players = int(
505 settings['xonstat.minimum_required_players'])
507 minimum_required_players = 2
509 real_players = num_real_players(player_events)
511 if real_players < minimum_required_players:
512 flg_has_min_real_players = False
514 return flg_has_min_real_players
517 def has_required_metadata(metadata):
519 Determines if a give set of metadata has enough data to create a game,
520 server, and map with.
522 flg_has_req_metadata = True
524 if 'G' not in metadata or\
525 'M' not in metadata or\
526 'I' not in metadata or\
528 flg_has_req_metadata = False
530 return flg_has_req_metadata
533 def should_do_weapon_stats(game_type_cd):
534 """True of the game type should record weapon stats. False otherwise."""
535 if game_type_cd in 'cts':
541 def gametype_elo_eligible(game_type_cd):
542 """True of the game type should process Elos. False otherwise."""
543 elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft')
545 if game_type_cd in elo_game_types:
551 def register_new_nick(session, player, new_nick):
553 Change the player record's nick to the newly found nick. Store the old
554 nick in the player_nicks table for that player.
556 session - SQLAlchemy database session factory
557 player - player record whose nick is changing
558 new_nick - the new nickname
560 # see if that nick already exists
561 stripped_nick = strip_colors(qfont_decode(player.nick))
563 player_nick = session.query(PlayerNick).filter_by(
564 player_id=player.player_id, stripped_nick=stripped_nick).one()
565 except NoResultFound, e:
566 # player_id/stripped_nick not found, create one
567 # but we don't store "Anonymous Player #N"
568 if not re.search('^Anonymous Player #\d+$', player.nick):
569 player_nick = PlayerNick()
570 player_nick.player_id = player.player_id
571 player_nick.stripped_nick = stripped_nick
572 player_nick.nick = player.nick
573 session.add(player_nick)
575 # We change to the new nick regardless
576 player.nick = new_nick
577 player.stripped_nick = strip_colors(qfont_decode(new_nick))
581 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
583 Check the fastest cap time for the player and map. If there isn't
584 one, insert one. If there is, check if the passed time is faster.
587 # we don't record fastest cap times for bots or anonymous players
591 # see if a cap entry exists already
592 # then check to see if the new captime is faster
594 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
595 player_id=player_id, map_id=map_id, mod=mod).one()
597 # current captime is faster, so update
598 if captime < cur_fastest_cap.fastest_cap:
599 cur_fastest_cap.fastest_cap = captime
600 cur_fastest_cap.game_id = game_id
601 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
602 session.add(cur_fastest_cap)
604 except NoResultFound, e:
605 # none exists, so insert
606 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
608 session.add(cur_fastest_cap)
612 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
614 Updates the server in the given DB session, if needed.
616 :param server: The found server instance.
617 :param name: The incoming server name.
618 :param hashkey: The incoming server hashkey.
619 :param ip_addr: The incoming server IP address.
620 :param port: The incoming server port.
621 :param revision: The incoming server revision.
622 :param impure_cvars: The incoming number of impure server cvars.
625 # ensure the two int attributes are actually ints
632 impure_cvars = int(impure_cvars)
637 if name and server.name != name:
640 if hashkey and server.hashkey != hashkey:
641 server.hashkey = hashkey
643 if ip_addr and server.ip_addr != ip_addr:
644 server.ip_addr = ip_addr
646 if port and server.port != port:
649 if revision and server.revision != revision:
650 server.revision = revision
652 if impure_cvars and server.impure_cvars != impure_cvars:
653 server.impure_cvars = impure_cvars
654 server.pure_ind = True if impure_cvars == 0 else False
660 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
662 Find a server by name or create one if not found. Parameters:
664 session - SQLAlchemy database session factory
665 name - server name of the server to be found or created
666 hashkey - server hashkey
667 ip_addr - the IP address of the server
668 revision - the xonotic revision number
669 port - the port number of the server
670 impure_cvars - the number of impure cvar changes
672 servers_q = DBSession.query(Server).filter(Server.active_ind)
675 # if the hashkey is provided, we'll use that
676 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
678 # otherwise, it is just by name
679 servers_q = servers_q.filter(Server.name == name)
681 # order by the hashkey, which means any hashkey match will appear first if there are multiple
682 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
684 if len(servers) == 0:
685 server = Server(name=name, hashkey=hashkey)
688 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
691 if len(servers) == 1:
692 log.info("Found existing server {}.".format(server.server_id))
694 elif len(servers) > 1:
695 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
696 log.warn("Multiple servers found ({})! Using the first one ({})."
697 .format(server_id_list, server.server_id))
699 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
705 def get_or_create_map(session=None, name=None):
707 Find a map by name or create one if not found. Parameters:
709 session - SQLAlchemy database session factory
710 name - map name of the map to be found or created
713 # find one by the name, if it exists
714 gmap = session.query(Map).filter_by(name=name).one()
715 log.debug("Found map id {0}: {1}".format(gmap.map_id,
717 except NoResultFound, e:
718 gmap = Map(name=name)
721 log.debug("Created map id {0}: {1}".format(gmap.map_id,
723 except MultipleResultsFound, e:
724 # multiple found, so use the first one but warn
726 gmaps = session.query(Map).filter_by(name=name).order_by(
729 log.debug("Found map id {0}: {1} but found \
730 multiple".format(gmap.map_id, gmap.name))
735 def create_game(session, start_dt, game_type_cd, server_id, map_id,
736 match_id, duration, mod, winner=None):
738 Creates a game. Parameters:
740 session - SQLAlchemy database session factory
741 start_dt - when the game started (datetime object)
742 game_type_cd - the game type of the game being played
743 server_id - server identifier of the server hosting the game
744 map_id - map on which the game was played
745 winner - the team id of the team that won
746 duration - how long the game lasted
747 mod - mods in use during the game
749 seq = Sequence('games_game_id_seq')
750 game_id = session.execute(seq)
751 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
752 server_id=server_id, map_id=map_id, winner=winner)
753 game.match_id = match_id
756 # There is some drift between start_dt (provided by app) and create_dt
757 # (default in the database), so we'll make them the same until this is
759 game.create_dt = start_dt
762 game.duration = datetime.timedelta(seconds=int(round(float(duration))))
767 session.query(Game).filter(Game.server_id==server_id).\
768 filter(Game.match_id==match_id).one()
770 log.debug("Error: game with same server and match_id found! Ignoring.")
772 # if a game under the same server and match_id found,
773 # this is a duplicate game and can be ignored
774 raise pyramid.httpexceptions.HTTPOk('OK')
775 except NoResultFound, e:
776 # server_id/match_id combination not found. game is ok to insert
779 log.debug("Created game id {0} on server {1}, map {2} at \
780 {3}".format(game.game_id,
781 server_id, map_id, start_dt))
786 def get_or_create_player(session=None, hashkey=None, nick=None):
788 Finds a player by hashkey or creates a new one (along with a
789 corresponding hashkey entry. Parameters:
791 session - SQLAlchemy database session factory
792 hashkey - hashkey of the player to be found or created
793 nick - nick of the player (in case of a first time create)
796 if re.search('^bot#\d+', hashkey):
797 player = session.query(Player).filter_by(player_id=1).one()
798 # if we have an untracked player
799 elif re.search('^player#\d+$', hashkey):
800 player = session.query(Player).filter_by(player_id=2).one()
801 # else it is a tracked player
803 # see if the player is already in the database
804 # if not, create one and the hashkey along with it
806 hk = session.query(Hashkey).filter_by(
807 hashkey=hashkey).one()
808 player = session.query(Player).filter_by(
809 player_id=hk.player_id).one()
810 log.debug("Found existing player {0} with hashkey {1}".format(
811 player.player_id, hashkey))
817 # if nick is given to us, use it. If not, use "Anonymous Player"
818 # with a suffix added for uniqueness.
820 player.nick = nick[:128]
821 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
823 player.nick = "Anonymous Player #{0}".format(player.player_id)
824 player.stripped_nick = player.nick
826 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
828 log.debug("Created player {0} ({2}) with hashkey {1}".format(
829 player.player_id, hashkey, player.nick.encode('utf-8')))
834 def create_default_game_stat(session, game_type_cd):
835 """Creates a blanked-out pgstat record for the given game type"""
837 # this is what we have to do to get partitioned records in - grab the
838 # sequence value first, then insert using the explicit ID (vs autogenerate)
839 seq = Sequence('player_game_stats_player_game_stat_id_seq')
840 pgstat_id = session.execute(seq)
841 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
842 create_dt=datetime.datetime.utcnow())
844 if game_type_cd == 'as':
845 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
847 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
848 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
850 if game_type_cd == 'cq':
851 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
854 if game_type_cd == 'ctf':
855 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
856 pgstat.returns = pgstat.carrier_frags = 0
858 if game_type_cd == 'cts':
861 if game_type_cd == 'dom':
862 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
865 if game_type_cd == 'ft':
866 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
868 if game_type_cd == 'ka':
869 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
870 pgstat.carrier_frags = 0
871 pgstat.time = datetime.timedelta(seconds=0)
873 if game_type_cd == 'kh':
874 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
875 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
876 pgstat.carrier_frags = 0
878 if game_type_cd == 'lms':
879 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
881 if game_type_cd == 'nb':
882 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
885 if game_type_cd == 'rc':
886 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
891 def create_game_stat(session, game_meta, game, server, gmap, player, events):
892 """Game stats handler for all game types"""
894 game_type_cd = game.game_type_cd
896 pgstat = create_default_game_stat(session, game_type_cd)
898 # these fields should be on every pgstat record
899 pgstat.game_id = game.game_id
900 pgstat.player_id = player.player_id
901 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
902 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
903 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
904 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
905 pgstat.rank = int(events.get('rank', None))
906 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
908 if pgstat.nick != player.nick \
909 and player.player_id > 2 \
910 and pgstat.nick != 'Anonymous Player':
911 register_new_nick(session, player, pgstat.nick)
915 # gametype-specific stuff is handled here. if passed to us, we store it
916 for (key,value) in events.items():
917 if key == 'wins': wins = True
918 if key == 't': pgstat.team = int(value)
920 if key == 'scoreboard-drops': pgstat.drops = int(value)
921 if key == 'scoreboard-returns': pgstat.returns = int(value)
922 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
923 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
924 if key == 'scoreboard-caps': pgstat.captures = int(value)
925 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
926 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
927 if key == 'scoreboard-kills': pgstat.kills = int(value)
928 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
929 if key == 'scoreboard-objectives': pgstat.collects = int(value)
930 if key == 'scoreboard-captured': pgstat.captures = int(value)
931 if key == 'scoreboard-released': pgstat.drops = int(value)
932 if key == 'scoreboard-fastest':
933 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
934 if key == 'scoreboard-takes': pgstat.pickups = int(value)
935 if key == 'scoreboard-ticks': pgstat.drops = int(value)
936 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
937 if key == 'scoreboard-bctime':
938 pgstat.time = datetime.timedelta(seconds=int(value))
939 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
940 if key == 'scoreboard-losses': pgstat.drops = int(value)
941 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
942 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
943 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
944 if key == 'scoreboard-lives': pgstat.lives = int(value)
945 if key == 'scoreboard-goals': pgstat.captures = int(value)
946 if key == 'scoreboard-faults': pgstat.drops = int(value)
947 if key == 'scoreboard-laps': pgstat.laps = int(value)
949 if key == 'avglatency': pgstat.avg_latency = float(value)
950 if key == 'scoreboard-captime':
951 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
952 if game.game_type_cd == 'ctf':
953 update_fastest_cap(session, player.player_id, game.game_id,
954 gmap.map_id, pgstat.fastest, game.mod)
956 # there is no "winning team" field, so we have to derive it
957 if wins and pgstat.team is not None and game.winner is None:
958 game.winner = pgstat.team
966 def create_anticheats(session, pgstat, game, player, events):
967 """Anticheats handler for all game types"""
971 # all anticheat events are prefixed by "anticheat"
972 for (key,value) in events.items():
973 if key.startswith("anticheat"):
975 ac = PlayerGameAnticheat(
981 anticheats.append(ac)
983 except Exception as e:
984 log.debug("Could not parse value for key %s. Ignoring." % key)
989 def create_default_team_stat(session, game_type_cd):
990 """Creates a blanked-out teamstat record for the given game type"""
992 # this is what we have to do to get partitioned records in - grab the
993 # sequence value first, then insert using the explicit ID (vs autogenerate)
994 seq = Sequence('team_game_stats_team_game_stat_id_seq')
995 teamstat_id = session.execute(seq)
996 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
997 create_dt=datetime.datetime.utcnow())
999 # all team game modes have a score, so we'll zero that out always
1002 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
1005 if game_type_cd == 'ctf':
1011 def create_team_stat(session, game, events):
1012 """Team stats handler for all game types"""
1015 teamstat = create_default_team_stat(session, game.game_type_cd)
1016 teamstat.game_id = game.game_id
1018 # we should have a team ID if we have a 'Q' event
1019 if re.match(r'^team#\d+$', events.get('Q', '')):
1020 team = int(events.get('Q').replace('team#', ''))
1021 teamstat.team = team
1023 # gametype-specific stuff is handled here. if passed to us, we store it
1024 for (key,value) in events.items():
1025 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
1026 if key == 'scoreboard-caps': teamstat.caps = int(value)
1027 if key == 'scoreboard-goals': teamstat.caps = int(value)
1028 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
1030 session.add(teamstat)
1031 except Exception as e:
1037 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
1038 """Weapon stats handler for all game types"""
1041 # Version 1 of stats submissions doubled the data sent.
1042 # To counteract this we divide the data by 2 only for
1043 # POSTs coming from version 1.
1045 version = int(game_meta['V'])
1048 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
1054 for (key,value) in events.items():
1055 matched = re.search("acc-(.*?)-cnt-fired", key)
1057 weapon_cd = matched.group(1)
1059 # Weapon names changed for 0.8. We'll convert the old
1060 # ones to use the new scheme as well.
1061 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
1063 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
1064 pwstat_id = session.execute(seq)
1065 pwstat = PlayerWeaponStat()
1066 pwstat.player_weapon_stats_id = pwstat_id
1067 pwstat.player_id = player.player_id
1068 pwstat.game_id = game.game_id
1069 pwstat.player_game_stat_id = pgstat.player_game_stat_id
1070 pwstat.weapon_cd = mapped_weapon_cd
1073 pwstat.nick = events['n']
1075 pwstat.nick = events['P']
1077 if 'acc-' + weapon_cd + '-cnt-fired' in events:
1078 pwstat.fired = int(round(float(
1079 events['acc-' + weapon_cd + '-cnt-fired'])))
1080 if 'acc-' + weapon_cd + '-fired' in events:
1081 pwstat.max = int(round(float(
1082 events['acc-' + weapon_cd + '-fired'])))
1083 if 'acc-' + weapon_cd + '-cnt-hit' in events:
1084 pwstat.hit = int(round(float(
1085 events['acc-' + weapon_cd + '-cnt-hit'])))
1086 if 'acc-' + weapon_cd + '-hit' in events:
1087 pwstat.actual = int(round(float(
1088 events['acc-' + weapon_cd + '-hit'])))
1089 if 'acc-' + weapon_cd + '-frags' in events:
1090 pwstat.frags = int(round(float(
1091 events['acc-' + weapon_cd + '-frags'])))
1094 pwstat.fired = pwstat.fired/2
1095 pwstat.max = pwstat.max/2
1096 pwstat.hit = pwstat.hit/2
1097 pwstat.actual = pwstat.actual/2
1098 pwstat.frags = pwstat.frags/2
1101 pwstats.append(pwstat)
1106 def get_ranks(session, player_ids, game_type_cd):
1108 Gets the rank entries for all players in the given list, returning a dict
1109 of player_id -> PlayerRank instance. The rank entry corresponds to the
1110 game type of the parameter passed in as well.
1113 for pr in session.query(PlayerRank).\
1114 filter(PlayerRank.player_id.in_(player_ids)).\
1115 filter(PlayerRank.game_type_cd == game_type_cd).\
1117 ranks[pr.player_id] = pr
1122 def submit_stats(request):
1124 Entry handler for POST stats submissions.
1127 # placeholder for the actual session
1130 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1131 "----- END REQUEST BODY -----\n\n")
1133 (idfp, status) = verify_request(request)
1134 (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
1135 revision = game_meta.get('R', 'unknown')
1136 duration = game_meta.get('D', None)
1138 # only players present at the end of the match are eligible for stats
1139 raw_players = filter(played_in_game, raw_players)
1141 do_precondition_checks(request, game_meta, raw_players)
1143 # the "duel" gametype is fake
1144 if len(raw_players) == 2 \
1145 and num_real_players(raw_players) == 2 \
1146 and game_meta['G'] == 'dm':
1147 game_meta['G'] = 'duel'
1149 #----------------------------------------------------------------------
1150 # Actual setup (inserts/updates) below here
1151 #----------------------------------------------------------------------
1152 session = DBSession()
1154 game_type_cd = game_meta['G']
1156 # All game types create Game, Server, Map, and Player records
1158 server = get_or_create_server(
1161 name = game_meta['S'],
1162 revision = revision,
1163 ip_addr = get_remote_addr(request),
1164 port = game_meta.get('U', None),
1165 impure_cvars = game_meta.get('C', 0))
1167 gmap = get_or_create_map(
1169 name = game_meta['M'])
1173 start_dt = datetime.datetime.utcnow(),
1174 server_id = server.server_id,
1175 game_type_cd = game_type_cd,
1176 map_id = gmap.map_id,
1177 match_id = game_meta['I'],
1178 duration = duration,
1179 mod = game_meta.get('O', None))
1181 # keep track of the players we've seen
1185 for events in raw_players:
1186 player = get_or_create_player(
1188 hashkey = events['P'],
1189 nick = events.get('n', None))
1191 pgstat = create_game_stat(session, game_meta, game, server,
1192 gmap, player, events)
1193 pgstats.append(pgstat)
1195 if player.player_id > 1:
1196 anticheats = create_anticheats(session, pgstat, game, player, events)
1198 if player.player_id > 2:
1199 player_ids.append(player.player_id)
1200 hashkeys[player.player_id] = events['P']
1202 if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
1203 pwstats = create_weapon_stats(session, game_meta, game, player,
1206 # store them on games for easy access
1207 game.players = player_ids
1209 for events in raw_teams:
1211 teamstat = create_team_stat(session, game, events)
1212 except Exception as e:
1215 if server.elo_ind and gametype_elo_eligible(game_type_cd):
1216 ep = EloProcessor(session, game, pgstats)
1220 log.debug('Success! Stats recorded.')
1222 # ranks are fetched after we've done the "real" processing
1223 ranks = get_ranks(session, player_ids, game_type_cd)
1225 # plain text response
1226 request.response.content_type = 'text/plain'
1229 "now" : calendar.timegm(datetime.datetime.utcnow().timetuple()),
1233 "player_ids" : player_ids,
1234 "hashkeys" : hashkeys,
1239 except Exception as e: