Skip to content

Commit

Permalink
Issue/#724 generalize rating initialization (#740)
Browse files Browse the repository at this point in the history
* Make server shutdown cleaner

* Load leaderboard data from database

* Generalize rating creation based on existing ratings

* Make test complete faster

* Reimplement rating init to detect cycles, and stop checking legacy tables

Leaderboards can now have arbitrary initializers and initialization
should happen as expected. Ratings are initialized recursively until either
the current rating has no initializer, or a cycle is detected. Any ratings
that are implicitly created during this process are marked as transient and
will be recomputed whenever they are queried. This ensures we are always using
the latest version of the initializer rating when the first rating for a new
leaderboard is saved. For instance if tmm_2v2 is initialized from global, and
a player new to the matchmaker queues for 2v2, their 2v2 rating will be
initialized based on their global rating, and marked as transient. If the
player then cancels the queue and plays a bunch of global games (changing
their global rating), when they go to queue for 2v2 again, their 2v2 rating
will be reinitialized based on the updated global rating (instead of using the
cached version).

Legacy rating tables were migrated into the new leaderboard_rating table
in database migration v98. Therefore we can delete the code that checks
those legacy tables.

* Mark explicit initialization of global and ladder as deprecated

* Fix deviation propagation for initialization chains

Deviation will now only be increased once when initializing a long chain of
ratings

Co-authored-by: BlackYps <[email protected]>
  • Loading branch information
Askaholic and BlackYps authored Aug 28, 2021
1 parent 716717f commit eb61418
Show file tree
Hide file tree
Showing 16 changed files with 471 additions and 217 deletions.
3 changes: 2 additions & 1 deletion server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ def __init__(
players=self.services["player_service"],
ladder_service=self.services["ladder_service"],
party_service=self.services["party_service"],
oauth_service=self.services["oauth_service"]
rating_service=self.services["rating_service"],
oauth_service=self.services["oauth_service"],
)

def write_broadcast(
Expand Down
3 changes: 2 additions & 1 deletion server/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,8 @@
leaderboard = Table(
"leaderboard", metadata,
Column("id", Integer, primary_key=True),
Column("technical_name", String, nullable=False, unique=True),
Column("technical_name", String, nullable=False, unique=True),
Column("initializer_id", Integer, ForeignKey("leaderboard.id")),
)

leaderboard_rating = Table(
Expand Down
8 changes: 6 additions & 2 deletions server/lobbyconnection.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
from .players import Player, PlayerState
from .protocol import DisconnectedError, Protocol
from .rating import InclusiveRange, RatingType
from .rating_service import RatingService
from .types import Address, GameLaunchOptions


Expand All @@ -71,7 +72,8 @@ def __init__(
geoip: GeoIpService,
ladder_service: LadderService,
party_service: PartyService,
oauth_service: OAuthService
rating_service: RatingService,
oauth_service: OAuthService,
):
self._db = database
self.geoip_service = geoip
Expand All @@ -81,6 +83,7 @@ def __init__(
self.coturn_generator = CoturnHMAC(config.COTURN_HOSTS, config.COTURN_KEYS)
self.ladder_service = ladder_service
self.party_service = party_service
self.rating_service = rating_service
self.oauth_service = oauth_service
self._authenticated = False
self.player = None # type: Player
Expand Down Expand Up @@ -603,7 +606,8 @@ async def on_player_login(
login=username,
session=self.session,
player_id=player_id,
lobby_connection=self
lobby_connection=self,
leaderboards=self.rating_service.leaderboards
)

old_player = self.player_service.get_player(self.player.id)
Expand Down
3 changes: 3 additions & 0 deletions server/matchmaker/matchmaker_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,15 @@ async def queue_pop_timer(self) -> None:
# match this round and will have higher priority next round.

self.game_service.mark_dirty(self)
except asyncio.CancelledError:
break
except Exception:
self._logger.exception(
"Unexpected error during queue pop timer loop!"
)
# To avoid potential busy loops
await asyncio.sleep(1)
self._logger.info("%s queue stopped", self.name)

async def search(self, search: Search) -> None:
"""
Expand Down
14 changes: 6 additions & 8 deletions server/players.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@
Player type definitions
"""

from collections import defaultdict
from contextlib import suppress
from enum import Enum, unique
from typing import Optional, Union

from server.config import config
from server.rating import PlayerRatings, RatingType, RatingTypeMap
from typing import Dict, Optional, Union

from .factions import Faction
from .protocol import DisconnectedError
from .rating import Leaderboard, PlayerRatings, RatingType
from .weakattr import WeakAttribute


Expand Down Expand Up @@ -41,6 +40,7 @@ def __init__(
login: str = None,
session: int = 0,
player_id: int = 0,
leaderboards: Dict[str, Leaderboard] = {},
ratings=None,
clan=None,
game_count=None,
Expand All @@ -54,13 +54,11 @@ def __init__(
# The player_id of the user in the `login` table of the database.
self.session = session

self.ratings = PlayerRatings(
lambda: (config.START_RATING_MEAN, config.START_RATING_DEV)
)
self.ratings = PlayerRatings(leaderboards)
if ratings is not None:
self.ratings.update(ratings)

self.game_count = RatingTypeMap(int)
self.game_count = defaultdict(int)
if game_count is not None:
self.game_count.update(game_count)

Expand Down
148 changes: 109 additions & 39 deletions server/rating.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,128 @@
Type definitions for player ratings
"""

from typing import DefaultDict, Optional, Tuple, TypeVar, Union
from dataclasses import dataclass
from typing import Dict, Optional, Set, Tuple, TypeVar, Union

from trueskill import Rating
import trueskill

from server.config import config
from server.weakattr import WeakAttribute

# Values correspond to legacy table names. This will be fixed when db gets
# migrated.
Rating = Tuple[float, float]
V = TypeVar("V")


@dataclass(init=False)
class Leaderboard():
id: int
technical_name: str
# Need the type annotation here so that the dataclass decorator sees it as
# a field and includes it in generated methods (such as __eq__)
initializer: WeakAttribute["Leaderboard"] = WeakAttribute["Leaderboard"]()

def __init__(
self,
id: int,
technical_name: str,
initializer: Optional["Leaderboard"] = None
):
self.id = id
self.technical_name = technical_name
if initializer:
self.initializer = initializer

def __repr__(self) -> str:
initializer = self.initializer
initializer_name = "None"
if initializer:
initializer_name = initializer.technical_name
return (
f"{self.__class__.__name__}("
f"id={self.id}, technical_name={self.technical_name}, "
f"initializer={initializer_name})"
)


# Some places have references to these ratings hardcoded.
class RatingType():
GLOBAL = "global"
LADDER_1V1 = "ladder_1v1"
TMM_2V2 = "tmm_2v2"


K = Union[RatingType, str]
V = TypeVar("V")
class PlayerRatings(Dict[str, Rating]):
def __init__(self, leaderboards: Dict[str, Leaderboard], init: bool = True):
self.leaderboards = leaderboards
# Rating types which are present but should be recomputed.
self.transient: Set[str] = set()

# DEPRECATED: Initialize known rating types so the client can display them
if init:
_ = self[RatingType.GLOBAL]
_ = self[RatingType.LADDER_1V1]

def __setitem__(
self,
rating_type: str,
value: Union[Rating, trueskill.Rating],
) -> None:
if isinstance(value, trueskill.Rating):
rating = (value.mu, value.sigma)
else:
rating = value

self.transient.discard(rating_type)
super().__setitem__(rating_type, rating)

class RatingTypeMap(DefaultDict[K, V]):
"""
A thin wrapper around `defaultdict` which stores RatingType keys as strings.
"""
def __init__(self, default_factory, *args, **kwargs):
super().__init__(default_factory, *args, **kwargs)
def __getitem__(
self,
rating_type: str,
history: Optional[Set[str]] = None,
) -> Rating:
history = history or set()
entry = self.get(rating_type)

# Initialize defaults for enumerated rating types
for rating in (RatingType.GLOBAL, RatingType.LADDER_1V1):
self.__getitem__(rating)
if entry is None or rating_type in self.transient:
# Check for cycles
if rating_type in history:
return default_rating()

rating = self._get_initial_rating(rating_type, history=history)

# Only used to coerce rating type.
class PlayerRatings(RatingTypeMap[Tuple[float, float]]):
def __setitem__(self, key: K, value: Tuple[float, float]) -> None:
if isinstance(value, Rating):
val = (value.mu, value.sigma)
else:
val = value
super().__setitem__(key, val)

def __getitem__(self, key: K) -> Tuple[float, float]:
# TODO: Generalize for arbitrary ratings
# https://github.com/FAForever/server/issues/727
if key == RatingType.TMM_2V2 and key not in self:
mean, dev = self[RatingType.GLOBAL]
if dev > 250:
tmm_2v2_rating = (mean, dev)
else:
tmm_2v2_rating = (mean, min(dev + 150, 250))

self[key] = tmm_2v2_rating
return tmm_2v2_rating
else:
return super().__getitem__(key)
self.transient.add(rating_type)
super().__setitem__(rating_type, rating)
return rating

return super().__getitem__(rating_type)

def _get_initial_rating(
self,
rating_type: str,
history: Set[str],
) -> Rating:
"""Create an initial rating when no rating exists yet."""
leaderboard = self.leaderboards.get(rating_type)
if leaderboard is None or leaderboard.initializer is None:
return default_rating()

history.add(rating_type)
init_rating_type = leaderboard.initializer.technical_name
mean, dev = self.__getitem__(init_rating_type, history=history)

if dev > 250 or init_rating_type in self.transient:
return (mean, dev)

return (mean, min(dev + 150, 250))

def update(self, other: Dict[str, Rating]):
self.transient -= set(other)
if isinstance(other, PlayerRatings):
self.transient |= other.transient
super().update(other)


def default_rating() -> Rating:
return (config.START_RATING_MEAN, config.START_RATING_DEV)


class InclusiveRange():
Expand Down
Loading

0 comments on commit eb61418

Please sign in to comment.