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 def elo_submission_category(submission):
249 """Determines the Elo category purely by what is in the submission data."""
250 mod = submission.meta.get("O", "None")
252 vanilla_allowed_weapons = {"shotgun", "devastator", "blaster", "mortar", "vortex", "electro",
253 "arc", "hagar", "crylink", "machinegun"}
254 insta_allowed_weapons = {"vaporizer", "blaster"}
255 overkill_allowed_weapons = {"hmg", "vortex", "shotgun", "blaster", "machinegun", "rpc"}
258 if len(submission.weapons - vanilla_allowed_weapons) == 0:
260 elif mod == "InstaGib":
261 if len(submission.weapons - insta_allowed_weapons) == 0:
263 elif mod == "Overkill":
264 if len(submission.weapons - overkill_allowed_weapons) == 0:
272 def parse_stats_submission(body):
274 Parses the POST request body for a stats submission
276 # storage vars for the request body
282 # we're not in either stanza to start
285 for line in body.split('\n'):
287 (key, value) = line.strip().split(' ', 1)
289 # Server (S) and Nick (n) fields can have international characters.
291 value = unicode(value, 'utf-8')
293 if key not in 'P' 'Q' 'n' 'e' 't' 'i':
294 game_meta[key] = value
296 if key == 'Q' or key == 'P':
297 #log.debug('Found a {0}'.format(key))
298 #log.debug('in_Q: {0}'.format(in_Q))
299 #log.debug('in_P: {0}'.format(in_P))
300 #log.debug('events: {0}'.format(events))
302 # check where we were before and append events accordingly
303 if in_Q and len(events) > 0:
304 #log.debug('creating a team (Q) entry')
307 elif in_P and len(events) > 0:
308 #log.debug('creating a player (P) entry')
309 players.append(events)
313 #log.debug('key == P')
317 #log.debug('key == Q')
324 (subkey, subvalue) = value.split(' ', 1)
325 events[subkey] = subvalue
331 # no key/value pair - move on to the next line
334 # add the last entity we were working on
335 if in_P and len(events) > 0:
336 players.append(events)
337 elif in_Q and len(events) > 0:
340 return (game_meta, players, teams)
343 def is_blank_game(gametype, players):
344 """Determine if this is a blank game or not. A blank game is either:
346 1) a match that ended in the warmup stage, where accuracy events are not
347 present (for non-CTS games)
349 2) a match in which no player made a positive or negative score AND was
352 ... or for CTS, which doesn't record accuracy events
354 1) a match in which no player made a fastest lap AND was
357 ... or for NB, in which not all maps have weapons
359 1) a match in which no player made a positive or negative score
361 r = re.compile(r'acc-.*-cnt-fired')
362 flg_nonzero_score = False
363 flg_acc_events = False
364 flg_fastest_lap = False
366 for events in players:
367 if is_real_player(events) and played_in_game(events):
368 for (key,value) in events.items():
369 if key == 'scoreboard-score' and value != 0:
370 flg_nonzero_score = True
372 flg_acc_events = True
373 if key == 'scoreboard-fastest':
374 flg_fastest_lap = True
376 if gametype == 'cts':
377 return not flg_fastest_lap
378 elif gametype == 'nb':
379 return not flg_nonzero_score
381 return not (flg_nonzero_score and flg_acc_events)
384 def get_remote_addr(request):
385 """Get the Xonotic server's IP address"""
386 if 'X-Forwarded-For' in request.headers:
387 return request.headers['X-Forwarded-For']
389 return request.remote_addr
392 def is_supported_gametype(gametype, version):
393 """Whether a gametype is supported or not"""
396 # if the type can be supported, but with version constraints, uncomment
397 # here and add the restriction for a specific version below
398 supported_game_types = (
417 if gametype in supported_game_types:
422 # some game types were buggy before revisions, thus this additional filter
423 if gametype == 'ca' and version <= 5:
429 def do_precondition_checks(request, game_meta, raw_players):
430 """Precondition checks for ALL gametypes.
431 These do not require a database connection."""
432 if not has_required_metadata(game_meta):
433 msg = "Missing required game metadata"
435 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
437 content_type="text/plain"
441 version = int(game_meta['V'])
443 msg = "Invalid or incorrect game metadata provided"
445 raise pyramid.httpexceptions.HTTPUnprocessableEntity(
447 content_type="text/plain"
450 if not is_supported_gametype(game_meta['G'], version):
451 msg = "Unsupported game type ({})".format(game_meta['G'])
453 raise pyramid.httpexceptions.HTTPOk(
455 content_type="text/plain"
458 if not has_minimum_real_players(request.registry.settings, raw_players):
459 msg = "Not enough real players"
461 raise pyramid.httpexceptions.HTTPOk(
463 content_type="text/plain"
466 if is_blank_game(game_meta['G'], raw_players):
469 raise pyramid.httpexceptions.HTTPOk(
471 content_type="text/plain"
475 def num_real_players(player_events):
477 Returns the number of real players (those who played
478 and are on the scoreboard).
482 for events in player_events:
483 if is_real_player(events) and played_in_game(events):
489 def has_minimum_real_players(settings, player_events):
491 Determines if the collection of player events has enough "real" players
492 to store in the database. The minimum setting comes from the config file
493 under the setting xonstat.minimum_real_players.
495 flg_has_min_real_players = True
498 minimum_required_players = int(
499 settings['xonstat.minimum_required_players'])
501 minimum_required_players = 2
503 real_players = num_real_players(player_events)
505 if real_players < minimum_required_players:
506 flg_has_min_real_players = False
508 return flg_has_min_real_players
511 def has_required_metadata(metadata):
513 Determines if a give set of metadata has enough data to create a game,
514 server, and map with.
516 flg_has_req_metadata = True
518 if 'G' not in metadata or\
519 'M' not in metadata or\
520 'I' not in metadata or\
522 flg_has_req_metadata = False
524 return flg_has_req_metadata
527 def should_do_weapon_stats(game_type_cd):
528 """True of the game type should record weapon stats. False otherwise."""
529 if game_type_cd in 'cts':
535 def gametype_elo_eligible(game_type_cd):
536 """True of the game type should process Elos. False otherwise."""
537 elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft')
539 if game_type_cd in elo_game_types:
545 def register_new_nick(session, player, new_nick):
547 Change the player record's nick to the newly found nick. Store the old
548 nick in the player_nicks table for that player.
550 session - SQLAlchemy database session factory
551 player - player record whose nick is changing
552 new_nick - the new nickname
554 # see if that nick already exists
555 stripped_nick = strip_colors(qfont_decode(player.nick))
557 player_nick = session.query(PlayerNick).filter_by(
558 player_id=player.player_id, stripped_nick=stripped_nick).one()
559 except NoResultFound, e:
560 # player_id/stripped_nick not found, create one
561 # but we don't store "Anonymous Player #N"
562 if not re.search('^Anonymous Player #\d+$', player.nick):
563 player_nick = PlayerNick()
564 player_nick.player_id = player.player_id
565 player_nick.stripped_nick = stripped_nick
566 player_nick.nick = player.nick
567 session.add(player_nick)
569 # We change to the new nick regardless
570 player.nick = new_nick
571 player.stripped_nick = strip_colors(qfont_decode(new_nick))
575 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
577 Check the fastest cap time for the player and map. If there isn't
578 one, insert one. If there is, check if the passed time is faster.
581 # we don't record fastest cap times for bots or anonymous players
585 # see if a cap entry exists already
586 # then check to see if the new captime is faster
588 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
589 player_id=player_id, map_id=map_id, mod=mod).one()
591 # current captime is faster, so update
592 if captime < cur_fastest_cap.fastest_cap:
593 cur_fastest_cap.fastest_cap = captime
594 cur_fastest_cap.game_id = game_id
595 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
596 session.add(cur_fastest_cap)
598 except NoResultFound, e:
599 # none exists, so insert
600 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
602 session.add(cur_fastest_cap)
606 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
608 Updates the server in the given DB session, if needed.
610 :param server: The found server instance.
611 :param name: The incoming server name.
612 :param hashkey: The incoming server hashkey.
613 :param ip_addr: The incoming server IP address.
614 :param port: The incoming server port.
615 :param revision: The incoming server revision.
616 :param impure_cvars: The incoming number of impure server cvars.
619 # ensure the two int attributes are actually ints
626 impure_cvars = int(impure_cvars)
631 if name and server.name != name:
634 if hashkey and server.hashkey != hashkey:
635 server.hashkey = hashkey
637 if ip_addr and server.ip_addr != ip_addr:
638 server.ip_addr = ip_addr
640 if port and server.port != port:
643 if revision and server.revision != revision:
644 server.revision = revision
646 if impure_cvars and server.impure_cvars != impure_cvars:
647 server.impure_cvars = impure_cvars
648 server.pure_ind = True if impure_cvars == 0 else False
654 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
656 Find a server by name or create one if not found. Parameters:
658 session - SQLAlchemy database session factory
659 name - server name of the server to be found or created
660 hashkey - server hashkey
661 ip_addr - the IP address of the server
662 revision - the xonotic revision number
663 port - the port number of the server
664 impure_cvars - the number of impure cvar changes
666 servers_q = DBSession.query(Server).filter(Server.active_ind)
669 # if the hashkey is provided, we'll use that
670 servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
672 # otherwise, it is just by name
673 servers_q = servers_q.filter(Server.name == name)
675 # order by the hashkey, which means any hashkey match will appear first if there are multiple
676 servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
678 if len(servers) == 0:
679 server = Server(name=name, hashkey=hashkey)
682 log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
685 if len(servers) == 1:
686 log.info("Found existing server {}.".format(server.server_id))
688 elif len(servers) > 1:
689 server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
690 log.warn("Multiple servers found ({})! Using the first one ({})."
691 .format(server_id_list, server.server_id))
693 if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
699 def get_or_create_map(session=None, name=None):
701 Find a map by name or create one if not found. Parameters:
703 session - SQLAlchemy database session factory
704 name - map name of the map to be found or created
707 # find one by the name, if it exists
708 gmap = session.query(Map).filter_by(name=name).one()
709 log.debug("Found map id {0}: {1}".format(gmap.map_id,
711 except NoResultFound, e:
712 gmap = Map(name=name)
715 log.debug("Created map id {0}: {1}".format(gmap.map_id,
717 except MultipleResultsFound, e:
718 # multiple found, so use the first one but warn
720 gmaps = session.query(Map).filter_by(name=name).order_by(
723 log.debug("Found map id {0}: {1} but found \
724 multiple".format(gmap.map_id, gmap.name))
729 def create_game(session, start_dt, game_type_cd, server_id, map_id,
730 match_id, duration, mod, winner=None):
732 Creates a game. Parameters:
734 session - SQLAlchemy database session factory
735 start_dt - when the game started (datetime object)
736 game_type_cd - the game type of the game being played
737 server_id - server identifier of the server hosting the game
738 map_id - map on which the game was played
739 winner - the team id of the team that won
740 duration - how long the game lasted
741 mod - mods in use during the game
743 seq = Sequence('games_game_id_seq')
744 game_id = session.execute(seq)
745 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
746 server_id=server_id, map_id=map_id, winner=winner)
747 game.match_id = match_id
750 # There is some drift between start_dt (provided by app) and create_dt
751 # (default in the database), so we'll make them the same until this is
753 game.create_dt = start_dt
756 game.duration = datetime.timedelta(seconds=int(round(float(duration))))
761 session.query(Game).filter(Game.server_id==server_id).\
762 filter(Game.match_id==match_id).one()
764 log.debug("Error: game with same server and match_id found! Ignoring.")
766 # if a game under the same server and match_id found,
767 # this is a duplicate game and can be ignored
768 raise pyramid.httpexceptions.HTTPOk('OK')
769 except NoResultFound, e:
770 # server_id/match_id combination not found. game is ok to insert
773 log.debug("Created game id {0} on server {1}, map {2} at \
774 {3}".format(game.game_id,
775 server_id, map_id, start_dt))
780 def get_or_create_player(session=None, hashkey=None, nick=None):
782 Finds a player by hashkey or creates a new one (along with a
783 corresponding hashkey entry. Parameters:
785 session - SQLAlchemy database session factory
786 hashkey - hashkey of the player to be found or created
787 nick - nick of the player (in case of a first time create)
790 if re.search('^bot#\d+', hashkey):
791 player = session.query(Player).filter_by(player_id=1).one()
792 # if we have an untracked player
793 elif re.search('^player#\d+$', hashkey):
794 player = session.query(Player).filter_by(player_id=2).one()
795 # else it is a tracked player
797 # see if the player is already in the database
798 # if not, create one and the hashkey along with it
800 hk = session.query(Hashkey).filter_by(
801 hashkey=hashkey).one()
802 player = session.query(Player).filter_by(
803 player_id=hk.player_id).one()
804 log.debug("Found existing player {0} with hashkey {1}".format(
805 player.player_id, hashkey))
811 # if nick is given to us, use it. If not, use "Anonymous Player"
812 # with a suffix added for uniqueness.
814 player.nick = nick[:128]
815 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
817 player.nick = "Anonymous Player #{0}".format(player.player_id)
818 player.stripped_nick = player.nick
820 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
822 log.debug("Created player {0} ({2}) with hashkey {1}".format(
823 player.player_id, hashkey, player.nick.encode('utf-8')))
828 def create_default_game_stat(session, game_type_cd):
829 """Creates a blanked-out pgstat record for the given game type"""
831 # this is what we have to do to get partitioned records in - grab the
832 # sequence value first, then insert using the explicit ID (vs autogenerate)
833 seq = Sequence('player_game_stats_player_game_stat_id_seq')
834 pgstat_id = session.execute(seq)
835 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
836 create_dt=datetime.datetime.utcnow())
838 if game_type_cd == 'as':
839 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
841 if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
842 pgstat.kills = pgstat.deaths = pgstat.suicides = 0
844 if game_type_cd == 'cq':
845 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
848 if game_type_cd == 'ctf':
849 pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
850 pgstat.returns = pgstat.carrier_frags = 0
852 if game_type_cd == 'cts':
855 if game_type_cd == 'dom':
856 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
859 if game_type_cd == 'ft':
860 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
862 if game_type_cd == 'ka':
863 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
864 pgstat.carrier_frags = 0
865 pgstat.time = datetime.timedelta(seconds=0)
867 if game_type_cd == 'kh':
868 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
869 pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
870 pgstat.carrier_frags = 0
872 if game_type_cd == 'lms':
873 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
875 if game_type_cd == 'nb':
876 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
879 if game_type_cd == 'rc':
880 pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
885 def create_game_stat(session, game_meta, game, server, gmap, player, events):
886 """Game stats handler for all game types"""
888 game_type_cd = game.game_type_cd
890 pgstat = create_default_game_stat(session, game_type_cd)
892 # these fields should be on every pgstat record
893 pgstat.game_id = game.game_id
894 pgstat.player_id = player.player_id
895 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
896 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
897 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
898 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
899 pgstat.rank = int(events.get('rank', None))
900 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
902 if pgstat.nick != player.nick \
903 and player.player_id > 2 \
904 and pgstat.nick != 'Anonymous Player':
905 register_new_nick(session, player, pgstat.nick)
909 # gametype-specific stuff is handled here. if passed to us, we store it
910 for (key,value) in events.items():
911 if key == 'wins': wins = True
912 if key == 't': pgstat.team = int(value)
914 if key == 'scoreboard-drops': pgstat.drops = int(value)
915 if key == 'scoreboard-returns': pgstat.returns = int(value)
916 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
917 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
918 if key == 'scoreboard-caps': pgstat.captures = int(value)
919 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
920 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
921 if key == 'scoreboard-kills': pgstat.kills = int(value)
922 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
923 if key == 'scoreboard-objectives': pgstat.collects = int(value)
924 if key == 'scoreboard-captured': pgstat.captures = int(value)
925 if key == 'scoreboard-released': pgstat.drops = int(value)
926 if key == 'scoreboard-fastest':
927 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
928 if key == 'scoreboard-takes': pgstat.pickups = int(value)
929 if key == 'scoreboard-ticks': pgstat.drops = int(value)
930 if key == 'scoreboard-revivals': pgstat.revivals = int(value)
931 if key == 'scoreboard-bctime':
932 pgstat.time = datetime.timedelta(seconds=int(value))
933 if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
934 if key == 'scoreboard-losses': pgstat.drops = int(value)
935 if key == 'scoreboard-pushes': pgstat.pushes = int(value)
936 if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
937 if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
938 if key == 'scoreboard-lives': pgstat.lives = int(value)
939 if key == 'scoreboard-goals': pgstat.captures = int(value)
940 if key == 'scoreboard-faults': pgstat.drops = int(value)
941 if key == 'scoreboard-laps': pgstat.laps = int(value)
943 if key == 'avglatency': pgstat.avg_latency = float(value)
944 if key == 'scoreboard-captime':
945 pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
946 if game.game_type_cd == 'ctf':
947 update_fastest_cap(session, player.player_id, game.game_id,
948 gmap.map_id, pgstat.fastest, game.mod)
950 # there is no "winning team" field, so we have to derive it
951 if wins and pgstat.team is not None and game.winner is None:
952 game.winner = pgstat.team
960 def create_anticheats(session, pgstat, game, player, events):
961 """Anticheats handler for all game types"""
965 # all anticheat events are prefixed by "anticheat"
966 for (key,value) in events.items():
967 if key.startswith("anticheat"):
969 ac = PlayerGameAnticheat(
975 anticheats.append(ac)
977 except Exception as e:
978 log.debug("Could not parse value for key %s. Ignoring." % key)
983 def create_default_team_stat(session, game_type_cd):
984 """Creates a blanked-out teamstat record for the given game type"""
986 # this is what we have to do to get partitioned records in - grab the
987 # sequence value first, then insert using the explicit ID (vs autogenerate)
988 seq = Sequence('team_game_stats_team_game_stat_id_seq')
989 teamstat_id = session.execute(seq)
990 teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
991 create_dt=datetime.datetime.utcnow())
993 # all team game modes have a score, so we'll zero that out always
996 if game_type_cd in 'ca' 'ft' 'lms' 'ka':
999 if game_type_cd == 'ctf':
1005 def create_team_stat(session, game, events):
1006 """Team stats handler for all game types"""
1009 teamstat = create_default_team_stat(session, game.game_type_cd)
1010 teamstat.game_id = game.game_id
1012 # we should have a team ID if we have a 'Q' event
1013 if re.match(r'^team#\d+$', events.get('Q', '')):
1014 team = int(events.get('Q').replace('team#', ''))
1015 teamstat.team = team
1017 # gametype-specific stuff is handled here. if passed to us, we store it
1018 for (key,value) in events.items():
1019 if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
1020 if key == 'scoreboard-caps': teamstat.caps = int(value)
1021 if key == 'scoreboard-goals': teamstat.caps = int(value)
1022 if key == 'scoreboard-rounds': teamstat.rounds = int(value)
1024 session.add(teamstat)
1025 except Exception as e:
1031 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
1032 """Weapon stats handler for all game types"""
1035 # Version 1 of stats submissions doubled the data sent.
1036 # To counteract this we divide the data by 2 only for
1037 # POSTs coming from version 1.
1039 version = int(game_meta['V'])
1042 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
1048 for (key,value) in events.items():
1049 matched = re.search("acc-(.*?)-cnt-fired", key)
1051 weapon_cd = matched.group(1)
1053 # Weapon names changed for 0.8. We'll convert the old
1054 # ones to use the new scheme as well.
1055 mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
1057 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
1058 pwstat_id = session.execute(seq)
1059 pwstat = PlayerWeaponStat()
1060 pwstat.player_weapon_stats_id = pwstat_id
1061 pwstat.player_id = player.player_id
1062 pwstat.game_id = game.game_id
1063 pwstat.player_game_stat_id = pgstat.player_game_stat_id
1064 pwstat.weapon_cd = mapped_weapon_cd
1067 pwstat.nick = events['n']
1069 pwstat.nick = events['P']
1071 if 'acc-' + weapon_cd + '-cnt-fired' in events:
1072 pwstat.fired = int(round(float(
1073 events['acc-' + weapon_cd + '-cnt-fired'])))
1074 if 'acc-' + weapon_cd + '-fired' in events:
1075 pwstat.max = int(round(float(
1076 events['acc-' + weapon_cd + '-fired'])))
1077 if 'acc-' + weapon_cd + '-cnt-hit' in events:
1078 pwstat.hit = int(round(float(
1079 events['acc-' + weapon_cd + '-cnt-hit'])))
1080 if 'acc-' + weapon_cd + '-hit' in events:
1081 pwstat.actual = int(round(float(
1082 events['acc-' + weapon_cd + '-hit'])))
1083 if 'acc-' + weapon_cd + '-frags' in events:
1084 pwstat.frags = int(round(float(
1085 events['acc-' + weapon_cd + '-frags'])))
1088 pwstat.fired = pwstat.fired/2
1089 pwstat.max = pwstat.max/2
1090 pwstat.hit = pwstat.hit/2
1091 pwstat.actual = pwstat.actual/2
1092 pwstat.frags = pwstat.frags/2
1095 pwstats.append(pwstat)
1100 def get_ranks(session, player_ids, game_type_cd):
1102 Gets the rank entries for all players in the given list, returning a dict
1103 of player_id -> PlayerRank instance. The rank entry corresponds to the
1104 game type of the parameter passed in as well.
1107 for pr in session.query(PlayerRank).\
1108 filter(PlayerRank.player_id.in_(player_ids)).\
1109 filter(PlayerRank.game_type_cd == game_type_cd).\
1111 ranks[pr.player_id] = pr
1116 def submit_stats(request):
1118 Entry handler for POST stats submissions.
1121 # placeholder for the actual session
1124 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1125 "----- END REQUEST BODY -----\n\n")
1127 (idfp, status) = verify_request(request)
1128 (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
1129 revision = game_meta.get('R', 'unknown')
1130 duration = game_meta.get('D', None)
1132 # only players present at the end of the match are eligible for stats
1133 raw_players = filter(played_in_game, raw_players)
1135 do_precondition_checks(request, game_meta, raw_players)
1137 # the "duel" gametype is fake
1138 if len(raw_players) == 2 \
1139 and num_real_players(raw_players) == 2 \
1140 and game_meta['G'] == 'dm':
1141 game_meta['G'] = 'duel'
1143 #----------------------------------------------------------------------
1144 # Actual setup (inserts/updates) below here
1145 #----------------------------------------------------------------------
1146 session = DBSession()
1148 game_type_cd = game_meta['G']
1150 # All game types create Game, Server, Map, and Player records
1152 server = get_or_create_server(
1155 name = game_meta['S'],
1156 revision = revision,
1157 ip_addr = get_remote_addr(request),
1158 port = game_meta.get('U', None),
1159 impure_cvars = game_meta.get('C', 0))
1161 gmap = get_or_create_map(
1163 name = game_meta['M'])
1167 start_dt = datetime.datetime.utcnow(),
1168 server_id = server.server_id,
1169 game_type_cd = game_type_cd,
1170 map_id = gmap.map_id,
1171 match_id = game_meta['I'],
1172 duration = duration,
1173 mod = game_meta.get('O', None))
1175 # keep track of the players we've seen
1179 for events in raw_players:
1180 player = get_or_create_player(
1182 hashkey = events['P'],
1183 nick = events.get('n', None))
1185 pgstat = create_game_stat(session, game_meta, game, server,
1186 gmap, player, events)
1187 pgstats.append(pgstat)
1189 if player.player_id > 1:
1190 anticheats = create_anticheats(session, pgstat, game, player, events)
1192 if player.player_id > 2:
1193 player_ids.append(player.player_id)
1194 hashkeys[player.player_id] = events['P']
1196 if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
1197 pwstats = create_weapon_stats(session, game_meta, game, player,
1200 # store them on games for easy access
1201 game.players = player_ids
1203 for events in raw_teams:
1205 teamstat = create_team_stat(session, game, events)
1206 except Exception as e:
1209 if server.elo_ind and gametype_elo_eligible(game_type_cd):
1210 ep = EloProcessor(session, game, pgstats)
1214 log.debug('Success! Stats recorded.')
1216 # ranks are fetched after we've done the "real" processing
1217 ranks = get_ranks(session, player_ids, game_type_cd)
1219 # plain text response
1220 request.response.content_type = 'text/plain'
1223 "now" : calendar.timegm(datetime.datetime.utcnow().timetuple()),
1227 "player_ids" : player_ids,
1228 "hashkeys" : hashkeys,
1233 except Exception as e: