hostile-takeover/stats/gamestats.py
2014-07-06 17:47:28 -07:00

545 lines
19 KiB
Python

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": <integer>,
"server_start": <unsigned integer>,
"gameid": <integer>,
"packid_id": <unsigned integer>,
"packid_hash": "<string>",
"title": "<string>"
"filename": "<string>"
"game_speed": <integer>,
"min_players": <integer>,
"max_players": <integer>,
"start_utc": <unsigned integer>,
"end_utc": <unsigned integer>,
"player_stats": [
{
"name": "<string>",
"pid": <integer>,
"winstats": {
"side_mask": <integer>,
"side_mask_allies": <integer>,
"credits_acquired": <integer>,
"credits_consumed": <integer>,
"enemy_munts_killed": <integer>,
"enemy_structs_killed": <integer>,
"munts_lost": <integer>,
"structs_lost": <integer>,
"unit_counts": [ <kcutMax integers> ],
"built_counts": [ <kcutMax integers> ],
"ff": <integer>
}
}
]
}
"""
# 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, json, viewing_player_name='', load_players=False):
super(GameStats, self).__init__(json.loads(json))
self.json = json
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 save_actions(self, dids):
# 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())
utc = self.end_utc
playerdetail.save(player_name, anonymous, did, ip, action, utc)
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 {}
# 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)
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