elo system
This commit is contained in:
parent
84b360e8ed
commit
dc23ff502b
109
README.md
109
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": "<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>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -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)
|
||||
}
|
||||
}))
|
||||
|
||||
|
2
main.py
2
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)
|
||||
|
||||
|
||||
|
45
match.py
45
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
|
||||
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
|
12
tools.py
12
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)
|
||||
|
@ -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)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user