diff --git a/README.md b/README.md index 82b1743..d180c01 100644 --- a/README.md +++ b/README.md @@ -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": "", - "name": "" - } -} -``` - -response: - -```JSON -{ - "type": "register_response", - "data": { - "success": true, - "msg": "" - } -} -``` - - - -**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: **json match state:** @@ -107,9 +21,10 @@ response: "y": "..." } game_over: , - player_won: > - current_player: > - player_a: "..." + is_draw: , + player_won: >, + current_player: >, + player_a: "...", player_b: "..." } ``` @@ -341,7 +256,21 @@ response: { "type": "friends_update", "data": { - "friends": "" + "friends": "", + "elos": "" + } +} +``` + + + +**elo rank update**: + +```json +{ + "type": "elo_update", + "data": { + "elo": } } ``` diff --git a/connection_handler.py b/connection_handler.py index 27dca22..82cce3d 100644 --- a/connection_handler.py +++ b/connection_handler.py @@ -6,7 +6,7 @@ from user_manager import UserManager from match_manager import MatchManager from match import Match -from tools import debug +from tools import debug, elo_p_win, elo_update def parse_message(msg: str): @@ -133,6 +133,7 @@ class ConnectionHandler(object): "msg": "" } })) + await self.send_elo(conn) await self.send_friends(conn) await self._on_match_state_req(conn, None) return conn @@ -234,6 +235,7 @@ class ConnectionHandler(object): })) if success: + await self.send_elo(conn) await self.send_friends(conn) await self._on_match_state_req(conn, None) @@ -355,7 +357,7 @@ class ConnectionHandler(object): ) ) return - + if (self.match_manager.is_match(conn.user_name, opponent)): await conn.websocket.send( json.dumps( @@ -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 if other_user in self.open_connections_by_user: @@ -457,6 +464,9 @@ class ConnectionHandler(object): })) if match.game_over: 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): match = None @@ -468,6 +478,36 @@ class ConnectionHandler(object): if (match is None): 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_state = match.to_json_state() @@ -567,12 +607,23 @@ class ConnectionHandler(object): await self.send_friends(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({ 'type': 'friends_update', '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) } })) diff --git a/main.py b/main.py index fa26284..2c1137e 100644 --- a/main.py +++ b/main.py @@ -16,7 +16,7 @@ from tools import debug um = UserManager() sm = SessionManager(datetime.timedelta(hours=12)) -mm = MatchManager() +mm = MatchManager(user_manager=um) ch = ConnectionHandler(sm, um, mm) diff --git a/match.py b/match.py index 4c7e43c..7d74ccc 100644 --- a/match.py +++ b/match.py @@ -29,19 +29,20 @@ def SimpleDecode(jsonDump): return np.array(json.loads(jsonDump)) -FIELD_EMPTY = 0 -FIELD_USER_A = 1 -FIELD_USER_B = 2 -FIELD_DRAW = 3 - - 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): self.n = n self.id = match_id self.complete_field = np.zeros(shape=(n*n, n*n), dtype=int) self.global_field = np.zeros(shape=(n, n), dtype=int) self.player_won = None + self.is_draw = False self.game_over = False self.last_move = None self.is_player_a = True @@ -59,9 +60,15 @@ class Match(object): self.game_over = match_obj['game_over'] self.last_move = match_obj['last_move'] 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): - match_obj = { + return json.dumps(self.to_dict_state()) + + def to_dict_state(self): + return { 'complete_field': self.complete_field.tolist(), 'global_field': self.global_field.tolist(), 'last_move': self.last_move, @@ -69,11 +76,10 @@ class Match(object): 'player_won': self.player_won, 'active_player': self.player_a_name if self.is_player_a else self.player_b_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): tmp = self.player_a_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_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! 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 - 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 True 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): is_col = True @@ -157,7 +163,7 @@ class Match(object): abs_x = sub_x * self.n + x 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): 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} # 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") 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 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): self.game_over = True self.player_won = None + self.is_draw = True self.is_player_a = not self.is_player_a diff --git a/match_manager.py b/match_manager.py index 1451e34..8c66de2 100644 --- a/match_manager.py +++ b/match_manager.py @@ -1,18 +1,20 @@ from database_connection import DatabaseConnection, SQLInjectionError, get_sql_time +from user_manager import UserManager from user import User import datetime import uuid import settings from match import Match -from tools import debug +from tools import debug, elo_p_win, elo_update class MatchManager(object): - def __init__(self): + def __init__(self, user_manager: UserManager = None): + self.user_manager = user_manager pass - def get_match(self, id): + def get_match(self, id: str) -> Match: query = "SELECT * FROM matches WHERE id=%s" result = DatabaseConnection.global_single_query(query, (id)) if len(result) == 0: @@ -21,11 +23,11 @@ class MatchManager(object): player_b_name=result[0]['user_b'], json_state=result[0]['match_state']) 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" 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 # 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: @@ -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))) 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()) query = "UPDATE matches SET match_state=%s, active_user=%s, last_active=%s WHERE id=%s" DatabaseConnection.global_single_execution( 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']) if match is None: return None @@ -58,10 +104,10 @@ class MatchManager(object): debug("updated 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)" - 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" DatabaseConnection.global_single_execution(query, (match_id)) diff --git a/settings.py b/settings.py index 971723c..edc7d75 100644 --- a/settings.py +++ b/settings.py @@ -12,5 +12,8 @@ db_db = None db_charset = 'utf8mb4' -# field dimension +elo_start_value = 1000 +elo_default_k = 20 + +# field dimension (warning: this constant is not constantly used) n = 3 diff --git a/tools.py b/tools.py index 38d9228..f4b1c32 100644 --- a/tools.py +++ b/tools.py @@ -1,5 +1,15 @@ 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) + + +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) diff --git a/user_manager.py b/user_manager.py index 90402e4..422eedd 100644 --- a/user_manager.py +++ b/user_manager.py @@ -3,6 +3,7 @@ from user import User import datetime import hashlib import uuid +from settings import elo_start_value class UserManager(object): @@ -41,35 +42,68 @@ class UserManager(object): assert len(self.get_user(user_name)) == 0 pw_salt = uuid.uuid4().hex 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)" - DatabaseConnection.global_single_execution(query, (user_name, pw_hash, pw_salt, datetime.datetime.now())) - + 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(), elo_start_value)) + def touch_user(self, user_name): matches = self.get_user(user_name) assert len(matches) == 1 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): if len(self.get_user(friend_name)) > 0: 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 False - + def get_friends_for_user(self, user_name): query = "SELECT friend FROM friends WHERE user=%s" tmp = DatabaseConnection.global_single_query(query, (user_name)) friends = set() for entry in tmp: 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): 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): query = "SELECT name, last_seen FROM users" return DatabaseConnection.global_single_query(query) -