import models import player import config import playerstat import wrap import ratingjob import serverinfo import playerdetail import json # import logging import time import traceback import StringIO from google.appengine.ext import db """ AddGameStat json: { "server_id": , "server_start": , "gameid": , "packid_id": , "packid_hash": "", "title": "" "filename": "" "game_speed": , "min_players": , "max_players": , "start_utc": , "end_utc": , "player_stats": [ { "name": "", "pid": , "ip": "", "did": "", "platform": "", "winstats": { "side_mask": , "side_mask_allies": , "credits_acquired": , "credits_consumed": , "enemy_munts_killed": , "enemy_structs_killed": , "munts_lost": , "structs_lost": , "unit_counts": [ ], "built_counts": [ ], "ff": } } ] } """ # 0: Security Guard # 1: Rocket Trooper # 2: Corporate Raider # 3: SR-98 Eagle # 4: T-29 Broadsword # 5: M-18 Hydra # 6: T-33 Liberator # 7: G-4 Bullpup # 8: H-7 Dominion # 9: Power Generator # 10: Galaxite Processor # 11: Galaxite Storage Warehouse # 12: Human Resource Center # 13: Vehicle Transport Station # 14: Surveillance Center # 15: Research & Development Center # 16: Headquarters # 17: Gatling Tower # 18: Rocket Tower # 19: Andy # 20: A-3 Cyclops # 21: Replicator # 22: Fox GAME_RESULT_SCOREABLE = 0 GAME_RESULT_TOO_SHORT = 1 GAME_RESULT_NEED_REGISTERED_OPPONENTS = 2 GAME_RESULT_NO_WINNERS = 3 PLAYER_RESULT_NONE = 0 PLAYER_RESULT_LOSE = 1 PLAYER_RESULT_WIN = 2 kfwsReceivedStats = 0x0001 kfwsWinner = 0x0002 kfwsLoser = 0x0004 kfwsWinChallenger = 0x0008 kfwsComputer = 0x0010 kfwsHuman = 0x0020 kfwsAnonymous = 0x0040 kfwsLocked = 0x0080 kfwsRemovedAtGameStart = 0x0100 kfwsMask = 0x1ff COMPUTER_DEFAULT_RATING = 1200 ANONYMOUS_DEFAULT_RATING = 1300 PLAYER_DEFAULT_RATING = 1500 DURATION_SECS_MINIMUM = 60 * 3 class GameStats(wrap.DictWrap): def __init__(self, j, viewing_player_name='', load_players=False): super(GameStats, self).__init__(json.loads(j)) self.json = j self.viewing_player_name = viewing_player_name # Prune out non-contributing players self.prune_zombie_players() # Calc results self.init_win_results() # Initialize PlayerStats objects self.init_player_stats() # Create pid->player_stat map self.pid_player_stat_map = {} for player_stat in self.player_stats: self.pid_player_stat_map[player_stat.pid] = player_stat # Create key->pid map self.key_pid_map = {} for player_stat in self.player_stats: if player_stat.is_user: key = models.playermodel_key(player_stat.name) self.key_pid_map[key] = player_stat.pid # If asked, load and push Player objects into PlayerStat objects if load_players: self.load_player_objects() # Remember teams self.teams = self.get_teams() def init_player_stats(self): # Replace player_stats with PlayerStats objects and sort by side player_stats = [] for player_stat in self.player_stats: player_stat = playerstat.PlayerStat(player_stat, self.pid_win_result_map[player_stat.pid]) player_stats.append(player_stat) player_stats.sort(lambda x, y: cmp(x.side, y.side)) self.player_stats = player_stats def load_player_objects(self): # Get all the player models at once keys = self.key_pid_map.keys() objs = models.PlayerModel.get(keys) # Append player objects to player_stat objects for i in xrange(len(keys)): pid = self.key_pid_map[keys[i]] player_stat = self.pid_player_stat_map[pid] player_stat.player = player.Player(objs[i], self.viewing_player_name) def prune_zombie_players(self): # Remove players that had no contribution. This removes computer # players that were allocated but never used. i = 0 while i < len(self.player_stats): if sum(self.player_stats[i].winstats.built_counts) == 0: self.player_stats.pop(i) continue if self.player_stats[i].winstats.ff & kfwsRemovedAtGameStart: self.player_stats.pop(i) continue i += 1 def get_nonanonymous_human_count(self): humans = 0 for team in self.teams: for player_stat in team: if (player_stat.winstats.ff & kfwsComputer): continue if (player_stat.winstats.ff & kfwsAnonymous): continue if (player_stat.winstats.ff & kfwsHuman): humans += 1 return humans def get_game_result(self): # Games must run for a minimum time to be scored elapsed_secs = self.end_utc - self.start_utc if elapsed_secs < DURATION_SECS_MINIMUM: return GAME_RESULT_TOO_SHORT; # There must be at least one winner winner_tids = self.get_winner_tids() if len(winner_tids) == 0: return GAME_RESULT_NO_WINNERS winners_registered = False for tid in winner_tids: for player_stat in self.teams[tid]: if player_stat.winstats.ff & (kfwsComputer | kfwsAnonymous): continue winners_registered = True losers_registered = False for tid in xrange(len(self.teams)): if winner_tids.__contains__(tid): continue for player_stat in self.teams[tid]: if player_stat.winstats.ff & (kfwsComputer | kfwsAnonymous): continue losers_registered = True # For a registered player to win, there must be a registered loser too, # and vice versa. if not winners_registered or not losers_registered: return GAME_RESULT_NEED_REGISTERED_OPPONENTS return GAME_RESULT_SCOREABLE def init_win_results(self): self.pid_win_result_map = {} winner_tids = self.get_winner_tids() for player_stat in self.player_stats: tid = self.find_tid(player_stat.pid) if winner_tids.__contains__(tid): self.pid_win_result_map[player_stat.pid] = PLAYER_RESULT_WIN else: self.pid_win_result_map[player_stat.pid] = PLAYER_RESULT_LOSE def get_winner_tids(self): # Players on the same team as winners are winners too winner_tids = [] for tid in xrange(len(self.teams)): for player_stat in self.teams[tid]: if player_stat.winstats.ff & kfwsWinner: winner_tids.append(tid) break return winner_tids def get_winner_count(self): count = 0 for value in self.pid_win_result_map.values(): if value == PLAYER_RESULT_WIN: count += 1 return count def get_first_winner_color(self): for pid in self.pid_win_result_map.keys(): if self.pid_win_result_map[pid] == PLAYER_RESULT_WIN: return self.pid_player_stat_map[pid].get_side_color() return 'black' def find_tid(self, pid): for tid in xrange(len(self.teams)): for player_stat in self.teams[tid]: if player_stat.pid == pid: return tid return -1 def get_duration_minutes(self): return '%.2f' % ((self.end_utc - self.start_utc) / 60.0) def get_duration_seconds(self): return self.end_utc - self.start_utc def get_game_speed_multiplier(self): n = 0 if self.game_speed != 0: n = (8 * 100) / self.game_speed return '%d.%dx' % (n / 100, n % 100) def get_teams(self): # Return a list of player_stat tuples, each tuple representing a team. # Most of the time these tuples will have one player in them each, # but if there are allies, there may be more. Only check for ally # equality, to prune out enemies being on the same team. # Calc unique masks. unique_masks = [] for player_stat in self.player_stats: ally_mask = player_stat.winstats.side_mask_allies if not unique_masks.__contains__(ally_mask): unique_masks.append(ally_mask) # Calc team tuples (of player_stats) teams = [] for mask in unique_masks: team = [] for player_stat in self.player_stats: ally_mask = player_stat.winstats.side_mask_allies if ally_mask == mask: team.append(player_stat) teams.append(tuple(team)) return teams def get_has_multiplayer_teams(self): return len(self.teams) != len(self.player_stats) def get_game_key(self): return models.gamestatsmodel_key(self.server_id, self.server_start, self.gameid) def get_game_key_name(self): return self.get_game_key().name() def get_detail_url(self): url = '%s?g=%s' % (config.GAMEDETAIL_URL, self.get_game_key_name()) if self.viewing_player_name: url = '%s&p=%s' % (url, self.viewing_player_name) return url def get_team_ratings(self, win_shares): # Players with usernames have default ratings. Anonymous users # have a lower rating, so users prefer to play real users. # Computer players have an even lower rating. # Team rating is a weighted total based on kill contribution r = {} for tid in xrange(len(self.teams)): team_rating = 0 for player_stat in self.teams[tid]: # Win share for this player. If not present, # this player had no kill contribution win_share = win_shares[player_stat.pid] # Add up weighted rating if player_stat.is_computer: team_rating += COMPUTER_DEFAULT_RATING * win_share continue if player_stat.is_anonymous: team_rating += ANONYMOUS_DEFAULT_RATING * win_share continue rating = player_stat.player.rating if rating == 0: team_rating += PLAYER_DEFAULT_RATING * win_share else: team_rating += rating * win_share r[tid] = team_rating # logging.info('+++ team: %d rating: %d' % (tid, team_rating)) return r def calc_win_share(self, team, pid, winner_team): # A player's win share on a team is the % of units killed by that # player. stat_map = {} total_killed = 0 for player_stat in team: total_killed += player_stat.winstats.enemy_munts_killed total_killed += player_stat.winstats.enemy_structs_killed stat_map[player_stat.pid] = player_stat pid_killed = 0 pid_killed += stat_map[pid].winstats.enemy_munts_killed pid_killed += stat_map[pid].winstats.enemy_structs_killed if total_killed != 0: return float(pid_killed) / float(total_killed) return 1.0 / len(team) def get_ratings(self): # Get winner team ids winner_tids = self.get_winner_tids() # Calc win shares for each pid win_shares = {} for tid in xrange(len(self.teams)): for player_stat in self.teams[tid]: win_shares[player_stat.pid] = self.calc_win_share(self.teams[tid], player_stat.pid, winner_tids.__contains__(tid)) # logging for pid in win_shares.keys(): name = self.pid_player_stat_map[pid].name # logging.info('+++ win_share %s: %f' % (name, win_shares[pid])) # Get team ratings r = self.get_team_ratings(win_shares) # Estimate win percentages [0..1] for each team, which indicates a # percentage likelihood of a win. Estimate across all teams (this is # a bit different than standard ELO which is 2 player only). See: # http://en.wikipedia.org/wiki/Elo_rating_system#Mathematical_details q = {} qt = 0.0 for tid in r.keys(): q[tid] = 10**(float(r[tid])/400) qt += q[tid] e = {} for tid in r.keys(): e[tid] = q[tid] / qt # Calc actual win percentages [0..1] for each team. s = {} for tid in r.keys(): if winner_tids.__contains__(tid): s[tid] = 1.0 else: s[tid] = 0.0 # Calc per-team actual minus expected rating deltas [0..1] d = {} for tid in r.keys(): d[tid] = s[tid] - e[tid] # logging.info('+++ actual - expected tid: %d = %f' % (tid, d[tid])) # Now calculate per-player rating percentages based on percentage # contribution to kills for that team. ratings = [] for key in self.key_pid_map.keys(): pid = self.key_pid_map[key] tid = self.find_tid(pid) rating = (key, d[tid] * win_shares[pid], winner_tids.__contains__(tid)) ratings.append(rating) return ratings def lookup_dids(self): dids = {} for player_stat in self.player_stats: if 'did' in player_stat.__dict__ and len(player_stat.did) != 0: dids[player_stat.name] = player_stat.did continue dids[player_stat.name] = '' return dids def lookup_platforms(self): platforms = {} for player_stat in self.player_stats: if 'platform' in player_stat.__dict__ and len(player_stat.platform) != 0: platforms[player_stat.name] = player_stat.platform continue platforms[player_stat.name] = '' return platforms def save_actions(self, dids, platforms): # For every legit player, add a player action for player_stat in self.player_stats: if player_stat.winstats.ff & kfwsComputer: continue player_name = player_stat.name anonymous = (player_stat.winstats.ff & kfwsAnonymous) != 0 did = dids[player_name] if player_name in dids else '' ip = player_stat.ip if 'ip' in player_stat.__dict__ else '' action = dict(action='game', key=self.get_game_key_name()) platform = platforms[player_name] if player_name in platforms else '' playerdetail.save(player_name, anonymous, did, ip, action, platform) def save(self, update_player_stats=True, lookup_dids=True, save_actions=True): try: # Lookup dids if asked dids = self.lookup_dids() if lookup_dids else {} # Lookup platforms platforms = self.lookup_platforms() # If not saved, it's already in the db. if not db.run_in_transaction(self.add_txn, dids): return False if update_player_stats: self.update_player_stats() if save_actions: self.save_actions(dids, platforms) return True except: # Save game in a special place for later analysis # This should be rare but it'll help for chasing down bugs. obj = models.GameStatsFailed() obj.json = self.json io = StringIO.StringIO() traceback.print_exc(file=io) io.seek(0) obj.backtrace = io.read() io.close() obj.put() raise def update_player_stats(self): if self.get_game_result() != GAME_RESULT_SCOREABLE: return for player_key, rating_percent, winner in self.get_ratings(): db.run_in_transaction(self.update_player_txn, player_key, rating_percent, winner) def update_player_txn(self, player_key, rating_percent, winner): p = models.PlayerModel.get(player_key) # Update rating rating = p.rating if rating == 0: rating = PLAYER_DEFAULT_RATING k = 32 if rating >= 2000: k = 20 if rating >= 2400: k = 10 p.rating = int(round(rating + k * rating_percent)) # Winners get "match point" if winner: p.rating += 1 # Update misc totals pid = self.key_pid_map[player_key] player_stat = self.pid_player_stat_map[pid] p.credits_acquired_total += player_stat.winstats.credits_acquired p.credits_consumed_total += player_stat.winstats.credits_consumed p.munts_killed_total += player_stat.winstats.enemy_munts_killed p.structs_killed_total += player_stat.winstats.enemy_structs_killed p.munts_lost_total += player_stat.winstats.munts_lost p.structs_lost_total += player_stat.winstats.structs_lost # Update built unit counts. Adding units to this will be a pain. for index in xrange(len(player_stat.winstats.built_counts)): count = int(player_stat.winstats.built_counts[index]) p.built_counts_total[index] += count # Update game count, last game, and elapsed time p.game_count += 1 if winner: p.games_won += 1 p.last_game_key_name = self.get_game_key_name() p.elapsed_seconds_total += self.get_duration_seconds() if self.get_game_result() == GAME_RESULT_SCOREABLE: p.last_game_utc = self.end_utc p.rating_check_utc = ratingjob.next_rating_check_utc(self.end_utc) p.put() def add_txn(self, dids): game_key = self.get_game_key() if models.GameStatsModel.get(game_key): return False # Collect key names in game key_names = [key.name() for key in self.key_pid_map.keys()] # Collect old ratings of these players, for debugging purposes old_ratings = {} for key in self.key_pid_map.keys(): pid = self.key_pid_map[key] player_stat = self.pid_player_stat_map[pid] old_ratings[key.name()] = player_stat.player.rating obj = models.GameStatsModel(key_name=game_key.name(), player_key_names = key_names, mission_title = self.title, start_utc = self.start_utc, end_utc = self.end_utc, old_ratings = json.dumps(old_ratings), dids = json.dumps(dids), json = self.json) obj.put() return True def load_from_key_name(key_name, viewing_player_name, load_players=False): key = models.gamestatsmodel_key_from_name(key_name) obj = models.GameStatsModel.get(key) if obj: return GameStats(obj.json, viewing_player_name, load_players=load_players), obj return None, None