elo system

This commit is contained in:
Jonas Weinz 2019-03-29 12:52:03 +01:00
parent 84b360e8ed
commit dc23ff502b
8 changed files with 217 additions and 137 deletions

109
README.md
View File

@ -6,92 +6,6 @@ communication with the web client is done by a (far from any standard and almost
**register as player:**
```json
{
"type": "register",
"data": {
"id": "<player_id>",
"name": "<player_name>"
}
}
```
response:
```JSON
{
"type": "register_response",
"data": {
"success": true,
"msg": "<additional info e.g. in case of error>"
}
}
```
**message from server that game started**
```json
{
"type": "game_starts",
"data": {
"msg": "...",
"opponent_name": "...",
"is_first_move": true
}
}
```
note: `is_first_move` indicates whether the player or it's opponent begins
**move**
```json
{
"type": "move",
"data": {
"sub_x": "...",
"sub_y": "...",
"x": "...",
"y": "..."
}
}
```
response:
```json
{
"type": "move_response",
"data": {
"success": true,
"msg": "..."
}
}
```
**end game**
```json
{
"type": "end_game",
"data": {
"msg": "..."
}
}
```
(response?)
## new version: ## new version:
**json match state:** **json match state:**
@ -107,9 +21,10 @@ response:
"y": "..." "y": "..."
} }
game_over: <true | false>, game_over: <true | false>,
player_won: <null | <player_name>> is_draw: <true | false>,
current_player: <null | <player_name>> player_won: <null | <player_name>>,
player_a: "..." current_player: <null | <player_name>>,
player_a: "...",
player_b: "..." player_b: "..."
} }
``` ```
@ -341,7 +256,21 @@ response:
{ {
"type": "friends_update", "type": "friends_update",
"data": { "data": {
"friends": "<list of friends>" "friends": "<list of friends>",
"elos": "<list of elo values>"
}
}
```
**elo rank update**:
```json
{
"type": "elo_update",
"data": {
"elo": <elo_value>
} }
} }
``` ```

View File

@ -6,7 +6,7 @@ from user_manager import UserManager
from match_manager import MatchManager from match_manager import MatchManager
from match import Match from match import Match
from tools import debug from tools import debug, elo_p_win, elo_update
def parse_message(msg: str): def parse_message(msg: str):
@ -133,6 +133,7 @@ class ConnectionHandler(object):
"msg": "" "msg": ""
} }
})) }))
await self.send_elo(conn)
await self.send_friends(conn) await self.send_friends(conn)
await self._on_match_state_req(conn, None) await self._on_match_state_req(conn, None)
return conn return conn
@ -234,6 +235,7 @@ class ConnectionHandler(object):
})) }))
if success: if success:
await self.send_elo(conn)
await self.send_friends(conn) await self.send_friends(conn)
await self._on_match_state_req(conn, None) await self._on_match_state_req(conn, None)
@ -444,6 +446,11 @@ class ConnectionHandler(object):
} }
})) }))
if match.game_over:
if match.is_draw or (match.player_won is not None):
# send rank update
await self.send_elo(conn)
other_user = match.player_a_name if conn.user_name == match.player_b_name else match.player_b_name other_user = match.player_a_name if conn.user_name == match.player_b_name else match.player_b_name
if other_user in self.open_connections_by_user: if other_user in self.open_connections_by_user:
@ -457,6 +464,9 @@ class ConnectionHandler(object):
})) }))
if match.game_over: if match.game_over:
self.match_manager.delete_match(match.id) self.match_manager.delete_match(match.id)
if match.is_draw or (match.player_won is not None):
# send rank update
await self.send_elo(other_conn)
async def _on_match_close(self, conn, data): async def _on_match_close(self, conn, data):
match = None match = None
@ -468,6 +478,36 @@ class ConnectionHandler(object):
if (match is None): if (match is None):
return return
if not match.game_over:
# check whether both player made a move. If so, the match is ranked as lost for the player who aborted the match
if match.complete_field.__contains__(Match.FIELD_USER_A) and match.complete_field.__contains__(Match.FIELD_USER_B):
# update rankings:
player_lost = conn.user_name
player_won = match.player_a_name if player_lost == match.player_b_name else match.player_b_name
elo_won = self.user_manager.get_elo(player_won)
elo_lost = self.user_manager.get_elo(player_lost)
# calculate elo values:
p_won = elo_p_win(elo_won, elo_lost)
p_lost = 1 - p_won
new_elo_won = elo_update(elo_won, 1, p_won)
new_elo_lost = elo_update(elo_lost, 0, p_lost)
self.user_manager.update_elo(player_won, new_elo_won)
self.user_manager.update_elo(player_lost, new_elo_lost)
await self.send_elo(conn)
if player_won in self.open_connections_by_user:
other_conn = self.open_connections_by_user[player_won]
await self.send_elo(other_conn)
debug(
f"Match {match.id} is aborted by {player_lost} (against {player_won}). Update elo-rankings: {elo_won}->{new_elo_won} and {elo_lost}->{new_elo_lost}")
match.game_over = True match.game_over = True
match_state = match.to_json_state() match_state = match.to_json_state()
@ -567,12 +607,23 @@ class ConnectionHandler(object):
await self.send_friends(conn) await self.send_friends(conn)
async def send_friends(self, conn): async def send_friends(self, conn):
friends = list(self.user_manager.get_friends_for_user(conn.user_name))
friends, elos = self.user_manager.get_friends_and_elos_for_user(
conn.user_name)
await conn.send(json.dumps({ await conn.send(json.dumps({
'type': 'friends_update', 'type': 'friends_update',
'data': { 'data': {
'friends': friends 'friends': friends,
'elos': elos
}
}))
async def send_elo(self, conn):
await conn.send(json.dumps({
'type': 'elo_update',
'data': {
'elo': self.user_manager.get_elo(conn.user_name)
} }
})) }))

View File

@ -16,7 +16,7 @@ from tools import debug
um = UserManager() um = UserManager()
sm = SessionManager(datetime.timedelta(hours=12)) sm = SessionManager(datetime.timedelta(hours=12))
mm = MatchManager() mm = MatchManager(user_manager=um)
ch = ConnectionHandler(sm, um, mm) ch = ConnectionHandler(sm, um, mm)

View File

@ -29,19 +29,20 @@ def SimpleDecode(jsonDump):
return np.array(json.loads(jsonDump)) return np.array(json.loads(jsonDump))
FIELD_EMPTY = 0
FIELD_USER_A = 1
FIELD_USER_B = 2
FIELD_DRAW = 3
class Match(object): class Match(object):
FIELD_EMPTY = 0
FIELD_USER_A = 1
FIELD_USER_B = 2
FIELD_DRAW = 3
def __init__(self, n, match_id, player_a_name, player_b_name, json_state=None): def __init__(self, n, match_id, player_a_name, player_b_name, json_state=None):
self.n = n self.n = n
self.id = match_id self.id = match_id
self.complete_field = np.zeros(shape=(n*n, n*n), dtype=int) self.complete_field = np.zeros(shape=(n*n, n*n), dtype=int)
self.global_field = np.zeros(shape=(n, n), dtype=int) self.global_field = np.zeros(shape=(n, n), dtype=int)
self.player_won = None self.player_won = None
self.is_draw = False
self.game_over = False self.game_over = False
self.last_move = None self.last_move = None
self.is_player_a = True self.is_player_a = True
@ -60,8 +61,14 @@ class Match(object):
self.last_move = match_obj['last_move'] self.last_move = match_obj['last_move']
self.is_player_a = match_obj['active_player'] == self.player_a_name self.is_player_a = match_obj['active_player'] == self.player_a_name
# draw state w.r.t backward compability
self.is_draw = match_obj['is_draw'] if 'is_draw' in match_obj else False
def to_json_state(self): def to_json_state(self):
match_obj = { return json.dumps(self.to_dict_state())
def to_dict_state(self):
return {
'complete_field': self.complete_field.tolist(), 'complete_field': self.complete_field.tolist(),
'global_field': self.global_field.tolist(), 'global_field': self.global_field.tolist(),
'last_move': self.last_move, 'last_move': self.last_move,
@ -69,11 +76,10 @@ class Match(object):
'player_won': self.player_won, 'player_won': self.player_won,
'active_player': self.player_a_name if self.is_player_a else self.player_b_name, 'active_player': self.player_a_name if self.is_player_a else self.player_b_name,
'player_a': self.player_a_name, 'player_a': self.player_a_name,
'player_b': self.player_b_name 'player_b': self.player_b_name,
'is_draw': self.is_draw
} }
return json.dumps(match_obj)
def switch_player_names(self): def switch_player_names(self):
tmp = self.player_a_name tmp = self.player_a_name
self.player_a_name = self.player_b_name self.player_a_name = self.player_b_name
@ -99,20 +105,20 @@ class Match(object):
last_sub_x = self.last_move['sub_x'] last_sub_x = self.last_move['sub_x']
last_sub_y = self.last_move['sub_y'] last_sub_y = self.last_move['sub_y']
if sub_x != last_x and self.global_field[last_y, last_x] == FIELD_EMPTY: if sub_x != last_x and self.global_field[last_y, last_x] == Match.FIELD_EMPTY:
# user is not allowed to place everywhere! wrong move! # user is not allowed to place everywhere! wrong move!
return False return False
if sub_y != last_y and self.global_field[last_y, last_x] == FIELD_EMPTY: if sub_y != last_y and self.global_field[last_y, last_x] == Match.FIELD_EMPTY:
return False return False
if self.complete_field[sub_y * self.n + y][sub_x * self.n + x] != FIELD_EMPTY: if self.complete_field[sub_y * self.n + y][sub_x * self.n + x] != Match.FIELD_EMPTY:
return False return False
return True return True
def is_full(self, field): def is_full(self, field):
return not field.__contains__(FIELD_EMPTY) return not field.__contains__(Match.FIELD_EMPTY)
def check_win(self, field, x, y): def check_win(self, field, x, y):
is_col = True is_col = True
@ -157,7 +163,7 @@ class Match(object):
abs_x = sub_x * self.n + x abs_x = sub_x * self.n + x
abs_y = sub_y * self.n + y abs_y = sub_y * self.n + y
player_mark = FIELD_USER_A if self.is_player_a else FIELD_USER_B player_mark = Match.FIELD_USER_A if self.is_player_a else Match.FIELD_USER_B
if not self.is_move_valid(sub_x, sub_y, x, y): if not self.is_move_valid(sub_x, sub_y, x, y):
debug("invalid move") debug("invalid move")
@ -170,7 +176,7 @@ class Match(object):
self.last_move = {'sub_x': sub_x, 'sub_y': sub_y, 'x': x, 'y': y} self.last_move = {'sub_x': sub_x, 'sub_y': sub_y, 'x': x, 'y': y}
# check whether this indicates changes in the global field: # check whether this indicates changes in the global field:
if self.global_field[sub_y, sub_x] != FIELD_EMPTY: if self.global_field[sub_y, sub_x] != Match.FIELD_EMPTY:
debug("field not empty") debug("field not empty")
return False return False
@ -184,10 +190,11 @@ class Match(object):
self.player_won = self.player_a_name if self.is_player_a else self.player_b_name self.player_won = self.player_a_name if self.is_player_a else self.player_b_name
elif self.is_full(subgrid): elif self.is_full(subgrid):
self.global_field[sub_y, sub_x] = FIELD_DRAW self.global_field[sub_y, sub_x] = Match.FIELD_DRAW
if self.is_full(self.global_field): if self.is_full(self.global_field):
self.game_over = True self.game_over = True
self.player_won = None self.player_won = None
self.is_draw = True
self.is_player_a = not self.is_player_a self.is_player_a = not self.is_player_a

View File

@ -1,18 +1,20 @@
from database_connection import DatabaseConnection, SQLInjectionError, get_sql_time from database_connection import DatabaseConnection, SQLInjectionError, get_sql_time
from user_manager import UserManager
from user import User from user import User
import datetime import datetime
import uuid import uuid
import settings import settings
from match import Match from match import Match
from tools import debug from tools import debug, elo_p_win, elo_update
class MatchManager(object): class MatchManager(object):
def __init__(self): def __init__(self, user_manager: UserManager = None):
self.user_manager = user_manager
pass pass
def get_match(self, id): def get_match(self, id: str) -> Match:
query = "SELECT * FROM matches WHERE id=%s" query = "SELECT * FROM matches WHERE id=%s"
result = DatabaseConnection.global_single_query(query, (id)) result = DatabaseConnection.global_single_query(query, (id))
if len(result) == 0: if len(result) == 0:
@ -21,11 +23,11 @@ class MatchManager(object):
player_b_name=result[0]['user_b'], json_state=result[0]['match_state']) player_b_name=result[0]['user_b'], json_state=result[0]['match_state'])
return match return match
def get_matches_for_user(self, user_name): def get_matches_for_user(self, user_name: str) -> list:
query = "SELECT * FROM matches WHERE user_a=%s OR user_b=%s" query = "SELECT * FROM matches WHERE user_a=%s OR user_b=%s"
return DatabaseConnection.global_single_query(query, (user_name, user_name)) return DatabaseConnection.global_single_query(query, (user_name, user_name))
def create_new_match(self, user_a, user_b): def create_new_match(self, user_a: str, user_b: str) -> Match:
match_id = uuid.uuid4().hex match_id = uuid.uuid4().hex
# check if already existent (but should not be the case) # check if already existent (but should not be the case)
if len(DatabaseConnection.global_single_query("SELECT id FROM matches WHERE id=%s", (match_id))) > 0: if len(DatabaseConnection.global_single_query("SELECT id FROM matches WHERE id=%s", (match_id))) > 0:
@ -39,13 +41,57 @@ class MatchManager(object):
query, (match_id, user_a, user_b, match.to_json_state(), match.get_current_player(), get_sql_time(now))) query, (match_id, user_a, user_b, match.to_json_state(), match.get_current_player(), get_sql_time(now)))
return match return match
def update_match(self, match_id, match): def update_match(self, match_id: str, match: Match) -> None:
now = get_sql_time(datetime.datetime.now()) now = get_sql_time(datetime.datetime.now())
query = "UPDATE matches SET match_state=%s, active_user=%s, last_active=%s WHERE id=%s" query = "UPDATE matches SET match_state=%s, active_user=%s, last_active=%s WHERE id=%s"
DatabaseConnection.global_single_execution( DatabaseConnection.global_single_execution(
query, (match.to_json_state(), match.get_current_player(), now, match_id)) query, (match.to_json_state(), match.get_current_player(), now, match_id))
def apply_move(self, move_data): # check whether we have to update the elo values (game over triggered by the last move)
if match.game_over:
if match.player_won is not None:
player_won = match.player_won
player_lost = match.player_a_name if player_won == match.player_b_name else match.player_b_name
elo_won = self.user_manager.get_elo(player_won)
elo_lost = self.user_manager.get_elo(player_lost)
# calculate elo values:
p_won = elo_p_win(elo_won, elo_lost)
p_lost = 1 - p_won
new_elo_won = elo_update(elo_won, 1, p_won)
new_elo_lost = elo_update(elo_lost, 0, p_lost)
self.user_manager.update_elo(player_won, new_elo_won)
self.user_manager.update_elo(player_lost, new_elo_lost)
debug(
f"Match {match_id} is won by {player_won} over {player_lost}. Update elo-rankings: {elo_won}->{new_elo_won} and {elo_lost}->{new_elo_lost}")
elif match.is_draw:
elo_a = self.user_manager.get_elo(match.player_a_name)
elo_b = self.user_manager.get_elo(match.player_b_name)
p_a_wins = elo_p_win(elo_a, elo_b)
p_b_wins = 1 - p_a_wins
new_elo_a = elo_update(elo_a, 0.5, p_a_wins)
new_elo_b = elo_update(elo_b, 0.5, p_b_wins)
self.user_manager.update_elo(match.player_a_name, new_elo_a)
self.user_manager.update_elo(match.player_b_name, new_elo_b)
debug(
f"{match_id} between {match.player_a_name} and {match.player_b_name} ended in draw. Update elo-rankings: {elo_a}->{new_elo_a} and {elo_b}->{new_elo_b}")
else:
# someone aborted a match. TODO: apply some penalty to the ranking
pass
def apply_move(self, move_data: dict) -> Match:
match = self.get_match(move_data['id']) match = self.get_match(move_data['id'])
if match is None: if match is None:
return None return None
@ -58,10 +104,10 @@ class MatchManager(object):
debug("updated match") debug("updated match")
return match return match
def is_match(self, player_a, player_b): def is_match(self, player_a, player_b) -> bool:
query = "SELECT * FROM matches WHERE (user_a=%s AND user_b=%s) OR (user_a=%s AND user_b=%s)" query = "SELECT * FROM matches WHERE (user_a=%s AND user_b=%s) OR (user_a=%s AND user_b=%s)"
return len(DatabaseConnection.global_single_query(query, (player_a, player_b, player_b, player_a))) return len(DatabaseConnection.global_single_query(query, (player_a, player_b, player_b, player_a))) > 0
def delete_match(self, match_id): def delete_match(self, match_id: str) -> None:
query = "DELETE FROM matches WHERE id=%s" query = "DELETE FROM matches WHERE id=%s"
DatabaseConnection.global_single_execution(query, (match_id)) DatabaseConnection.global_single_execution(query, (match_id))

View File

@ -12,5 +12,8 @@ db_db = None
db_charset = 'utf8mb4' db_charset = 'utf8mb4'
# field dimension elo_start_value = 1000
elo_default_k = 20
# field dimension (warning: this constant is not constantly used)
n = 3 n = 3

View File

@ -1,5 +1,15 @@
import datetime import datetime
from settings import elo_default_k
def debug(msg): def debug(msg: str) -> None:
print("[" + datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + "]: " + msg) print("[" + datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + "]: " + msg)
def elo_p_win(player_elo: int, opponent_elo: int) -> float:
return (1 / (1 + 10**((opponent_elo - player_elo)/400)))
def elo_update(old_elo: int, single_game_result: float, expected_result: float, k: float = elo_default_k) -> int:
new_elo = old_elo + k * (single_game_result - expected_result)
return round(new_elo)

View File

@ -3,6 +3,7 @@ from user import User
import datetime import datetime
import hashlib import hashlib
import uuid import uuid
from settings import elo_start_value
class UserManager(object): class UserManager(object):
@ -41,19 +42,42 @@ class UserManager(object):
assert len(self.get_user(user_name)) == 0 assert len(self.get_user(user_name)) == 0
pw_salt = uuid.uuid4().hex pw_salt = uuid.uuid4().hex
pw_hash = hashlib.sha512(pw.encode() + pw_salt.encode()).hexdigest() pw_hash = hashlib.sha512(pw.encode() + pw_salt.encode()).hexdigest()
query = "INSERT INTO users (name, pw_hash, pw_salt, last_seen) VALUES ( %s, %s, %s, %s)" query = "INSERT INTO users (name, pw_hash, pw_salt, last_seen, elo) VALUES ( %s, %s, %s, %s, %s)"
DatabaseConnection.global_single_execution(query, (user_name, pw_hash, pw_salt, datetime.datetime.now())) DatabaseConnection.global_single_execution(
query, (user_name, pw_hash, pw_salt, datetime.datetime.now(), elo_start_value))
def touch_user(self, user_name): def touch_user(self, user_name):
matches = self.get_user(user_name) matches = self.get_user(user_name)
assert len(matches) == 1 assert len(matches) == 1
query = "UPDATE users SET last_seen=%s WHERE name=%s" query = "UPDATE users SET last_seen=%s WHERE name=%s"
DatabaseConnection.global_single_execution(query, (datetime.datetime.now(), user_name)) DatabaseConnection.global_single_execution(
query, (datetime.datetime.now(), user_name))
def update_elo(self, user_name, new_elo):
query = "UPDATE users SET elo=%s WHERE name=%s"
DatabaseConnection.global_single_execution(query, (new_elo, user_name))
def get_elo(self, user_name):
query = "SELECT elo FROM users WHERE name=%s"
q = DatabaseConnection.global_single_query(query, (user_name))
if len(q) > 0:
return q[0]['elo']
return None
def get_average_elo(self):
query = "SELECT AVG(elo) AS average FROM users"
q = DatabaseConnection.global_single_query(query)
if len(q) > 0:
return float(q[0]['average'])
return None
def add_friend_to_user(self, user_name, friend_name): def add_friend_to_user(self, user_name, friend_name):
if len(self.get_user(friend_name)) > 0: if len(self.get_user(friend_name)) > 0:
query = "INSERT INTO friends (user, friend) VALUES ( %s, %s)" query = "INSERT INTO friends (user, friend) VALUES ( %s, %s)"
DatabaseConnection.global_single_execution(query, (user_name, friend_name)) DatabaseConnection.global_single_execution(
query, (user_name, friend_name))
return True return True
return False return False
@ -63,13 +87,23 @@ class UserManager(object):
friends = set() friends = set()
for entry in tmp: for entry in tmp:
friends.add(entry['friend']) friends.add(entry['friend'])
return friends return list(friends)
def get_friends_and_elos_for_user(self, user_name):
query = "SELECT friends.friend AS friend, users.elo AS elo FROM friends JOIN users ON friends.friend=users.name WHERE friends.user=%s"
tmp = DatabaseConnection.global_single_query(query, (user_name))
friends = []
elos = []
for entry in tmp:
friends.append(entry['friend'])
elos.append(entry['elo'])
return friends, elos
def remove_friend_from_user(self, user_name, friend_name): def remove_friend_from_user(self, user_name, friend_name):
query = "DELETE FROM friends WHERE user=%s AND friend=%s" query = "DELETE FROM friends WHERE user=%s AND friend=%s"
DatabaseConnection.global_single_execution(query, (user_name, friend_name)) DatabaseConnection.global_single_execution(
query, (user_name, friend_name))
def get_all_users(self): def get_all_users(self):
query = "SELECT name, last_seen FROM users" query = "SELECT name, last_seen FROM users"
return DatabaseConnection.global_single_query(query) return DatabaseConnection.global_single_query(query)