Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import datetime

from comprl.server.data.sql_backend import Base
from comprl.server.data.models import Base

import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
Expand Down
2 changes: 2 additions & 0 deletions comprl/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Matchmaking parameters can now be reloaded from the config file at runtime by sending
a SIGHUP signal to the server.
- Optional score decay that gradually increases the sigma rating of inactive users.
- Log all rating changes of users (after games or due to score decay). This may be
interesting for analysis, e.g. how much the leaderboard fluctuates.


## [0.1.0]
Expand Down
7 changes: 5 additions & 2 deletions comprl/src/comprl/scripts/create_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ def main() -> int:
db_path = config["database_path"]

if pathlib.Path(db_path).exists():
print(f"ERROR: Database '{db_path}' already exists.", file=sys.stderr)
return 1
key = input(f"Database '{db_path}' already exists. Continue? [yN] ")
if key.lower() != "y":
print("Aborting.")
return 1

create_database_tables(db_path)

return 0
Expand Down
2 changes: 1 addition & 1 deletion comprl/src/comprl/scripts/reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def reset_games():

def reset_elo():
"""reset the elo in the user database: set mu=25.000 and sigma=8.333"""
UserData.reset_all_matchmaking_parameters()
UserData.reset_all_ratings()
logging.info(
"The matchmaking parameters have been reset to default values for all users."
)
Expand Down
23 changes: 15 additions & 8 deletions comprl/src/comprl/server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import sqlalchemy as sa

from comprl.server import config, networking
from comprl.server.data import get_session, init_engine, User, Game
from comprl.server.data import get_session, init_engine, User, Game, UserData
from comprl.server.data.models import DEFAULT_SIGMA
from comprl.server.managers import GameManager, PlayerManager, MatchmakingManager
from comprl.server.interfaces import IPlayer, IServer
Expand Down Expand Up @@ -176,19 +176,26 @@ def _score_decay(self) -> None:
log.info("Skip score decay (no games played).")
return

# get all users who didn't play in the last interval
# get all users who didn't play in the last interval and who aren't at the
# maximum sigma already
stmt = sa.select(User).where(
User.sigma < DEFAULT_SIGMA,
~sa.exists().where(
sa.and_(
Game.start_time >= cutoff,
sa.or_(Game.user1 == User.user_id, Game.user2 == User.user_id),
)
)
Game.start_time >= cutoff,
sa.or_(Game.user1 == User.user_id, Game.user2 == User.user_id),
),
)
inactive_users = session.scalars(stmt).all()

for user in inactive_users:
user.sigma = min(user.sigma + conf.score_decay.delta, DEFAULT_SIGMA)
new_sigma = min(user.sigma + conf.score_decay.delta, DEFAULT_SIGMA)
UserData.update_rating(
session,
user=user,
mu=user.mu,
sigma=new_sigma,
game_id=None,
)

session.commit()

Expand Down
1 change: 1 addition & 0 deletions comprl/src/comprl/server/data/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .models import (
User as User,
Game as Game,
RatingChangeLog as RatingChangeLog,
get_one as get_one,
)
from .sql_backend import (
Expand Down
18 changes: 18 additions & 0 deletions comprl/src/comprl/server/data/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ class Game(Base):
)


class RatingChangeLog(Base):
"""Log of score changes."""

__tablename__ = "rating_change_log"

id: Mapped[int] = mapped_column(init=False, primary_key=True)
timestamp: Mapped[datetime.datetime] = mapped_column(sa.DateTime)

user_id: Mapped[int] = mapped_column(sa.ForeignKey("users.user_id"))
user_: Mapped["User"] = relationship(init=False, foreign_keys=[user_id])

game_id: Mapped[int | None] = mapped_column(sa.ForeignKey("games.id"))
game_: Mapped["Game"] = relationship(init=False, foreign_keys=[game_id])

new_mu: Mapped[float]
new_sigma: Mapped[float]


def create_database_tables(db_path: str) -> None:
"""Create the database tables in the given SQLite database."""
engine = sa.create_engine(f"sqlite:///{db_path}")
Expand Down
62 changes: 59 additions & 3 deletions comprl/src/comprl/server/data/sql_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@

import itertools
import os
from datetime import datetime
from typing import Sequence

import bcrypt
import sqlalchemy as sa

from comprl.server.data.interfaces import GameEndState, GameResult, UserRole
from comprl.server.data.models import Game, User, DEFAULT_MU, DEFAULT_SIGMA
from comprl.server.data.models import (
Game,
RatingChangeLog,
User,
DEFAULT_MU,
DEFAULT_SIGMA,
get_one,
)
from comprl.shared.types import GameID


_engine: sa.Engine | None = None
Expand Down Expand Up @@ -243,7 +252,7 @@ def get_user_by_token(access_token: str) -> User | None:
return user

@staticmethod
def get_matchmaking_parameters(user_id: int) -> tuple[float, float]:
def get_rating(user_id: int) -> tuple[float, float]:
"""
Retrieves the matchmaking parameters of a user based on their ID.

Expand All @@ -261,7 +270,54 @@ def get_matchmaking_parameters(user_id: int) -> tuple[float, float]:
return user.mu, user.sigma

@staticmethod
def reset_all_matchmaking_parameters() -> None:
def update_rating(
session: sa.orm.Session,
*,
user: int | User,
mu: float,
sigma: float,
game_id: GameID | None,
) -> None:
"""Update the rating of a user.

Updates the my and sigma rating of a user to the new values. Also logs the
change in the score change log table.

Args:
session: The database session.
user: The ID of the user or an already loaded user instance.
mu: The new mu value.
sigma: The new sigma value.
game_id: The ID of the game that caused the rating change (if any).
"""
if isinstance(user, User):
_user = user
else:
_user = get_one(session, User, user)

_user.mu = mu
_user.sigma = sigma

if game_id is not None:
# need to get the database ID of the game (not the UUID one)
game_db_id = session.execute(
sa.select(Game.id).where(Game.game_id == str(game_id))
).scalar_one()
else:
game_db_id = None

session.add(
RatingChangeLog(
timestamp=datetime.now(),
user_id=_user.user_id,
game_id=game_db_id,
new_mu=mu,
new_sigma=sigma,
)
)

@staticmethod
def reset_all_ratings() -> None:
"""Resets the matchmaking parameters of all users."""
with get_session() as session:
session.query(User).update({"mu": DEFAULT_MU, "sigma": DEFAULT_SIGMA})
Expand Down
26 changes: 21 additions & 5 deletions comprl/src/comprl/server/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@

from comprl.server.interfaces import IGame, IPlayer
from comprl.shared.types import GameID, PlayerID
from comprl.server.data import GameData, User, UserData, get_session, get_one
from comprl.server.data import (
GameData,
User,
UserData,
get_one,
get_session,
)
from comprl.server.data.interfaces import UserRole, GameEndState
from comprl.server.config import get_config

Expand Down Expand Up @@ -653,10 +659,20 @@ def _end_game(self, game: IGame) -> None:
scores=[result.score_user_1, result.score_user_2],
)

user1.mu = p1.mu
user1.sigma = p1.sigma
user2.mu = p2.mu
user2.sigma = p2.sigma
UserData.update_rating(
session,
user=user1,
mu=p1.mu,
sigma=p1.sigma,
game_id=result.game_id,
)
UserData.update_rating(
session,
user=user2,
mu=p2.mu,
sigma=p2.sigma,
game_id=result.game_id,
)

session.commit()

Expand Down
4 changes: 2 additions & 2 deletions comprl/tests/reset_database_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def test_reset(tmp_path):
)

# verify users are added correctly
mu, sigma = UserData.get_matchmaking_parameters(user_id=userID1)
mu, sigma = UserData.get_rating(user_id=userID1)
assert pytest.approx(mu) == 24.0
assert pytest.approx(sigma) == 9.333

Expand Down Expand Up @@ -79,7 +79,7 @@ def test_reset(tmp_path):

# test
for user_id in (userID1, userID2, userID3, userID4):
mu, sigma = UserData.get_matchmaking_parameters(user_id=user_id)
mu, sigma = UserData.get_rating(user_id=user_id)
assert pytest.approx(mu) == 25.0, f"user_id: {user_id}"
assert pytest.approx(sigma) == 8.333, f"user_id: {user_id}"

Expand Down
4 changes: 2 additions & 2 deletions comprl/tests/user_database_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ def test_user_data(tmp_path):
assert _user.username == user[0]

set_matchmaking_parameters(user_id=user_ids[1], mu=23.0, sigma=3.0)
mu0, sigma0 = UserData.get_matchmaking_parameters(user_ids[0])
mu1, sigma1 = UserData.get_matchmaking_parameters(user_ids[1])
mu0, sigma0 = UserData.get_rating(user_ids[0])
mu1, sigma1 = UserData.get_rating(user_ids[1])

# user0 was unmodified, so here the default values should be returned
assert pytest.approx(mu0) == 25.0
Expand Down