]> de.git.xonotic.org Git - xonotic/xonstat.git/blobdiff - xonstat/views/submission.py
Clean up the function signature a bit.
[xonotic/xonstat.git] / xonstat / views / submission.py
index 74e2d3c0e6a05c1206449d2203c1ec7b9c94e24d..9c2cd9ca9dd18a55870cd980a90426312195ed3c 100644 (file)
@@ -1,9 +1,10 @@
+import calendar
+import collections
 import datetime
 import logging
 import re
 
 import pyramid.httpexceptions
-import sqlalchemy.sql.expression as expr
 from sqlalchemy import Sequence
 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
 from xonstat.elo import EloProcessor
@@ -15,79 +16,253 @@ 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 = []
 
-    # we're not in either stanza to start
-    in_P = in_Q = False
+        # distinct weapons that we have seen fired
+        self.weapons = set()
 
-    for line in body.split('\n'):
+        # has a human player fired a shot?
+        self.human_fired_weapon = False
+
+        # does any human have a non-zero score?
+        self.human_nonzero_score = False
+
+        # does any human have a fastest cap?
+        self.human_fastest = False
+
+    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', '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(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 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)
@@ -104,40 +279,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
@@ -149,6 +308,7 @@ def is_supported_gametype(gametype, version):
             'cts',
             'dm',
             'dom',
+            'duel',
             'ft', 'freezetag',
             'ka', 'keepaway',
             'kh',
@@ -159,133 +319,87 @@ 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):
-    """
-    Determines if a given set of events correspond with a non-bot
-    """
-    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):
+def has_minimum_real_players(settings, submission):
     """
-    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.
+    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.
     """
-    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)
+    return len(submission.human_players) >= minimum_required_players
 
-    if real_players < minimum_required_players:
-        flg_has_min_real_players = False
 
-    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"
+        )
 
+    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"
+        )
 
-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 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"
+        )
+
+    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"
+        )
 
-    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
 
-    return flg_has_req_metadata
+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):
+def gametype_elo_eligible(game_type_cd):
     """True of the game type should process Elos. False otherwise."""
-    elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft')
-
-    if game_type_cd in elo_game_types:
-        return True
-    else:
-        return False
+    return game_type_cd in {'duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft'}
 
 
 def register_new_nick(session, player, new_nick):
@@ -349,139 +463,143 @@ 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]
 
@@ -490,27 +608,25 @@ def create_game(session, start_dt, game_type_cd, server_id, map_id,
     # 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
 
@@ -620,7 +736,7 @@ def create_default_game_stat(session, game_type_cd):
     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
@@ -766,7 +882,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 = []
 
@@ -774,7 +890,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...')
@@ -851,6 +966,48 @@ def get_ranks(session, player_ids, game_type_cd):
     return ranks
 
 
+def update_player(session, player, events):
+    pass
+
+
+def create_player(session, events):
+    pass
+
+
+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():
+                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])
+        players_by_hashkey[hashkey] = player
+
+    return players_by_hashkey
+
+
 def submit_stats(request):
     """
     Entry handler for POST stats submissions.
@@ -863,88 +1020,69 @@ def submit_stats(request):
                 "----- 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)
+        submission = Submission(request.body, request.headers)
 
-        # 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))
+            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}
+        get_or_create_players(session, game, gmap, events_by_hashkey)
 
         # keep track of the players we've seen
         player_ids = []
         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)
+        for events in submission.humans + submission.bots:
+            player = get_or_create_player(session, events['P'], events.get('n', None))
+            pgstat = create_game_stat(session, game, gmap, player, events)
             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']
 
-            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
         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):
+        if server.elo_ind and gametype_elo_eligible(submission.game_type_cd):
             ep = EloProcessor(session, game, pgstats)
             ep.save(session)
 
@@ -952,20 +1090,20 @@ def submit_stats(request):
         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,
+                "elos": ep.wip,
+                "ranks": ranks,
         }
 
     except Exception as e: