Merge branch 'feature/elo_system' of jonas/ultimate_tictactoe_server into master

This commit is contained in:
Jonas Weinz 2019-03-29 11:53:50 +00:00 committed by Gogs
commit 7543133d71
10 changed files with 220 additions and 138 deletions

2
.gitignore vendored
View File

@ -58,3 +58,5 @@ docs/_build/
# PyBuilder
target/
# publishing scripts:
push_to_test.sh

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:
**json match state:**
@ -107,9 +21,10 @@ response:
"y": "..."
}
game_over: <true | false>,
player_won: <null | <player_name>>
current_player: <null | <player_name>>
player_a: "..."
is_draw: <true | false>,
player_won: <null | <player_name>>,
current_player: <null | <player_name>>,
player_a: "...",
player_b: "..."
}
```
@ -341,7 +256,21 @@ response:
{
"type": "friends_update",
"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 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)
}
}))

View File

@ -19,7 +19,7 @@ def create_tables():
"DROP TABLE IF EXISTS sessions",
"DROP TABLE IF EXISTS users",
"DROP TABLE IF EXISTS friends",
"CREATE TABLE users (name varchar(16) NOT NULL, pw_hash varchar(128) NOT NULL, pw_salt varchar(32) NOT NULL, last_seen datetime NOT NULL, PRIMARY KEY (name)) CHARACTER SET " + settings.db_charset,
"CREATE TABLE users (name varchar(16) NOT NULL, pw_hash varchar(128) NOT NULL, pw_salt varchar(32) NOT NULL, last_seen datetime NOT NULL, elo int NOT NULL, PRIMARY KEY (name)) CHARACTER SET " + settings.db_charset,
"CREATE TABLE matches (id varchar(32) NOT NULL, user_a varchar(16) NOT NULL, user_b varchar(16) NOT NULL, match_state varchar(4096) NOT NULL, active_user varchar(16), last_active datetime NOT NULL, FOREIGN KEY (user_a) REFERENCES users(name), FOREIGN KEY (user_b) REFERENCES users(name), FOREIGN KEY (active_user) REFERENCES users(name)) CHARACTER SET " + settings.db_charset,
"CREATE TABLE sessions (id varchar(32) NOT NULL, registered_user varchar(16), temp_user varchar(16), last_seen datetime NOT NULL, PRIMARY KEY (id), FOREIGN KEY(registered_user) REFERENCES users(name)) CHARACTER SET " + settings.db_charset,
"CREATE TABLE friends (user varchar(16) NOT NULL, friend varchar(16) NOT NULL, FOREIGN KEY(user) REFERENCES users(name), FOREIGN KEY(friend) REFERENCES users(name)) CHARACTER SET " + settings.db_charset

View File

@ -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)

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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)

View File

@ -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)