2020 rewrite

This commit is contained in:
2020-06-15 00:50:54 +02:00
parent 99fa086a3f
commit fc5a825981
26 changed files with 1035 additions and 1541 deletions

142
python/Round.py Normal file
View File

@@ -0,0 +1,142 @@
import json
import datetime as dt
import dateutil.parser
import backends.entities.Players as Players
import backends.database as db
## A comment on why the login-offset is nessesary ##
## - losing teams tend to have players leaving and joining more rapidly
## - every time a new player joins he has to setup
## - new players are unfamiliar with postions of enemy team
## - new players have to run from spawn
## --> their impact factor (which is calculated from their active time) must account for that
loginoffset = dt.timedelta(seconds=60)
class Round:
def __init__(self, winnerTeam, loserTeam, _map, duration, startTime, winnerSide):
self.winners = winnerTeam
self.losers = loserTeam
self.winnerSide = winnerSide
self.map = _map
self.duration = duration
self.start = startTime
### Sync players from Databse ###
for p in self.winners + self.losers:
playerInDB = db.getOrCreatePlayer(p)
p.rating = playerInDB.rating
def normalized_playtimes(self):
'''returns a dict-Object with {key=(teamid,player):value=player_time_played/total_time_of_round}'''
np = dict()
for p in self.winners:
if self.duration == None:
d = 1.0
else:
d = (p.activeTime-loginoffset)/self.duration
if d < -1:
raise AssertionError("Normalized Playtime was less than -1 ??")
if d < 0:
d = 0.0
elif d > 1:
d = 1.0
np.update({(0,p):d})
for p in self.losers:
if self.duration == None:
d = 1.0
else:
d = (p.activeTime-loginoffset)/self.duration
if d < 0:
d = 0.0
elif d > 1:
d = 1.0
np.update({(1,p):d})
return np
def pt_difference(self):
'''Used to check difference in playtimes per team'''
if self.duration == None:
return 1
w1 = w2 = 0
for p in self.winners:
if p.is_fake:
w1 += 1.0
continue
d = (p.activeTime-loginoffset)/self.duration
if d < 0:
d = 0.0
elif d > 1:
d = 1.0
w1 += d
for p in self.losers:
d = (p.activeTime-loginoffset)/self.duration
if p.is_fake:
w2 += 1.0
continue
if d < 0:
d = 0.0
elif d > 1:
d = 1.0
w2 += d
# no div0 plox
if min(w1,w2) <= 0:
return 0
return max(w1,w2)/min(w1,w2)
def toJson(self):
winnersList = []
losersList = []
for w in self.winners:
winnersList += [{ "playerId" : w.id,
"playerName" : w.name,
"isFake" : w.is_fake,
"activeTime" : w.activeTime.total_seconds() }]
for w in self.losers:
losersList += [{ "playerId" : w.id,
"playerName" : w.name,
"isFake" : w.is_fake,
"activeTime" : w.activeTime.total_seconds() }]
retDict = { "winners" : winnersList,
"losers" : losersList,
"startTime" : self.start.isoformat(),
"duration" : self.duration.total_seconds(),
"map" : self.map,
"winner-side" : self.winnerSide }
return json.dumps(retDict)
def fromJson(jsonDict):
winnersList = []
losersList = []
timestamp = dateutil.parser.isoparse(jsonDict["startTime"])
winnerTeam = jsonDict.get("winner-side")
if not winnerTeam:
winnerTeam = -1
loserTeam = -2
else:
loserTeam = (winnerTeam % 2) + 2
for p in jsonDict["winners"]:
pObj = Players.PlayerInRound(p["playerId"], p["playerName"], winnerTeam, timestamp)
pObj.activeTime = int(p["activeTime"])
winnersList += [pObj]
for p in jsonDict["losers"]:
pObj = Players.PlayerInRound(p["playerId"], p["playerName"], loserTeam, timestamp)
pObj.activeTime = int(p["activeTime"])
losersList += [pObj]
return Round(winnersList, losersList, jsonDict["duration"], jsonDict["map"], timestamp, winnerTeam)

View File

@@ -0,0 +1,99 @@
import sqlite3
import backends.entities.Players as Players
import backends.trueskillWrapper as trueskill
# setup
# create TABLE players (id TEXT PRIMARY KEY, name TEXT, lastgame TEXT, wins INTEGER, mu REAL, sigma REAL, game INTEGER);
DATABASE = "players.sqlite"
def getPlayer(playerId):
conn = sqlite3.connect("players.sqlite")
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM players WHERE id = ?", (playerId,))
rows = cursor.fetchall()
if len(rows) < 1:
return None
else:
playerId, playerName, lastGame, wins, mu, sigma, games = rows[0]
return Players.PlayerInDatabase(playerId, playerName,
trueskill.newRating(mu=mu, sigma=sigma), wins, games)
finally:
conn.close()
def getOrCreatePlayer(player):
playerInDb = getPlayer(player.id)
if not playerInDb:
return savePlayerToDatabase(player)
else:
return playerInDb
def getMultiplePlayers(playerIdList):
return [ getPlayer(p) for p in playerIdList ]
def savePlayerToDatabase(player, incrementWins=0):
conn = sqlite3.connect(DATABASE)
cursor = conn.cursor()
if not player.rating:
player.rating = trueskill.newRating()
playerFromDatabase = getPlayer(player.id)
if playerFromDatabase:
cursor.execute('''UPDATE players SET id = ?,
name = ?,
lastgame = ?,
wins = ?,
mu = ?,
sigma = ?,
games = ?
WHERE id = ?''',
(player.id, player.name, None, playerFromDatabase.wins + incrementWins,
player.rating.mu, player.rating.sigma, playerFromDatabase.games + 1, player.id))
else:
cursor.execute("INSERT INTO players VALUES (?,?,?,?,?,?,?)",
(player.id, player.name, None, 0,
player.rating.mu, player.rating.sigma, 0))
conn.commit()
conn.close()
return getPlayer(player.id)
def saveMultiplePlayersToDatabase(playerList, incrementWins=0):
for p in playerList:
savePlayerToDatabase(p, incrementWins)
## open leaderboard functions ##
def findPlayerByName(playerName):
conn = sqlite3.connect(DATABASE)
cursor = conn.cursor()
playerNamePrepared = "%{}%".format(playerName.replace("%", "%%"))
cursor.execute("SELECT * FROM players WHERE name == ?", (playerName,))
rows = cursor.fetchall()
playerRow = None
if len(rows) < 1:
cursor.execute("SELECT * FROM players WHERE name LIKE ?", (playerNamePrepared,))
rows = cursor.fetchall()
if len(rows) < 1:
return None
playerRow = rows[0]
else:
playerRow = rows[0]
playerId, playerName, lastGame, wins, mu, sigma, games = playerRow
conn.close()
return Players.PlayerInDatabase(playerId, playerName,
trueskill.newRating(mu=mu, sigma=sigma), wins, games)
def getTotalEntries(playerName):
conn = sqlite3.connect(DATABASE)
cursor = conn.cursor()
playerNamePrepared = "%{}%".format(playerName.replace("%", "%%"))
cursor.execute("select count(*) from players")
count = cursor.fetchone()
conn.close()
return count
def getRankRange(start, end):
pass

View File

@@ -0,0 +1,67 @@
import datetime as dt
import json
class Player:
def __init__(self, playerId, name, rating=None):
self.rating = rating
self.id = playerId
self.name = name
def __hash__(self):
return hash(self.id)
def __eq__(self, other):
if not other:
return False
elif isinstance(other, str):
return self.id == other
elif isinstance(other, Player):
return self.id == other.id
else:
raise TypeError("Player: Unsupported equals with types {} and {}".format(type(other),type(self)))
def __str__(self):
return "Player: {}, ID: {}".format(self.name, self.id)
class PlayerInRound(Player):
def __init__(self, playerId, name, team, timestamp, is_fake=False):
self.name = name
self.id = playerId
self.team = int(team)
self.active = True
self.rating = None
self.activeTime = dt.timedelta(0)
self.is_fake = is_fake
self.timestamp = timestamp
def __str__(self):
return "PlayerInRound: N: {} ID: {} Team: {}".format(self.name, self.id, self.team)
class PlayerInDatabase(Player):
def __init__(self, playerId, name, rating, wins, games):
self.id = playerId
self.name = name
self.rating = rating
self.lastUpdate = dt.datetime.now()
self.wins = wins
self.games = games
def winratio(self):
if self.games == 0:
return "---"
return str(int(self.wins*100/self.games))
def getName(self):
return self.name.encode('utf-8')[:25].decode('utf-8','ignore').rstrip(" ")
def toJson(self):
retDict = { "name" : self.name,
"id" : self.id,
"rating-mu" : self.rating.mu,
"rating-sigma" : self.rating.mu,
"games" : self.games,
"wins" : self.wins,
"last-game" : self.lastUpdate.isoformat()}
return json.dumps(retDict)

View File

@@ -0,0 +1,219 @@
import abc
import dateutil.parser
import datetime as dt
import Round
import backends.entities.Players as Players
NO_TEAM = 0
OBSERVERS = 1
SECURITY = 2
INSURGENT = 3
def _getKey(dic, key):
'''Helper function'''
tmp = list(dic)
return tmp[tmp.index(key)]
def parse(events):
'''Parse a string blob representing a full round'''
eventsParsed = []
for eventJsonObject in events:
eventsParsed += [parseEventString(eventJsonObject)]
es = EventSeries(eventsParsed)
return Round.Round(es.winnerTeam, es.loserTeam, es.map, es.duration, es.startTime, es.winnerTeamId)
def parseEventString(event):
'''Take a dictionary representing an event and return an actual event object'''
TEAMCHANGE = ["teamchange"]
ACTIVE_PLAYERS = ["ct","dc","round_start_active","round_end_active","tc","active_players"]
DISCONNECT = ["disconnect"]
WINNER_INFO = ["winner"]
MAP_INFO = ["mapname", "map"]
IGNORE = ["map_start_active","start","plugin unloaded"]
print(event)
etype = event["etype"]
if False:
pass
# elif etype in TEAMCHANGE:
# return TeamchangeEvent(event)
elif etype in ACTIVE_PLAYERS:
return ActivePlayersEvent(event)
# elif etype in DISCONNECT:
# return DisconnectEvent(event)
elif etype in WINNER_INFO:
return WinnerInformationEvent(event)
elif etype in MAP_INFO:
return MapInformationEvent(event)
elif etype in IGNORE:
pass
class Event(abc.ABC):
'''Abstract class all events inherit from'''
pass
#class DisconnectEvent(Event):
# '''Event describing a disconnect by an individual player'''
#
# def __init__(self, event):
# self.timestamp = dt.datetime.fromtimestamp(event["timestamp"])
# self.player = event["playerId"]
#
#class TeamchangeEvent(Event):
# '''Event describing a teamchange by an individual player'''
#
# def __init__(self, event):
# self.timestamp = dt.datetime.fromtimestamp(event["timestamp"])
# self.player = event["playerId"]
# self.old_team = event["previousTeam"]
class ActivePlayersEvent(Event):
'''Event describing as absolute values the currently active player'''
def __init__(self, event):
self.timestamp = dt.datetime.fromtimestamp(event["timestamp"])
self.players = [ Players.PlayerInRound(p["id"], p["name"], p["team"], self.timestamp)
for p in event["players"] ]
class WinnerInformationEvent(Event):
'''Event describing which team has won the game'''
def __init__(self, event):
self.timestamp = dt.datetime.fromtimestamp(event["timestamp"])
self.winner = event["winnerTeam"]
class MapInformationEvent(Event):
'''Event describing the current map'''
def __init__(self, event):
self.timestamp = dt.datetime.fromtimestamp(event["timestamp"])
self.map = event["map"]
class EventSeries():
def __init__(self, events):
self.events = events
self.winnerTeam = None
self.winnerTeamId = -1
self.loserTeam = None
self.loserTeamId = -1
self.map = ""
lastEvent = max(events, key=lambda e: e.timestamp)
firstEvent = min(events, key=lambda e: e.timestamp)
self.duration = lastEvent.timestamp - firstEvent.timestamp
self.startTime = firstEvent.timestamp
self.teamA = []
self.teamAId = 2
self.teamB = []
self.teamBId = 3
for e in events:
if type(e) == ActivePlayersEvent:
for playerInRound in e.players:
## Case 1: Player isn't in any team yet
if playerInRound not in self.teamA and playerInRound not in self.teamB:
playerInRound.active = True
if playerInRound.team == self.teamAId:
self.teamA += [playerInRound]
else:
self.teamB += [playerInRound]
## Case 2: Player is in the wrong team
if playerInRound not in self._teamFromId(playerInRound.team):
index = self._teamFromId(playerInRound.team, inverted=True).index(playerInRound)
playerInEventSeries = self._teamFromId(playerInRound.team, inverted=True)[index]
# update playtime #
playerInEventSeries.active = False
playerInEventSeries.activeTime += e.timestamp - playerInEventSeries.timestamp
# add player to correct team #
playerInRound.active = True
if playerInRound.team == self.teamAId:
self.teamA += [playerInRound]
else:
self.teamB += [playerInRound]
## Case 3: Player is already in the correct team
else:
index = self._teamFromId(playerInRound.team).index(playerInRound)
playerInEventSeries = self._teamFromId(playerInRound.team)[index]
# update playtime #
if playerInEventSeries.active:
playerInEventSeries.activeTime += e.timestamp - playerInEventSeries.timestamp
playerInEventSeries.timestamp = e.timestamp
playerInEventSeries.active = True
# mark all missing players as inactive and update their play times #
for playerInEventSeries in self.teamA + self.teamB:
if playerInEventSeries not in e.players:
# update playtime #
playerInEventSeries.active = False
playerInEventSeries.activeTime += e.timestamp - playerInEventSeries.timestamp
elif type(e) == WinnerInformationEvent:
self.winnerTeamId = int(e.winner)
self.winnerTeam = self._teamFromId(self.winnerTeamId)
self.loserTeam = self._teamFromId(self.winnerTeamId, inverted=True)
elif type(e) == MapInformationEvent:
self.map = e.map
### normalize teamchanges
toBeRemovedFromLosers = [] # cannot change iteable during iteration
toBeRemovedFromWinners = []
for playerInEventSeries in self.winnerTeam:
if playerInEventSeries in self.loserTeam:
# get active time in both teams #
playerLoserTeamIndex = self.loserTeam.index(playerInEventSeries)
loserTeamActiveTime = self.loserTeam[playerLoserTeamIndex].activeTime
winnerTeamActiveTime = playerInEventSeries.activeTime
# substract the smaller active time and mark player #
# to be removed from the team he played less on #
if winnerTeamActiveTime > loserTeamActiveTime:
toBeRemovedFromLosers += [playerInEventSeries]
playerInEventSeries.activeTime - loserTeamActiveTime
else:
toBeRemovedFromWinners += [playerInEventSeries]
self.loserTeam[playerLoserTeamIndex].activeTime -= winnerTeamActiveTime
# after iteration actually remove the players #
for player in toBeRemovedFromWinners:
self.winnerTeam.remove(player)
for player in toBeRemovedFromLosers:
self.loserTeam.remove(player)
def _teamFromId(self, teamId, inverted=False):
'''Return the attribute array representing the teamId in this event series
or a dummy array for observers and no-team
Inverted: return the team NOT beloning to the teamId'''
if inverted:
teamId = ( teamId + 1 ) % 2 +2 # 2 => 3 and 3 => 2, 0/1 don't matter
if teamId == OBSERVERS or teamId == NO_TEAM:
return []
elif teamId == 2:
return self.teamA;
elif teamId == 3:
return self.teamB;
else:
errorMsg = "TeamID must be 0 - NoTeam, 1 - Observers, 2 - Security or 3 - Insurgent, but was {}"
raise ValueError(errorMsg.format(teamId))

View File

@@ -0,0 +1,41 @@
# insurgency specific
import StorrageBackend as SB
import TrueSkillWrapper as TS
# general
import Player
import time
from datetime import datetime
def loadCache(cacheFile):
raise NotImplementedError("This backend does not support caching")
def parse(f, exit_on_eof=True, start_at_end=False, cacheFile=None):
last_round_end = None
seek_start = True
round_lines = []
last_line_was_winner = False
lineCount = 0
startTime = datetime.now()
while True:
old_line_nr = f.tell()
line = f.readline()
# if no line or incomplete line, sleep and try again #
if not line or not line.strip("\n"):
if exit_on_eof:
return
time.sleep(5000)
continue
elif not line.endswith("\n"):
f.seek(old_line_nr)
time.sleep(5000)
continue
players = line.strip("\n").split("|")
playerObjects = [Player.PlayerForDatabase(pname, pname, TS.newRating()) for pname in players]
# get ratings if there are any yet #
SB.sync_from_database(playerObjects)
TS.rate_ffa(playerObjects)

View File

@@ -0,0 +1,85 @@
#!/usr/bin/python3
from trueskill import TrueSkill, Rating
env = TrueSkill(draw_probability=0, mu=1500, sigma=833, tau=40, backend='mpmath')
env.make_as_global()
#####################################################
################ HANDLE RATING INPUT ################
#####################################################
def evaluateRound(r):
# do no rate rounds that were too imbalanced #
if r.pt_difference() >= 2.1 or r.pt_difference() == 0:
raise ValueError("Teams too imbalanced: {} (zero=inifinity)".format(r.pt_difference()))
weights = r.normalized_playtimes()
# trueskill need groups = [ { key : rating, ... } , { key : rating, ... } ]
# --------------- ---------------
# Team 1 (winners) Team 2 (losers)
groups=[dict(), dict()]
for playerInDatabase in r.winners:
groups[0].update( { playerInDatabase : playerInDatabase.rating } )
for playerInDatabase in r.losers:
groups[1].update( { playerInDatabase : playerInDatabase.rating } )
if len(groups[1]) == 0 or len(groups[0]) ==0 :
raise ValueError("One of the rated Teams was empty")
rated = env.rate(groups, weights=weights)
return rated
#def rate_ffa(players):
# '''Takes a list of players in reverse finishing order (meaning best first)
# perform a truskill evaluation and write it to database'''
#
# # one player doesnt make sense #
# if len(players) <= 1:
# return False
#
# # create list of dicts for trueskill-library #
# playerRatingTupelDicts = []
# for p in players:
# playerRatingTupelDicts += [{p:p.rating}]
#
# # generate ranks
# ranks = [ i for i in range(0, len(playerRatingTupelDicts))]
#
# # rate and safe to database #
# rated = env.rate(playerRatingTupelDicts)
#
# # create sync dict #
# # first player is handled seperately #
# allPlayer = dict()
# for playerRatingDict in rated[1:]:
# allPlayer.update(playerRatingDict)
#
# # only first player gets win #
# StorrageBackend.sync_to_database(rated[0], True)
# StorrageBackend.sync_to_database(allPlayer, False)
#
# for p in allPlayer.keys():
# print(p)
#####################################################
################### LOCK/GETTER ####################
#####################################################
def newRating(mu=None, sigma=None):
if mu:
return Rating(mu=mu, sigma=env.sigma)
elif mu and sigma:
return Rating(mu=mu, sigma=sigma)
else:
return env.create_rating()
def getEnviroment():
return env
def balance(players, buddies=None):
raise NotImplementedError()
def get_player_rating(sid, name="NOTFOUND"):
raise NotImplementedError()

122
python/httpAPI.py Normal file
View File

@@ -0,0 +1,122 @@
#!/usr/bin/python3
import backends.database as db
import backends.trueskillWrapper as ts
import backends.eventStream
import Round
import json
import flask
import os
import sys
import datetime as dt
app = flask.Flask("skillbird")
def run(port, parser):
app.run(port=port)
## SERVER QUERIES ###
@app.route('/get-player-rating-msg')
def getPlayer():
playerId = flask.request.args.get("id")
p = db.getPlayer(playerId)
if not p:
return ("Player not found", 404)
return "{}'s Rating: {}".format(p.name, p.rating.mu - p.rating.sigma)
#### Open Leaderboard ###
@app.route('/findplayer')
def getFind():
playerName = flask.request.args.get("name")
p = db.findPlayerbyName(playerName)
return p.toJson()
@app.route('/getmaxentries')
def getMaxEntries():
return "{}".format(db.getTotalEntries())
@app.route('/rankrange')
def getRankRange():
try:
start = int(flask.request.args.get("start"))
end = int(flask.request.args.get("end"))
if end - start <= 0 or end - start > 100:
raise ValueError()
except ValueError:
return invalidParameters(start, end)
players = db.getRankRange(start, end)
return ""
################# DataSubmission #######################
@app.route('/submitt-round', methods=["POST"])
def jsonRound():
'''Submitt a full Round in the form a json directory as described in the documentation'''
evaluateRound(Round.fromJson(flask.request.json))
return ("", 204)
@app.route('/single-event', methods=["POST"])
def singleEvent():
'''Location for submitting single events with a session id, such an event stream must be terminated
with an "round_end" event'''
# get and check request parameters #
jsonDict = flask.request.json
session = flask.request.args.get("session")
if not session:
return ("Bad session ID", 500)
# define local names #
ROUND_END_IDENT = "round_end"
PATH_BASE = "data/"
SESSION_FILE_FORMAT = "{}-session-events.txt"
sesionFile = SESSION_FILE_FORMAT.format(session)
fullPath = os.path.join(PATH_BASE, sesionFile)
# if there is a local session check it's age first and delete it if it is too old #
if os.path.isfile(fullPath):
fileLastModified = dt.datetime.fromtimestamp(os.path.getmtime(fullPath))
maxAge = dt.timedelta(minutes=30)
if dt.datetime.now() - fileLastModified > maxAge:
print("Removed orphaned session file: {}".format(fullPath), sys.stderr)
os.remove(fullPath)
if jsonDict["etype"] == ROUND_END_IDENT:
events = []
with open(fullPath, "r") as f:
for l in f:
events += [json.loads(l)]
os.remove(fullPath)
matchRound = backends.eventStream.parse(events)
evaluateRound(matchRound)
else:
with open(fullPath, "a") as f:
f.write(json.dumps(jsonDict))
f.write("\n")
return ("", 204)
@app.route('/event-blob', methods=["POST"])
def eventBlob():
'''Location for submitting full rounds in the form of an event array'''
matchRound = backends.eventStream.parse(flask.request.json["events"])
evaluateRound(matchRound)
return ("", 204)
def evaluateRound(matchRound):
'''Run a match round throught the backand (includeing persisting it'''
# winners/losers are both dictionaries in the form of { PlayerInDatabase : newRating } #
winnersRated, losersRated = ts.evaluateRound(matchRound)
for playerInDatabase in winnersRated.keys():
playerInDatabase.rating = winnersRated[playerInDatabase]
for playerInDatabase in losersRated.keys():
playerInDatabase.rating = losersRated[playerInDatabase]
db.saveMultiplePlayersToDatabase(winnersRated.keys(), incrementWins=1)
db.saveMultiplePlayersToDatabase(losersRated.keys())

33
python/init.py Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/python3
import sys
import argparse
import httpAPI
#import backends.genericFFA
import backends.eventStream
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Insurgency rating python backend server')
### parser backend configuration ###
parser.add_argument('--parser-backend', required=True, choices=['ffa', 'eventStream'],
help="Use the free-for-all parser")
### readin configuration ###
parser.add_argument('--http-api-port', type=int, default=5000, help="HTTP API Port")
args = parser.parse_args()
### set parser ###
if args.parser_backend == "ffa":
#parser = backends.genericFFA
raise NotImplementedError()
elif args.parser_backend == "eventStream":
parser = backends.eventStream
else:
print("Unsupported parser: {}".format(args.parser_backend), file=sys.stderr)
sys.exit(1)
### set input mode ###
httpAPI.run(args.http_api_port, parser)