]> git.xonotic.org Git - xonotic/xonstat.git/blobdiff - xonstat/views/submission.py
Redo submission handling.
[xonotic/xonstat.git] / xonstat / views / submission.py
index ab5bd336e8560e7af50254581ace691811b3d20b..67edeb2514b592583fb16c130130d1a917a41337 100644 (file)
@@ -31,7 +31,7 @@ def is_blank_game(players):
     for events in players:
         if is_real_player(events):
             for (key,value) in events.items():
-                if key == 'scoreboard-score' and value != '0':
+                if key == 'scoreboard-score' and value != 0:
                     flg_nonzero_score = True
                 if r.search(key):
                     flg_acc_events = True
@@ -48,12 +48,13 @@ def get_remote_addr(request):
 
 def is_supported_gametype(gametype):
     """Whether a gametype is supported or not"""
-    flg_supported = True
+    supported_game_types = ('duel', 'dm', 'ctf', 'tdm', 'kh',
+            'ka', 'ft', 'freezetag', 'nb', 'nexball')
 
-    if gametype == 'cts' or gametype == 'lms':
-        flg_supported = False
-
-    return flg_supported
+    if gametype in supported_game_types:
+        return True
+    else:
+        return False
 
 
 def verify_request(request):
@@ -71,7 +72,7 @@ def verify_request(request):
     return (idfp, status)
 
 
-def num_real_players(player_events, count_bots=False):
+def num_real_players(player_events):
     """
     Returns the number of real players (those who played 
     and are on the scoreboard).
@@ -79,7 +80,7 @@ def num_real_players(player_events, count_bots=False):
     real_players = 0
 
     for events in player_events:
-        if is_real_player(events, count_bots):
+        if is_real_player(events) and played_in_game(events):
             real_players += 1
 
     return real_players
@@ -107,6 +108,22 @@ def has_minimum_real_players(settings, player_events):
     return flg_has_min_real_players
 
 
+def verify_requests(settings):
+    """
+    Determines whether or not to verify requests using the blind_id algorithm
+    """
+    try:
+        val_verify_requests = settings['xonstat.verify_requests']
+        if val_verify_requests == "true":
+            flg_verify_requests = True
+        else:
+            flg_verify_requests = False
+    except:
+        flg_verify_requests = True
+
+    return flg_verify_requests
+
+
 def has_required_metadata(metadata):
     """
     Determines if a give set of metadata has enough data to create a game,
@@ -124,25 +141,25 @@ def has_required_metadata(metadata):
     return flg_has_req_metadata
 
 
-def is_real_player(events, count_bots=False):
+def is_real_player(events):
     """
-    Determines if a given set of player events correspond with a player who
+    Determines if a given set of events correspond with a non-bot
+    """
+    if not events['P'].startswith('bot'):
+        return True
+    else:
+        return False
 
-    1) is not a bot (P event does not look like a bot)
-    2) played in the game (matches 1)
-    3) was present at the end of the game (scoreboardvalid 1)
 
-    Returns True if the player meets the above conditions, and false otherwise.
+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)
     """
-    flg_is_real = False
-
-    # removing 'joins' here due to bug, but it should be here
     if 'matches' in events and 'scoreboardvalid' in events:
-        if (events['P'].startswith('bot') and count_bots) or \
-            not events['P'].startswith('bot'):
-            flg_is_real = True
-
-    return flg_is_real
+        return True
+    else:
+        return False
 
 
 def register_new_nick(session, player, new_nick):
@@ -155,7 +172,7 @@ def register_new_nick(session, player, new_nick):
     new_nick - the new nickname
     """
     # see if that nick already exists
-    stripped_nick = strip_colors(player.nick)
+    stripped_nick = strip_colors(qfont_decode(player.nick))
     try:
         player_nick = session.query(PlayerNick).filter_by(
             player_id=player.player_id, stripped_nick=stripped_nick).one()
@@ -165,16 +182,46 @@ def register_new_nick(session, player, new_nick):
         if not re.search('^Anonymous Player #\d+$', player.nick):
             player_nick = PlayerNick()
             player_nick.player_id = player.player_id
-            player_nick.stripped_nick = player.stripped_nick
+            player_nick.stripped_nick = stripped_nick
             player_nick.nick = player.nick
             session.add(player_nick)
 
     # We change to the new nick regardless
     player.nick = new_nick
-    player.stripped_nick = strip_colors(new_nick)
+    player.stripped_nick = strip_colors(qfont_decode(new_nick))
     session.add(player)
 
 
+def update_fastest_cap(session, player_id, game_id,  map_id, captime):
+    """
+    Check the fastest cap time for the player and map. If there isn't
+    one, insert one. If there is, check if the passed time is faster.
+    If so, update!
+    """
+    # we don't record fastest cap times for bots or anonymous players
+    if player_id <= 2:
+        return
+
+    # see if a cap entry exists already
+    # then check to see if the new captime is faster
+    try:
+        cur_fastest_cap = session.query(PlayerCaptime).filter_by(
+            player_id=player_id, map_id=map_id).one()
+
+        # current captime is faster, so update
+        if captime < cur_fastest_cap.fastest_cap:
+            cur_fastest_cap.fastest_cap = captime
+            cur_fastest_cap.game_id = game_id
+            cur_fastest_cap.create_dt = datetime.datetime.utcnow()
+            session.add(cur_fastest_cap)
+
+    except NoResultFound, e:
+        # none exists, so insert
+        cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime)
+        session.add(cur_fastest_cap)
+        session.flush()
+
+
 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
         revision=None):
     """
@@ -253,7 +300,8 @@ def get_or_create_map(session=None, name=None):
 
 
 def create_game(session=None, start_dt=None, game_type_cd=None, 
-        server_id=None, map_id=None, winner=None, match_id=None):
+        server_id=None, map_id=None, winner=None, match_id=None,
+        duration=None):
     """
     Creates a game. Parameters:
 
@@ -270,6 +318,11 @@ def create_game(session=None, start_dt=None, game_type_cd=None,
                 server_id=server_id, map_id=map_id, winner=winner)
     game.match_id = match_id
 
+    try:
+        game.duration = datetime.timedelta(seconds=int(round(float(duration))))
+    except:
+        pass
+
     try:
         session.query(Game).filter(Game.server_id==server_id).\
                 filter(Game.match_id==match_id).one()
@@ -282,6 +335,7 @@ def create_game(session=None, start_dt=None, game_type_cd=None,
     except NoResultFound, e:
         # 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))
@@ -324,7 +378,7 @@ def get_or_create_player(session=None, hashkey=None, nick=None):
             # with a suffix added for uniqueness.
             if nick:
                 player.nick = nick[:128]
-                player.stripped_nick = strip_colors(nick[:128])
+                player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
             else:
                 player.nick = "Anonymous Player #{0}".format(player.player_id)
                 player.stripped_nick = player.nick
@@ -361,8 +415,9 @@ def create_player_game_stat(session=None, player=None,
     #set game id from game record
     pgstat.game_id = game.game_id
 
-    # all games have a score
+    # all games have a score and every player has an alivetime
     pgstat.score = 0
+    pgstat.alivetime = datetime.timedelta(seconds=0)
 
     if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
         pgstat.kills = 0
@@ -377,9 +432,15 @@ def create_player_game_stat(session=None, player=None,
         pgstat.carrier_frags = 0
 
     for (key,value) in player_events.items():
-        if key == 'n': pgstat.nick = value[:128]
+        if key == 'n': 
+            pgstat.nick = value[:128]
+            pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
         if key == 't': pgstat.team = int(value)
-        if key == 'rank': pgstat.rank = int(value)
+        if key == 'rank': 
+            pgstat.rank = int(value)
+            # to support older servers who don't send scoreboardpos values
+            if pgstat.scoreboardpos is None:
+                pgstat.scoreboardpos = pgstat.rank
         if key == 'alivetime': 
             pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
         if key == 'scoreboard-drops': pgstat.drops = int(value)
@@ -391,6 +452,11 @@ def create_player_game_stat(session=None, player=None,
         if key == 'scoreboard-deaths': pgstat.deaths = int(value)
         if key == 'scoreboard-kills': pgstat.kills = int(value)
         if key == 'scoreboard-suicides': pgstat.suicides = int(value)
+        if key == 'scoreboard-captime':
+            pgstat.fastest_cap = datetime.timedelta(seconds=float(value)/100)
+        if key == 'avglatency': pgstat.avg_latency = float(value)
+        if key == 'teamrank': pgstat.teamrank = int(value)
+        if key == 'scoreboardpos': pgstat.scoreboardpos = int(value)
 
     # check to see if we had a name, and if
     # not use an anonymous handle
@@ -509,7 +575,7 @@ def parse_body(request):
             if key in 'S' 'n':
                 value = unicode(value, 'utf-8')
 
-            if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
+            if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I' 'D':
                 game_meta[key] = value
 
             if key == 'P':
@@ -547,7 +613,12 @@ def create_player_stats(session=None, player=None, game=None,
     pgstat = create_player_game_stat(session=session, 
         player=player, game=game, player_events=player_events)
 
-    #TODO: put this into a config setting in the ini file?
+    # fastest cap "upsert"
+    if game.game_type_cd == 'ctf' and pgstat.fastest_cap is not None:
+        update_fastest_cap(session, pgstat.player_id, game.game_id, 
+                game.map_id, pgstat.fastest_cap)
+
+    # bots don't get weapon stats. sorry, bots!
     if not re.search('^bot#\d+$', player_events['P']):
         create_player_weapon_stats(session=session, 
             player=player, game=game, pgstat=pgstat,
@@ -566,9 +637,10 @@ def stats_submit(request):
                 "----- END REQUEST BODY -----\n\n")
 
         (idfp, status) = verify_request(request)
-        if not idfp:
-            log.debug("ERROR: Unverified request")
-            raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
+        if verify_requests(request.registry.settings):
+            if not idfp:
+                log.debug("ERROR: Unverified request")
+                raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
 
         (game_meta, players) = parse_body(request)
 
@@ -589,7 +661,7 @@ def stats_submit(request):
             raise pyramid.httpexceptions.HTTPOk("OK")
 
         # the "duel" gametype is fake
-        if num_real_players(players, count_bots=True) == 2 and \
+        if num_real_players(players) == 2 and \
                 game_meta['G'] == 'dm':
             game_meta['G'] = 'duel'
 
@@ -612,14 +684,19 @@ def stats_submit(request):
 
         gmap = get_or_create_map(session=session, name=game_meta['M'])
 
-        # FIXME: use the gmtime instead of utcnow() when the timezone bug is
-        # fixed
+        # duration is optional
+        if 'D' in game_meta:
+            duration = game_meta['D']
+        else:
+            duration = None
+
         game = create_game(session=session, 
                 start_dt=datetime.datetime.utcnow(),
                 #start_dt=datetime.datetime(
                     #*time.gmtime(float(game_meta['T']))[:6]), 
                 server_id=server.server_id, game_type_cd=game_meta['G'], 
-                   map_id=gmap.map_id, match_id=game_meta['I'])
+                   map_id=gmap.map_id, match_id=game_meta['I'],
+                   duration=duration)
 
         # find or create a record for each player
         # and add stats for each if they were present at the end
@@ -651,3 +728,215 @@ def stats_submit(request):
         if session:
             session.rollback()
         return e
+
+
+def parse_stats_submission(body):
+    """
+    Parses the POST request body for a stats submission
+    """
+    # storage vars for the request body
+    game_meta = {}
+    events = {}
+    players ={}
+
+    for line in body.split('\n'):
+        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 in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I' 'D' 'O':
+                game_meta[key] = value
+
+            if key == 'P':
+                # if we were working on a player record already, append
+                # it and work on a new one (only set team info)
+                if len(events) > 0:
+                    players[events['P']] = events
+                    events = {}
+
+                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
+        except:
+            # no key/value pair - move on to the next line
+            pass
+
+    # add the last player we were working on
+    if len(events) > 0:
+        players[events['P']] = events
+
+    return (game_meta, players)
+
+
+def submit_stats(request):
+    """
+    Entry handler for POST stats submissions.
+    """
+    try:
+        # placeholder for the actual session
+        session = None
+
+        log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
+                "----- END REQUEST BODY -----\n\n")
+
+        (idfp, status) = verify_request(request)
+        if verify_requests(request.registry.settings):
+            if not idfp:
+                log.debug("ERROR: Unverified request")
+                raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
+
+        (game_meta, raw_players) = parse_stats_submission(request.body)
+
+        # only players present at the end of the match are eligible for stats
+        for rp in raw_players.values():
+            if not played_in_game(rp):
+                del raw_players[rp['P']]
+
+        revision = game_meta.get('R', 'unknown')
+        duration = game_meta.get('D', None)
+
+        #----------------------------------------------------------------------
+        # Precondition checks for ALL gametypes. These do not require a
+        # database connection.
+        #----------------------------------------------------------------------
+        if not is_supported_gametype(game_meta['G']):
+            log.debug("ERROR: Unsupported gametype")
+            raise pyramid.httpexceptions.HTTPOk("OK")
+
+        if not has_required_metadata(game_meta):
+            log.debug("ERROR: Required game meta missing")
+            raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta")
+
+        if not has_minimum_real_players(request.registry.settings, raw_players.values()):
+            log.debug("ERROR: Not enough real players")
+            raise pyramid.httpexceptions.HTTPOk("OK")
+
+        if is_blank_game(raw_players.values()):
+            log.debug("ERROR: Blank game")
+            raise pyramid.httpexceptions.HTTPOk("OK")
+
+        # the "duel" gametype is fake
+        if num_real_players(raw_players.values()) == 2 and game_meta['G'] == 'dm':
+            game_meta['G'] = 'duel'
+
+        #----------------------------------------------------------------------
+        # 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))
+
+        gmap = get_or_create_map(
+                session = session,
+                name    = game_meta['M'])
+
+        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)
+
+        players = {}
+        pgstats = {}
+        for events in raw_players.values():
+            player = get_or_create_player(
+                session = session,
+                hashkey = events['P'],
+                nick    = events.get('n', None))
+
+            pgstat = game_stats_handler(session, game_meta, game, server,
+                    gmap, player, events)
+
+            players[events['P']] = player
+            pgstats[events['P']] = pgstat
+
+        session.commit()
+        log.debug('Success! Stats recorded.')
+        return Response('200 OK')
+    except Exception as e:
+        raise e
+        if session:
+            session.rollback()
+        return e
+
+
+def game_stats_handler(session, game_meta, game, server, gmap, player, events):
+    """Game stats handler for all game types"""
+
+    # this is what we have to do to get partitioned records in - grab the
+    # sequence value first, then insert using the explicit ID (vs autogenerate)
+    seq = Sequence('player_game_stats_player_game_stat_id_seq')
+    pgstat_id = session.execute(seq)
+    pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
+            create_dt=datetime.datetime.utcnow())
+
+    # these fields should be on every pgstat record
+    pgstat.game_id       = game.game_id
+    pgstat.player_id     = player.player_id
+    pgstat.nick          = events.get('n', 'Anonymous Player')[:128]
+    log.debug(pgstat.nick)
+    pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
+    log.debug(qfont_decode(pgstat.nick))
+    log.debug(strip_colors(pgstat.nick))
+    pgstat.score         = int(events.get('scoreboard-score', 0))
+    pgstat.alivetime     = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
+    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
+    for (key,value) in events.items():
+        if key == 'wins': wins = True
+        if key == 't': pgstat.team = int(value)
+        if key == 'scoreboard-drops': pgstat.drops = int(value)
+        if key == 'scoreboard-returns': pgstat.returns = int(value)
+        if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
+        if key == 'scoreboard-pickups': pgstat.pickups = int(value)
+        if key == 'scoreboard-caps': pgstat.captures = int(value)
+        if key == 'scoreboard-score': pgstat.score = int(value)
+        if key == 'scoreboard-deaths': pgstat.deaths = int(value)
+        if key == 'scoreboard-kills': pgstat.kills = int(value)
+        if key == 'scoreboard-suicides': pgstat.suicides = int(value)
+        if key == 'avglatency': pgstat.avg_latency = float(value)
+
+        if key == 'scoreboard-captime':
+            pgstat.fastest_cap = datetime.timedelta(seconds=float(value)/100)
+            if game.game_type_cd == 'ctf':
+                update_fastest_cap(session, player.player_id, game.game_id,
+                        gmap.map_id, pgstat.fastest_cap)
+
+    # there is no "winning team" field, so we have to derive it
+    if wins and pgstat.team is not None and game.winner is None:
+        game.winner = pgstat.team
+        session.add(game)
+
+    session.add(pgstat)
+
+    return pgstat