X-Git-Url: https://de.git.xonotic.org/?p=xonotic%2Fxonstat.git;a=blobdiff_plain;f=xonstat%2Fviews%2Fsubmission.py;h=567d8a4777ba67eb3e6b3c48be0d32a8230e4b37;hp=f63fbd28d4063d1be43698026008a9260b930cf5;hb=HEAD;hpb=594417dc3d2a9aebf99ffbe510adfa80ae7dbc1d;ds=sidebyside diff --git a/xonstat/views/submission.py b/xonstat/views/submission.py index f63fbd2..567d8a4 100644 --- a/xonstat/views/submission.py +++ b/xonstat/views/submission.py @@ -1,95 +1,276 @@ +import calendar +import collections import datetime import logging -import os -import pyramid.httpexceptions import re -import time -import sqlalchemy.sql.expression as expr -from calendar import timegm -from pyramid.response import Response + +import pyramid.httpexceptions from sqlalchemy import Sequence -from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound +from sqlalchemy.orm.exc import NoResultFound from xonstat.elo import EloProcessor -from xonstat.models import * +from xonstat.models import DBSession, Server, Map, Game, PlayerGameStat, PlayerWeaponStat +from xonstat.models import PlayerRank, PlayerCaptime, PlayerGameFragMatrix +from xonstat.models import TeamGameStat, PlayerGameAnticheat, Player, Hashkey, PlayerNick from xonstat.util import strip_colors, qfont_decode, verify_request, weapon_map - log = logging.getLogger(__name__) -def parse_stats_submission(body): - """ - Parses the POST request body for a stats submission - """ - # storage vars for the request body - game_meta = {} - events = {} - players = [] - teams = [] +class Submission(object): + """Parses an incoming POST request for stats submissions.""" + + def __init__(self, body, headers): + # a copy of the HTTP headers + self.headers = headers + + # a copy of the HTTP POST body + self.body = body + + # the submission code version (from the server) + self.version = None + + # the revision string of the server + self.revision = None + + # the game type played + self.game_type_cd = None + + # the active game mod + self.mod = None + + # the name of the map played + self.map_name = None + + # unique identifier (string) for a match on a given server + self.match_id = None + + # the name of the server + self.server_name = None + + # the number of cvars that were changed to be different than default + self.impure_cvar_changes = None + + # the port number the game server is listening on + self.port_number = None + + # how long the game lasted + self.duration = None + + # which ladder is being used, if any + self.ladder = None + + # players involved in the match (humans, bots, and spectators) + self.players = [] + + # raw team events + self.teams = [] + + # the parsing deque (we use this to allow peeking) + self.q = collections.deque(self.body.split("\n")) + + ############################################################################################ + # Below this point are fields useful in determining if the submission is valid or + # performance optimizations that save us from looping over the events over and over again. + ############################################################################################ + + # humans who played in the match + self.humans = [] + + # bots who played in the match + self.bots = [] + + # player indexes for those who played + self.player_indexes = set() + + # distinct weapons that we have seen fired + self.weapons = set() - # we're not in either stanza to start - in_P = in_Q = False + # has a human player fired a shot? + self.human_fired_weapon = False - for line in body.split('\n'): + # does any human have a non-zero score? + self.human_nonzero_score = False + + # does any human have a fastest cap? + self.human_fastest = False + + self.parse() + + def next_item(self): + """Returns the next key:value pair off the queue.""" try: - (key, value) = line.strip().split(' ', 1) - - # Server (S) and Nick (n) fields can have international characters. - if key in 'S' 'n': - value = unicode(value, 'utf-8') - - if key not in 'P' 'Q' 'n' 'e' 't' 'i': - game_meta[key] = value - - if key == 'Q' or key == 'P': - #log.debug('Found a {0}'.format(key)) - #log.debug('in_Q: {0}'.format(in_Q)) - #log.debug('in_P: {0}'.format(in_P)) - #log.debug('events: {0}'.format(events)) - - # check where we were before and append events accordingly - if in_Q and len(events) > 0: - #log.debug('creating a team (Q) entry') - teams.append(events) - events = {} - elif in_P and len(events) > 0: - #log.debug('creating a player (P) entry') - players.append(events) - events = {} - - if key == 'P': - #log.debug('key == P') - in_P = True - in_Q = False - elif key == 'Q': - #log.debug('key == Q') - in_P = False - in_Q = True - - events[key] = value - - if key == 'e': - (subkey, subvalue) = value.split(' ', 1) - events[subkey] = subvalue - if key == 'n': - events[key] = value - if key == 't': - events[key] = value + items = self.q.popleft().strip().split(' ', 1) + if len(items) == 1: + # Some keys won't have values, like 'L' records where the server isn't actually + # participating in any ladders. These can be safely ignored. + return None, None + else: + return items except: - # no key/value pair - move on to the next line - pass - - # add the last entity we were working on - if in_P and len(events) > 0: - players.append(events) - elif in_Q and len(events) > 0: - teams.append(events) + return None, None + + def add_weapon_fired(self, sub_key): + """Adds a weapon to the set of weapons fired during the match (a set).""" + self.weapons.add(sub_key.split("-")[1]) + + @staticmethod + def is_human_player(player): + """ + Determines if a given set of events correspond with a non-bot + """ + return not player['P'].startswith('bot') + + @staticmethod + def played_in_game(player): + """ + Determines if a given set of player events correspond with a player who + played in the game (matches 1 and scoreboardvalid 1) + """ + return 'matches' in player and 'scoreboardvalid' in player + + def parse_player(self, key, pid): + """Construct a player events listing from the submission.""" + + # all of the keys related to player records + player_keys = ['i', 'n', 't', 'r', 'e'] + + player = {key: pid} + + player_fired_weapon = False + player_nonzero_score = False + player_fastest = False + + # Consume all following 'i' 'n' 't' 'e' records + while len(self.q) > 0: + (key, value) = self.next_item() + if key is None and value is None: + continue + elif key == 'e': + (sub_key, sub_value) = value.split(' ', 1) + player[sub_key] = sub_value + + if sub_key.endswith("cnt-fired"): + player_fired_weapon = True + self.add_weapon_fired(sub_key) + elif sub_key == 'scoreboard-score' and int(round(float(sub_value))) != 0: + player_nonzero_score = True + elif sub_key == 'scoreboard-fastest': + player_fastest = True + elif key == 'n': + player[key] = unicode(value, 'utf-8') + elif key in player_keys: + player[key] = value + else: + # something we didn't expect - put it back on the deque + self.q.appendleft("{} {}".format(key, value)) + break + + played = self.played_in_game(player) + human = self.is_human_player(player) + + if played: + self.player_indexes.add(int(player["i"])) + + if played and human: + self.humans.append(player) + + if player_fired_weapon: + self.human_fired_weapon = True + + if player_nonzero_score: + self.human_nonzero_score = True + + if player_fastest: + self.human_fastest = True + + elif played and not human: + self.bots.append(player) + + self.players.append(player) + + def parse_team(self, key, tid): + """Construct a team events listing from the submission.""" + team = {key: tid} + + # Consume all following 'e' records + while len(self.q) > 0 and self.q[0].startswith('e'): + (_, value) = self.next_item() + (sub_key, sub_value) = value.split(' ', 1) + team[sub_key] = sub_value + + self.teams.append(team) + + def parse(self): + """Parses the request body into instance variables.""" + while len(self.q) > 0: + (key, value) = self.next_item() + if key is None and value is None: + continue + elif key == 'V': + self.version = value + elif key == 'R': + self.revision = value + elif key == 'G': + self.game_type_cd = value + elif key == 'O': + self.mod = value + elif key == 'M': + self.map_name = value + elif key == 'I': + self.match_id = value + elif key == 'S': + self.server_name = unicode(value, 'utf-8') + elif key == 'C': + self.impure_cvar_changes = int(value) + elif key == 'U': + self.port_number = int(value) + elif key == 'D': + self.duration = datetime.timedelta(seconds=int(round(float(value)))) + elif key == 'L': + self.ladder = value + elif key == 'Q': + self.parse_team(key, value) + elif key == 'P': + self.parse_player(key, value) + else: + raise Exception("Invalid submission") + + return self + + def __repr__(self): + """Debugging representation of a submission.""" + return "game_type_cd: {}, mod: {}, players: {}, humans: {}, bots: {}, weapons: {}".format( + self.game_type_cd, self.mod, len(self.players), len(self.humans), len(self.bots), + self.weapons) + + +def elo_submission_category(submission): + """Determines the Elo category purely by what is in the submission data.""" + mod = submission.mod + + vanilla_allowed_weapons = {"shotgun", "devastator", "blaster", "mortar", "vortex", "electro", + "arc", "hagar", "crylink", "machinegun"} + insta_allowed_weapons = {"vaporizer", "blaster"} + overkill_allowed_weapons = {"hmg", "vortex", "shotgun", "blaster", "machinegun", "rpc"} + + if mod == "Xonotic": + if len(submission.weapons - vanilla_allowed_weapons) == 0: + return "vanilla" + elif mod == "InstaGib": + if len(submission.weapons - insta_allowed_weapons) == 0: + return "insta" + elif mod == "Overkill": + if len(submission.weapons - overkill_allowed_weapons) == 0: + return "overkill" + else: + return "general" - return (game_meta, players, teams) + return "general" -def is_blank_game(gametype, players): - """Determine if this is a blank game or not. A blank game is either: +def is_blank_game(submission): + """ + Determine if this is a blank game or not. A blank game is either: 1) a match that ended in the warmup stage, where accuracy events are not present (for non-CTS games) @@ -106,40 +287,24 @@ def is_blank_game(gametype, players): 1) a match in which no player made a positive or negative score """ - r = re.compile(r'acc-.*-cnt-fired') - flg_nonzero_score = False - flg_acc_events = False - flg_fastest_lap = False - - for events in players: - if is_real_player(events) and played_in_game(events): - for (key,value) in events.items(): - if key == 'scoreboard-score' and value != 0: - flg_nonzero_score = True - if r.search(key): - flg_acc_events = True - if key == 'scoreboard-fastest': - flg_fastest_lap = True - - if gametype == 'cts': - return not flg_fastest_lap - elif gametype == 'nb': - return not flg_nonzero_score + if submission.game_type_cd == 'cts': + return not submission.human_fastest + elif submission.game_type_cd == 'nb': + return not submission.human_nonzero_score else: - return not (flg_nonzero_score and flg_acc_events) + return not (submission.human_nonzero_score and submission.human_fired_weapon) -def get_remote_addr(request): - """Get the Xonotic server's IP address""" - if 'X-Forwarded-For' in request.headers: - return request.headers['X-Forwarded-For'] - else: - return request.remote_addr +def has_required_metadata(submission): + """Determines if a submission has all the required metadata fields.""" + return (submission.game_type_cd is not None + and submission.map_name is not None + and submission.match_id is not None + and submission.server_name is not None) -def is_supported_gametype(gametype, version): - """Whether a gametype is supported or not""" - is_supported = False +def is_supported_gametype(submission): + """Determines if a submission is of a valid and supported game type.""" # if the type can be supported, but with version constraints, uncomment # here and add the restriction for a specific version below @@ -151,6 +316,7 @@ def is_supported_gametype(gametype, version): 'cts', 'dm', 'dom', + 'duel', 'ft', 'freezetag', 'ka', 'keepaway', 'kh', @@ -161,133 +327,98 @@ def is_supported_gametype(gametype, version): 'tdm', ) - if gametype in supported_game_types: - is_supported = True - else: - is_supported = False + is_supported = submission.game_type_cd in supported_game_types # some game types were buggy before revisions, thus this additional filter - if gametype == 'ca' and version <= 5: + if submission.game_type_cd == 'ca' and submission.version <= 5: is_supported = False return is_supported -def do_precondition_checks(request, game_meta, raw_players): - """Precondition checks for ALL gametypes. - These do not require a database connection.""" - if not has_required_metadata(game_meta): - log.debug("ERROR: Required game meta missing") - raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta") - - try: - version = int(game_meta['V']) - except: - log.debug("ERROR: Required game meta invalid") - raise pyramid.httpexceptions.HTTPUnprocessableEntity("Invalid game meta") - - if not is_supported_gametype(game_meta['G'], version): - log.debug("ERROR: Unsupported gametype") - raise pyramid.httpexceptions.HTTPOk("OK") - - if not has_minimum_real_players(request.registry.settings, raw_players): - log.debug("ERROR: Not enough real players") - raise pyramid.httpexceptions.HTTPOk("OK") - - if is_blank_game(game_meta['G'], raw_players): - log.debug("ERROR: Blank game") - raise pyramid.httpexceptions.HTTPOk("OK") - - -def is_real_player(events): +def has_minimum_real_players(settings, submission): """ - Determines if a given set of events correspond with a non-bot + Determines if the submission has enough human players to store in the database. The minimum + setting comes from the config file under the setting xonstat.minimum_real_players. """ - if not events['P'].startswith('bot'): - return True - else: - return False - - -def played_in_game(events): - """ - Determines if a given set of player events correspond with a player who - played in the game (matches 1 and scoreboardvalid 1) - """ - if 'matches' in events and 'scoreboardvalid' in events: - return True - else: - return False - - -def num_real_players(player_events): - """ - Returns the number of real players (those who played - and are on the scoreboard). - """ - real_players = 0 - - for events in player_events: - if is_real_player(events) and played_in_game(events): - real_players += 1 - - return real_players - - -def has_minimum_real_players(settings, player_events): - """ - Determines if the collection of player events has enough "real" players - to store in the database. The minimum setting comes from the config file - under the setting xonstat.minimum_real_players. - """ - flg_has_min_real_players = True - try: - minimum_required_players = int( - settings['xonstat.minimum_required_players']) + minimum_required_players = int(settings.get("xonstat.minimum_required_players")) except: minimum_required_players = 2 - real_players = num_real_players(player_events) + # Make an exception for CTS since it can be done by individuals and there is no Elo anyway + if submission.game_type_cd == "cts": + minimum_required_players = 1 - if real_players < minimum_required_players: - flg_has_min_real_players = False + return len(submission.humans) >= minimum_required_players - return flg_has_min_real_players +def do_precondition_checks(settings, submission): + """Precondition checks for ALL gametypes. These do not require a database connection.""" + if not has_required_metadata(submission): + msg = "Missing required game metadata" + log.debug(msg) + raise pyramid.httpexceptions.HTTPUnprocessableEntity( + body=msg, + content_type="text/plain" + ) -def has_required_metadata(metadata): - """ - Determines if a give set of metadata has enough data to create a game, - server, and map with. - """ - flg_has_req_metadata = True + if submission.version is None: + msg = "Invalid or incorrect game metadata provided" + log.debug(msg) + raise pyramid.httpexceptions.HTTPUnprocessableEntity( + body=msg, + content_type="text/plain" + ) - if 'G' not in metadata or\ - 'M' not in metadata or\ - 'I' not in metadata or\ - 'S' not in metadata: - flg_has_req_metadata = False + if not is_supported_gametype(submission): + msg = "Unsupported game type ({})".format(submission.game_type_cd) + log.debug(msg) + raise pyramid.httpexceptions.HTTPOk( + body=msg, + content_type="text/plain" + ) - return flg_has_req_metadata + if not has_minimum_real_players(settings, submission): + msg = "Not enough real players" + log.debug(msg) + raise pyramid.httpexceptions.HTTPOk( + body=msg, + content_type="text/plain" + ) + + if is_blank_game(submission): + msg = "Blank game" + log.debug(msg) + raise pyramid.httpexceptions.HTTPOk( + body=msg, + content_type="text/plain" + ) + + +def get_remote_addr(request): + """Get the Xonotic server's IP address""" + if 'X-Forwarded-For' in request.headers: + return request.headers['X-Forwarded-For'] + else: + return request.remote_addr def should_do_weapon_stats(game_type_cd): """True of the game type should record weapon stats. False otherwise.""" - if game_type_cd in 'cts': - return False - else: - return True + return game_type_cd not in {'cts'} -def should_do_elos(game_type_cd): - """True of the game type should process Elos. False otherwise.""" - elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft') +def should_do_frag_matrix(game_type_cd): + """True if the game type should record frag matrix values. False otherwise.""" + return game_type_cd in { + 'as', 'ca', 'ctf', 'dm', 'dom', 'ft', 'freezetag', 'ka', 'kh', 'rune', 'tdm', + } - if game_type_cd in elo_game_types: - return True - else: - return False + +def gametype_elo_eligible(game_type_cd): + """True of the game type should process Elos. False otherwise.""" + return game_type_cd in {'duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft'} def register_new_nick(session, player, new_nick): @@ -351,168 +482,170 @@ def update_fastest_cap(session, player_id, game_id, map_id, captime, mod): session.flush() -def get_or_create_server(session, name, hashkey, ip_addr, revision, port, - impure_cvars): +def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars): """ - Find a server by name or create one if not found. Parameters: - - session - SQLAlchemy database session factory - name - server name of the server to be found or created - hashkey - server hashkey - ip_addr - the IP address of the server - revision - the xonotic revision number - port - the port number of the server - impure_cvars - the number of impure cvar changes + Updates the server in the given DB session, if needed. + + :param server: The found server instance. + :param name: The incoming server name. + :param hashkey: The incoming server hashkey. + :param ip_addr: The incoming server IP address. + :param port: The incoming server port. + :param revision: The incoming server revision. + :param impure_cvars: The incoming number of impure server cvars. + :return: bool """ - server = None - + # ensure the two int attributes are actually ints try: port = int(port) except: port = None - try: + try: impure_cvars = int(impure_cvars) except: impure_cvars = 0 - # finding by hashkey is preferred, but if not we will fall - # back to using name only, which can result in dupes - if hashkey is not None: - servers = session.query(Server).\ - filter_by(hashkey=hashkey).\ - order_by(expr.desc(Server.create_dt)).limit(1).all() - - if len(servers) > 0: - server = servers[0] - log.debug("Found existing server {0} by hashkey ({1})".format( - server.server_id, server.hashkey)) - else: - servers = session.query(Server).\ - filter_by(name=name).\ - order_by(expr.desc(Server.create_dt)).limit(1).all() + updated = False + if name and server.name != name: + server.name = name + updated = True + if hashkey and server.hashkey != hashkey: + server.hashkey = hashkey + updated = True + if ip_addr and server.ip_addr != ip_addr: + server.ip_addr = ip_addr + updated = True + if port and server.port != port: + server.port = port + updated = True + if revision and server.revision != revision: + server.revision = revision + updated = True + if impure_cvars and server.impure_cvars != impure_cvars: + server.impure_cvars = impure_cvars + server.pure_ind = True if impure_cvars == 0 else False + updated = True - if len(servers) > 0: - server = servers[0] - log.debug("Found existing server {0} by name".format(server.server_id)) + return updated - # still haven't found a server by hashkey or name, so we need to create one - if server is None: - server = Server(name=name, hashkey=hashkey) - session.add(server) - session.flush() - log.debug("Created server {0} with hashkey {1}".format( - server.server_id, server.hashkey)) - # detect changed fields - if server.name != name: - server.name = name - session.add(server) +def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars): + """ + Find a server by name or create one if not found. Parameters: - if server.hashkey != hashkey: - server.hashkey = hashkey - session.add(server) + session - SQLAlchemy database session factory + name - server name of the server to be found or created + hashkey - server hashkey + ip_addr - the IP address of the server + revision - the xonotic revision number + port - the port number of the server + impure_cvars - the number of impure cvar changes + """ + servers_q = DBSession.query(Server).filter(Server.active_ind) - if server.ip_addr != ip_addr: - server.ip_addr = ip_addr - session.add(server) + if hashkey: + # if the hashkey is provided, we'll use that + servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey)) + else: + # otherwise, it is just by name + servers_q = servers_q.filter(Server.name == name) - if server.port != port: - server.port = port - session.add(server) + # order by the hashkey, which means any hashkey match will appear first if there are multiple + servers = servers_q.order_by(Server.hashkey, Server.create_dt).all() - if server.revision != revision: - server.revision = revision + if len(servers) == 0: + server = Server(name=name, hashkey=hashkey) session.add(server) + session.flush() + log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey)) + else: + server = servers[0] + if len(servers) == 1: + log.info("Found existing server {}.".format(server.server_id)) - if server.impure_cvars != impure_cvars: - server.impure_cvars = impure_cvars - if impure_cvars > 0: - server.pure_ind = False - else: - server.pure_ind = True + elif len(servers) > 1: + server_id_list = ", ".join(["{}".format(s.server_id) for s in servers]) + log.warn("Multiple servers found ({})! Using the first one ({})." + .format(server_id_list, server.server_id)) + + if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars): session.add(server) return server -def get_or_create_map(session=None, name=None): +def get_or_create_map(session, name): """ Find a map by name or create one if not found. Parameters: session - SQLAlchemy database session factory name - map name of the map to be found or created """ - try: - # find one by the name, if it exists - gmap = session.query(Map).filter_by(name=name).one() - log.debug("Found map id {0}: {1}".format(gmap.map_id, - gmap.name)) - except NoResultFound, e: + maps = session.query(Map).filter_by(name=name).order_by(Map.map_id).all() + + if maps is None or len(maps) == 0: gmap = Map(name=name) session.add(gmap) session.flush() - log.debug("Created map id {0}: {1}".format(gmap.map_id, - gmap.name)) - except MultipleResultsFound, e: - # multiple found, so use the first one but warn - log.debug(e) - gmaps = session.query(Map).filter_by(name=name).order_by( - Map.map_id).all() - gmap = gmaps[0] - log.debug("Found map id {0}: {1} but found \ - multiple".format(gmap.map_id, gmap.name)) + log.debug("Created map id {}: {}".format(gmap.map_id, gmap.name)) + elif len(maps) == 1: + gmap = maps[0] + log.debug("Found map id {}: {}".format(gmap.map_id, gmap.name)) + else: + gmap = maps[0] + map_id_list = ", ".join(["{}".format(m.map_id) for m in maps]) + log.warn("Multiple maps found for {} ({})! Using the first one.".format(name, map_id_list)) return gmap -def create_game(session, start_dt, game_type_cd, server_id, map_id, - match_id, duration, mod, winner=None): +def create_game(session, game_type_cd, server_id, map_id, match_id, start_dt, duration, mod, + winner=None): """ Creates a game. Parameters: session - SQLAlchemy database session factory - start_dt - when the game started (datetime object) game_type_cd - the game type of the game being played + mod - mods in use during the game server_id - server identifier of the server hosting the game map_id - map on which the game was played - winner - the team id of the team that won + match_id - a unique match ID given by the server + start_dt - when the game started (datetime object) duration - how long the game lasted - mod - mods in use during the game + winner - the team id of the team that won """ seq = Sequence('games_game_id_seq') game_id = session.execute(seq) - game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd, - server_id=server_id, map_id=map_id, winner=winner) + game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd, server_id=server_id, + map_id=map_id, winner=winner) game.match_id = match_id game.mod = mod[:64] # There is some drift between start_dt (provided by app) and create_dt - # (default in the database), so we'll make them the same until this is + # (default in the database), so we'll make them the same until this is # resolved. game.create_dt = start_dt - try: - game.duration = datetime.timedelta(seconds=int(round(float(duration)))) - except: - pass + game.duration = duration try: - session.query(Game).filter(Game.server_id==server_id).\ - filter(Game.match_id==match_id).one() + session.query(Game).filter(Game.server_id == server_id)\ + .filter(Game.match_id == match_id).one() log.debug("Error: game with same server and match_id found! Ignoring.") - # if a game under the same server and match_id found, - # this is a duplicate game and can be ignored - raise pyramid.httpexceptions.HTTPOk('OK') - except NoResultFound, e: + # if a game under the same server_id and match_id exists, this is a duplicate + msg = "Duplicate game (pre-existing match_id)" + log.debug(msg) + raise pyramid.httpexceptions.HTTPOk(body=msg, content_type="text/plain") + + except NoResultFound: # server_id/match_id combination not found. game is ok to insert session.add(game) session.flush() - log.debug("Created game id {0} on server {1}, map {2} at \ - {3}".format(game.game_id, - server_id, map_id, start_dt)) + log.debug("Created game id {} on server {}, map {} at {}" + .format(game.game_id, server_id, map_id, start_dt)) return game @@ -581,10 +714,6 @@ def create_default_game_stat(session, game_type_cd): if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm': pgstat.kills = pgstat.deaths = pgstat.suicides = 0 - if game_type_cd == 'cq': - pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0 - pgstat.drops = 0 - if game_type_cd == 'ctf': pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0 pgstat.returns = pgstat.carrier_frags = 0 @@ -609,20 +738,14 @@ def create_default_game_stat(session, game_type_cd): pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0 pgstat.carrier_frags = 0 - if game_type_cd == 'lms': - pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0 - if game_type_cd == 'nb': pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0 pgstat.drops = 0 - if game_type_cd == 'rc': - pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0 - return pgstat -def create_game_stat(session, game_meta, game, server, gmap, player, events): +def create_game_stat(session, game, gmap, player, events): """Game stats handler for all game types""" game_type_cd = game.game_type_cd @@ -639,11 +762,6 @@ def create_game_stat(session, game_meta, game, server, gmap, player, events): pgstat.rank = int(events.get('rank', None)) pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank)) - if pgstat.nick != player.nick \ - and player.player_id > 2 \ - and pgstat.nick != 'Anonymous Player': - register_new_nick(session, player, pgstat.nick) - wins = False # gametype-specific stuff is handled here. if passed to us, we store it @@ -733,7 +851,7 @@ def create_default_team_stat(session, game_type_cd): # all team game modes have a score, so we'll zero that out always teamstat.score = 0 - if game_type_cd in 'ca' 'ft' 'lms' 'ka': + if game_type_cd in 'ca' 'ft' 'ka': teamstat.rounds = 0 if game_type_cd == 'ctf': @@ -768,7 +886,7 @@ def create_team_stat(session, game, events): return teamstat -def create_weapon_stats(session, game_meta, game, player, pgstat, events): +def create_weapon_stats(session, version, game, player, pgstat, events): """Weapon stats handler for all game types""" pwstats = [] @@ -776,7 +894,6 @@ def create_weapon_stats(session, game_meta, game, player, pgstat, events): # To counteract this we divide the data by 2 only for # POSTs coming from version 1. try: - version = int(game_meta['V']) if version == 1: is_doubled = True log.debug('NOTICE: found a version 1 request, halving the weapon stats...') @@ -853,121 +970,233 @@ def get_ranks(session, player_ids, game_type_cd): return ranks +def update_player(session, player, events): + """ + Updates a player record using the latest information. + :param session: SQLAlchemy session + :param player: Player model representing what is in the database right now (before updates) + :param events: Dict of player events from the submission + :return: player + """ + nick = events.get('n', 'Anonymous Player')[:128] + if nick != player.nick and not nick.startswith("Anonymous Player"): + register_new_nick(session, player, nick) + + return player + + +def create_player(session, events): + """ + Creates a new player from the list of events. + :param session: SQLAlchemy session + :param events: Dict of player events from the submission + :return: Player + """ + player = Player() + session.add(player) + session.flush() + + nick = events.get('n', None) + if nick: + player.nick = nick[:128] + player.stripped_nick = strip_colors(qfont_decode(player.nick)) + else: + player.nick = "Anonymous Player #{0}".format(player.player_id) + player.stripped_nick = player.nick + + hk = Hashkey(player_id=player.player_id, hashkey=events.get('P', None)) + session.add(hk) + + return player + + +def get_or_create_players(session, events_by_hashkey): + hashkeys = set(events_by_hashkey.keys()) + players_by_hashkey = {} + + bot = session.query(Player).filter(Player.player_id == 1).one() + anon = session.query(Player).filter(Player.player_id == 2).one() + + # fill in the bots and anonymous players + for hashkey in events_by_hashkey.keys(): + if hashkey.startswith("bot#"): + players_by_hashkey[hashkey] = bot + hashkeys.remove(hashkey) + elif hashkey.startswith("player#"): + players_by_hashkey[hashkey] = anon + hashkeys.remove(hashkey) + + # We are left with the "real" players and can now fetch them by their collective hashkeys. + # Those that are returned here are pre-existing players who need to be updated. + for p, hk in session.query(Player, Hashkey)\ + .filter(Player.player_id == Hashkey.player_id)\ + .filter(Hashkey.hashkey.in_(hashkeys))\ + .all(): + log.debug("Found existing player {} with hashkey {}" + .format(p.player_id, hk.hashkey)) + + player = update_player(session, p, events_by_hashkey[hk.hashkey]) + players_by_hashkey[hk.hashkey] = player + hashkeys.remove(hk.hashkey) + + # The remainder are the players we haven't seen before, so we need to create them. + for hashkey in hashkeys: + player = create_player(session, events_by_hashkey[hashkey]) + + log.debug("Created player {0} ({2}) with hashkey {1}" + .format(player.player_id, hashkey, player.nick.encode('utf-8'))) + + players_by_hashkey[hashkey] = player + + return players_by_hashkey + + +def create_frag_matrix(session, player_indexes, pgstat, events): + """ + Construct a PlayerFragMatrix object from the events of a given player. + + :param session: The DBSession we're adding objects to. + :param player_indexes: The set of player indexes of those that actually played in the game. + :param pgstat: The PlayerGameStat object of the player whose frag matrix we want to create. + :param events: The raw player events of the above player. + :return: PlayerFragMatrix + """ + player_index = int(events.get("i", None)) + + # "kills-4" -> 4 + victim_index = lambda x: int(x.split("-")[1]) + + matrix = {victim_index(k): int(v) for (k, v) in events.items() + if k.startswith("kills-") and victim_index(k) in player_indexes} + + if len(matrix) > 0: + pfm = PlayerGameFragMatrix(pgstat.game_id, pgstat.player_game_stat_id, pgstat.player_id, + player_index, matrix) + + session.add(pfm) + return pfm + else: + return None + + def submit_stats(request): """ Entry handler for POST stats submissions. """ - try: - # placeholder for the actual session - session = None + # placeholder for the actual session + session = None + try: log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body + - "----- END REQUEST BODY -----\n\n") + "----- END REQUEST BODY -----\n\n") (idfp, status) = verify_request(request) - (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body) - revision = game_meta.get('R', 'unknown') - duration = game_meta.get('D', None) - - # only players present at the end of the match are eligible for stats - raw_players = filter(played_in_game, raw_players) - - do_precondition_checks(request, game_meta, raw_players) + try: + submission = Submission(request.body, request.headers) + except: + msg = "Invalid submission" + log.debug(msg) + raise pyramid.httpexceptions.HTTPUnprocessableEntity( + body=msg, + content_type="text/plain" + ) - # the "duel" gametype is fake - if len(raw_players) == 2 \ - and num_real_players(raw_players) == 2 \ - and game_meta['G'] == 'dm': - game_meta['G'] = 'duel' + do_precondition_checks(request.registry.settings, submission) - #---------------------------------------------------------------------- + ####################################################################### # Actual setup (inserts/updates) below here - #---------------------------------------------------------------------- + ####################################################################### session = DBSession() - game_type_cd = game_meta['G'] - # All game types create Game, Server, Map, and Player records # the same way. server = get_or_create_server( - session = session, - hashkey = idfp, - name = game_meta['S'], - revision = revision, - ip_addr = get_remote_addr(request), - port = game_meta.get('U', None), - impure_cvars = game_meta.get('C', 0)) - - gmap = get_or_create_map( - session = session, - name = game_meta['M']) + session=session, + hashkey=idfp, + name=submission.server_name, + revision=submission.revision, + ip_addr=get_remote_addr(request), + port=submission.port_number, + impure_cvars=submission.impure_cvar_changes + ) + + gmap = get_or_create_map(session, submission.map_name) game = create_game( - session = session, - start_dt = datetime.datetime.utcnow(), - server_id = server.server_id, - game_type_cd = game_type_cd, - map_id = gmap.map_id, - match_id = game_meta['I'], - duration = duration, - mod = game_meta.get('O', None)) - - # keep track of the players we've seen - player_ids = [] + session=session, + game_type_cd=submission.game_type_cd, + mod=submission.mod, + server_id=server.server_id, + map_id=gmap.map_id, + match_id=submission.match_id, + start_dt=datetime.datetime.utcnow(), + duration=submission.duration + ) + + events_by_hashkey = {elem["P"]: elem for elem in submission.humans + submission.bots} + players_by_hashkey = get_or_create_players(session, events_by_hashkey) + pgstats = [] - hashkeys = {} - for events in raw_players: - player = get_or_create_player( - session = session, - hashkey = events['P'], - nick = events.get('n', None)) - - pgstat = create_game_stat(session, game_meta, game, server, - gmap, player, events) + elo_pgstats = [] + player_ids = [] + hashkeys_by_player_id = {} + for hashkey, player in players_by_hashkey.items(): + events = events_by_hashkey[hashkey] + + pgstat = create_game_stat(session, game, gmap, player, events) pgstats.append(pgstat) + if should_do_frag_matrix(submission.game_type_cd): + create_frag_matrix(session, submission.player_indexes, pgstat, events) + + # player ranking opt-out + if 'r' in events and events['r'] == '0': + log.debug("Excluding player {} from ranking calculations (opt-out)" + .format(pgstat.player_id)) + else: + elo_pgstats.append(pgstat) + if player.player_id > 1: - anticheats = create_anticheats(session, pgstat, game, player, events) + create_anticheats(session, pgstat, game, player, events) if player.player_id > 2: player_ids.append(player.player_id) - hashkeys[player.player_id] = events['P'] + hashkeys_by_player_id[player.player_id] = hashkey - if should_do_weapon_stats(game_type_cd) and player.player_id > 1: - pwstats = create_weapon_stats(session, game_meta, game, player, - pgstat, events) + if should_do_weapon_stats(submission.game_type_cd) and player.player_id > 1: + create_weapon_stats(session, submission.version, game, player, pgstat, events) - # store them on games for easy access + # player_ids for human players get stored directly on games for fast indexing game.players = player_ids - for events in raw_teams: - try: - teamstat = create_team_stat(session, game, events) - except Exception as e: - raise e + for events in submission.teams: + create_team_stat(session, game, events) - if should_do_elos(game_type_cd): - ep = EloProcessor(session, game, pgstats) + if server.elo_ind and gametype_elo_eligible(submission.game_type_cd): + ep = EloProcessor(session, game, elo_pgstats) ep.save(session) + elos = ep.wip + else: + elos = {} session.commit() log.debug('Success! Stats recorded.') # ranks are fetched after we've done the "real" processing - ranks = get_ranks(session, player_ids, game_type_cd) + ranks = get_ranks(session, player_ids, submission.game_type_cd) # plain text response request.response.content_type = 'text/plain' return { - "now" : timegm(datetime.datetime.utcnow().timetuple()), - "server" : server, - "game" : game, - "gmap" : gmap, - "player_ids" : player_ids, - "hashkeys" : hashkeys, - "elos" : ep.wip, - "ranks" : ranks, + "now": calendar.timegm(datetime.datetime.utcnow().timetuple()), + "server": server, + "game": game, + "gmap": gmap, + "player_ids": player_ids, + "hashkeys": hashkeys_by_player_id, + "elos": elos, + "ranks": ranks, } except Exception as e: