X-Git-Url: https://git.xonotic.org/?a=blobdiff_plain;f=xonstat%2Fviews%2Fsubmission.py;h=67edeb2514b592583fb16c130130d1a917a41337;hb=bb4aaf132675d5036688303c33cc6bfcf2f61f28;hp=95c83738df4384b438dbbca87532aec5a922d7d0;hpb=a6363be939c87af3074f8815068f8d3fc224d761;p=xonotic%2Fxonstat.git diff --git a/xonstat/views/submission.py b/xonstat/views/submission.py index 95c8373..67edeb2 100644 --- a/xonstat/views/submission.py +++ b/xonstat/views/submission.py @@ -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): @@ -165,7 +182,7 @@ 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) @@ -175,6 +192,36 @@ def register_new_nick(session, player, 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)) @@ -382,7 +436,11 @@ def create_player_game_stat(session=None, player=None, 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) @@ -394,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 @@ -512,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': @@ -550,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, @@ -569,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) @@ -592,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' @@ -615,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 @@ -654,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