v3.1.0: Invite-gated auth, Glicko-2 ratings, matchmaking queue
- Enforce invite codes on registration (INVITE_ONLY=true by default) - Bootstrap admin account for first-time setup - Require authentication for WebSocket connections and room creation - Add Glicko-2 rating system with multiplayer pairwise comparisons - Add Redis-backed matchmaking queue with expanding rating window - Auto-start matched games with standard rules after countdown - Add "Find Game" button and matchmaking UI to client - Add rating column to leaderboard - Scale down docker-compose.prod.yml for 512MB droplet Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
393
server/services/matchmaking.py
Normal file
393
server/services/matchmaking.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
Matchmaking service for public skill-based games.
|
||||
|
||||
Uses Redis sorted sets to maintain a queue of players looking for games,
|
||||
grouped by rating. A background task periodically scans the queue and
|
||||
creates matches when enough similar-skill players are available.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedPlayer:
|
||||
"""A player waiting in the matchmaking queue."""
|
||||
user_id: str
|
||||
username: str
|
||||
rating: float
|
||||
queued_at: float # time.time()
|
||||
connection_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchmakingConfig:
|
||||
"""Configuration for the matchmaking system."""
|
||||
enabled: bool = True
|
||||
min_players: int = 2
|
||||
max_players: int = 4
|
||||
initial_rating_window: int = 100 # +/- rating range to start
|
||||
expand_interval: int = 15 # seconds between range expansions
|
||||
expand_amount: int = 50 # rating points to expand by
|
||||
max_rating_window: int = 500 # maximum +/- range
|
||||
match_check_interval: float = 3.0 # seconds between match attempts
|
||||
countdown_seconds: int = 5 # countdown before matched game starts
|
||||
|
||||
|
||||
class MatchmakingService:
|
||||
"""
|
||||
Manages the matchmaking queue and creates matches.
|
||||
|
||||
Players join the queue with their rating. A background task
|
||||
periodically scans for groups of similarly-rated players and
|
||||
creates games when matches are found.
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client, config: Optional[MatchmakingConfig] = None):
|
||||
self.redis = redis_client
|
||||
self.config = config or MatchmakingConfig()
|
||||
self._queue: dict[str, QueuedPlayer] = {} # user_id -> QueuedPlayer
|
||||
self._websockets: dict[str, WebSocket] = {} # user_id -> WebSocket
|
||||
self._connection_ids: dict[str, str] = {} # user_id -> connection_id
|
||||
self._running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
|
||||
async def join_queue(
|
||||
self,
|
||||
user_id: str,
|
||||
username: str,
|
||||
rating: float,
|
||||
websocket: WebSocket,
|
||||
connection_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Add a player to the matchmaking queue.
|
||||
|
||||
Returns:
|
||||
Queue status dict.
|
||||
"""
|
||||
if user_id in self._queue:
|
||||
return {"position": self._get_position(user_id), "queue_size": len(self._queue)}
|
||||
|
||||
player = QueuedPlayer(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
rating=rating,
|
||||
queued_at=time.time(),
|
||||
connection_id=connection_id,
|
||||
)
|
||||
|
||||
self._queue[user_id] = player
|
||||
self._websockets[user_id] = websocket
|
||||
self._connection_ids[user_id] = connection_id
|
||||
|
||||
# Also add to Redis for persistence across restarts
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.zadd("matchmaking:queue", {user_id: rating})
|
||||
await self.redis.hset(
|
||||
"matchmaking:players",
|
||||
user_id,
|
||||
json.dumps({
|
||||
"username": username,
|
||||
"rating": rating,
|
||||
"queued_at": player.queued_at,
|
||||
"connection_id": connection_id,
|
||||
}),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis matchmaking write failed: {e}")
|
||||
|
||||
position = self._get_position(user_id)
|
||||
logger.info(f"Player {username} ({user_id[:8]}) joined queue (rating={rating:.0f}, pos={position})")
|
||||
|
||||
return {"position": position, "queue_size": len(self._queue)}
|
||||
|
||||
async def leave_queue(self, user_id: str) -> bool:
|
||||
"""Remove a player from the matchmaking queue."""
|
||||
if user_id not in self._queue:
|
||||
return False
|
||||
|
||||
player = self._queue.pop(user_id, None)
|
||||
self._websockets.pop(user_id, None)
|
||||
self._connection_ids.pop(user_id, None)
|
||||
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.zrem("matchmaking:queue", user_id)
|
||||
await self.redis.hdel("matchmaking:players", user_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis matchmaking remove failed: {e}")
|
||||
|
||||
if player:
|
||||
logger.info(f"Player {player.username} ({user_id[:8]}) left queue")
|
||||
|
||||
return True
|
||||
|
||||
async def get_queue_status(self, user_id: str) -> dict:
|
||||
"""Get current queue status for a player."""
|
||||
if user_id not in self._queue:
|
||||
return {"in_queue": False}
|
||||
|
||||
player = self._queue[user_id]
|
||||
wait_time = time.time() - player.queued_at
|
||||
current_window = self._get_rating_window(wait_time)
|
||||
|
||||
return {
|
||||
"in_queue": True,
|
||||
"position": self._get_position(user_id),
|
||||
"queue_size": len(self._queue),
|
||||
"wait_time": int(wait_time),
|
||||
"rating_window": current_window,
|
||||
}
|
||||
|
||||
async def find_matches(self, room_manager, broadcast_game_state_fn) -> list[dict]:
|
||||
"""
|
||||
Scan the queue and create matches.
|
||||
|
||||
Returns:
|
||||
List of match info dicts for matches created.
|
||||
"""
|
||||
if len(self._queue) < self.config.min_players:
|
||||
return []
|
||||
|
||||
matches_created = []
|
||||
matched_user_ids = set()
|
||||
|
||||
# Sort players by rating
|
||||
sorted_players = sorted(self._queue.values(), key=lambda p: p.rating)
|
||||
|
||||
for player in sorted_players:
|
||||
if player.user_id in matched_user_ids:
|
||||
continue
|
||||
|
||||
wait_time = time.time() - player.queued_at
|
||||
window = self._get_rating_window(wait_time)
|
||||
|
||||
# Find compatible players
|
||||
candidates = []
|
||||
for other in sorted_players:
|
||||
if other.user_id == player.user_id or other.user_id in matched_user_ids:
|
||||
continue
|
||||
if abs(other.rating - player.rating) <= window:
|
||||
candidates.append(other)
|
||||
|
||||
# Include the player themselves
|
||||
group = [player] + candidates
|
||||
|
||||
if len(group) >= self.config.min_players:
|
||||
# Take up to max_players
|
||||
match_group = group[:self.config.max_players]
|
||||
matched_user_ids.update(p.user_id for p in match_group)
|
||||
|
||||
# Create the match
|
||||
match_info = await self._create_match(match_group, room_manager)
|
||||
if match_info:
|
||||
matches_created.append(match_info)
|
||||
|
||||
return matches_created
|
||||
|
||||
async def _create_match(self, players: list[QueuedPlayer], room_manager) -> Optional[dict]:
|
||||
"""
|
||||
Create a room for matched players and notify them.
|
||||
|
||||
Returns:
|
||||
Match info dict, or None if creation failed.
|
||||
"""
|
||||
try:
|
||||
# Create room
|
||||
room = room_manager.create_room()
|
||||
|
||||
# Add all matched players to the room
|
||||
for player in players:
|
||||
ws = self._websockets.get(player.user_id)
|
||||
if not ws:
|
||||
continue
|
||||
|
||||
room.add_player(
|
||||
player.connection_id,
|
||||
player.username,
|
||||
ws,
|
||||
player.user_id,
|
||||
)
|
||||
|
||||
# Remove matched players from queue
|
||||
for player in players:
|
||||
await self.leave_queue(player.user_id)
|
||||
|
||||
# Notify all matched players
|
||||
match_info = {
|
||||
"room_code": room.code,
|
||||
"players": [
|
||||
{"username": p.username, "rating": round(p.rating)}
|
||||
for p in players
|
||||
],
|
||||
}
|
||||
|
||||
for player in players:
|
||||
ws = self._websockets.get(player.user_id)
|
||||
if ws:
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "queue_matched",
|
||||
"room_code": room.code,
|
||||
"players": match_info["players"],
|
||||
"countdown": self.config.countdown_seconds,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to notify matched player {player.user_id[:8]}: {e}")
|
||||
|
||||
# Also send room_joined to each player so the client switches screens
|
||||
for player in players:
|
||||
ws = self._websockets.get(player.user_id)
|
||||
if ws:
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "room_joined",
|
||||
"room_code": room.code,
|
||||
"player_id": player.connection_id,
|
||||
"authenticated": True,
|
||||
})
|
||||
# Send player list
|
||||
await ws.send_json({
|
||||
"type": "player_joined",
|
||||
"players": room.player_list(),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
avg_rating = sum(p.rating for p in players) / len(players)
|
||||
logger.info(
|
||||
f"Match created: room={room.code}, "
|
||||
f"players={[p.username for p in players]}, "
|
||||
f"avg_rating={avg_rating:.0f}"
|
||||
)
|
||||
|
||||
# Schedule auto-start after countdown
|
||||
asyncio.create_task(self._auto_start_game(room, self.config.countdown_seconds))
|
||||
|
||||
return match_info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create match: {e}")
|
||||
return None
|
||||
|
||||
async def _auto_start_game(self, room, countdown: int):
|
||||
"""Auto-start a matched game after countdown."""
|
||||
from game import GamePhase, GameOptions
|
||||
|
||||
await asyncio.sleep(countdown)
|
||||
|
||||
if room.game.phase != GamePhase.WAITING:
|
||||
return # Game already started or room closed
|
||||
|
||||
if len(room.players) < 2:
|
||||
return # Not enough players
|
||||
|
||||
# Standard rules for ranked games
|
||||
options = GameOptions()
|
||||
options.flip_mode = "never"
|
||||
options.initial_flips = 2
|
||||
|
||||
try:
|
||||
async with room.game_lock:
|
||||
room.game.start_game(1, 9, options) # 1 deck, 9 rounds, standard rules
|
||||
|
||||
# Send game started to all players
|
||||
for pid, rp in room.players.items():
|
||||
if rp.websocket and not rp.is_cpu:
|
||||
try:
|
||||
state = room.game.get_state(pid)
|
||||
await rp.websocket.send_json({
|
||||
"type": "game_started",
|
||||
"game_state": state,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"Auto-started matched game in room {room.code}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-start matched game: {e}")
|
||||
|
||||
def _get_rating_window(self, wait_time: float) -> int:
|
||||
"""Calculate the current rating window based on wait time."""
|
||||
expansions = int(wait_time / self.config.expand_interval)
|
||||
window = self.config.initial_rating_window + (expansions * self.config.expand_amount)
|
||||
return min(window, self.config.max_rating_window)
|
||||
|
||||
def _get_position(self, user_id: str) -> int:
|
||||
"""Get a player's position in the queue (1-indexed)."""
|
||||
sorted_ids = sorted(
|
||||
self._queue.keys(),
|
||||
key=lambda uid: self._queue[uid].queued_at,
|
||||
)
|
||||
try:
|
||||
return sorted_ids.index(user_id) + 1
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
async def start(self, room_manager, broadcast_fn):
|
||||
"""Start the matchmaking background task."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(
|
||||
self._matchmaking_loop(room_manager, broadcast_fn)
|
||||
)
|
||||
logger.info("Matchmaking service started")
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the matchmaking background task."""
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Matchmaking service stopped")
|
||||
|
||||
async def _matchmaking_loop(self, room_manager, broadcast_fn):
|
||||
"""Background task that periodically checks for matches."""
|
||||
while self._running:
|
||||
try:
|
||||
matches = await self.find_matches(room_manager, broadcast_fn)
|
||||
if matches:
|
||||
logger.info(f"Created {len(matches)} match(es)")
|
||||
|
||||
# Send queue status updates to all queued players
|
||||
for user_id in list(self._queue.keys()):
|
||||
ws = self._websockets.get(user_id)
|
||||
if ws:
|
||||
try:
|
||||
status = await self.get_queue_status(user_id)
|
||||
await ws.send_json({
|
||||
"type": "queue_status",
|
||||
**status,
|
||||
})
|
||||
except Exception:
|
||||
# Player disconnected, remove from queue
|
||||
await self.leave_queue(user_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Matchmaking error: {e}")
|
||||
|
||||
await asyncio.sleep(self.config.match_check_interval)
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up Redis queue data on shutdown."""
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.delete("matchmaking:queue")
|
||||
await self.redis.delete("matchmaking:players")
|
||||
except Exception:
|
||||
pass
|
||||
322
server/services/rating_service.py
Normal file
322
server/services/rating_service.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Glicko-2 rating service for Golf game matchmaking.
|
||||
|
||||
Implements the Glicko-2 rating system adapted for multiplayer games.
|
||||
Each game is treated as a set of pairwise comparisons between all players.
|
||||
|
||||
Reference: http://www.glicko.net/glicko/glicko2.pdf
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import asyncpg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Glicko-2 constants
|
||||
INITIAL_RATING = 1500.0
|
||||
INITIAL_RD = 350.0
|
||||
INITIAL_VOLATILITY = 0.06
|
||||
TAU = 0.5 # System constant (constrains volatility change)
|
||||
CONVERGENCE_TOLERANCE = 0.000001
|
||||
GLICKO2_SCALE = 173.7178 # Factor to convert between Glicko and Glicko-2 scales
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerRating:
|
||||
"""A player's Glicko-2 rating."""
|
||||
user_id: str
|
||||
rating: float = INITIAL_RATING
|
||||
rd: float = INITIAL_RD
|
||||
volatility: float = INITIAL_VOLATILITY
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def mu(self) -> float:
|
||||
"""Convert rating to Glicko-2 scale."""
|
||||
return (self.rating - 1500) / GLICKO2_SCALE
|
||||
|
||||
@property
|
||||
def phi(self) -> float:
|
||||
"""Convert RD to Glicko-2 scale."""
|
||||
return self.rd / GLICKO2_SCALE
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"rating": round(self.rating, 1),
|
||||
"rd": round(self.rd, 1),
|
||||
"volatility": round(self.volatility, 6),
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _g(phi: float) -> float:
|
||||
"""Glicko-2 g function."""
|
||||
return 1.0 / math.sqrt(1.0 + 3.0 * phi * phi / (math.pi * math.pi))
|
||||
|
||||
|
||||
def _E(mu: float, mu_j: float, phi_j: float) -> float:
|
||||
"""Glicko-2 expected score."""
|
||||
return 1.0 / (1.0 + math.exp(-_g(phi_j) * (mu - mu_j)))
|
||||
|
||||
|
||||
def _compute_variance(mu: float, opponents: list[tuple[float, float]]) -> float:
|
||||
"""
|
||||
Compute the estimated variance of the player's rating
|
||||
based on game outcomes.
|
||||
|
||||
opponents: list of (mu_j, phi_j) tuples
|
||||
"""
|
||||
v_inv = 0.0
|
||||
for mu_j, phi_j in opponents:
|
||||
g_phi = _g(phi_j)
|
||||
e = _E(mu, mu_j, phi_j)
|
||||
v_inv += g_phi * g_phi * e * (1.0 - e)
|
||||
if v_inv == 0:
|
||||
return float('inf')
|
||||
return 1.0 / v_inv
|
||||
|
||||
|
||||
def _compute_delta(mu: float, opponents: list[tuple[float, float, float]], v: float) -> float:
|
||||
"""
|
||||
Compute the estimated improvement in rating.
|
||||
|
||||
opponents: list of (mu_j, phi_j, score) tuples
|
||||
"""
|
||||
total = 0.0
|
||||
for mu_j, phi_j, score in opponents:
|
||||
total += _g(phi_j) * (score - _E(mu, mu_j, phi_j))
|
||||
return v * total
|
||||
|
||||
|
||||
def _new_volatility(sigma: float, phi: float, v: float, delta: float) -> float:
|
||||
"""Compute new volatility using the Illinois algorithm (Glicko-2 Step 5)."""
|
||||
a = math.log(sigma * sigma)
|
||||
delta_sq = delta * delta
|
||||
phi_sq = phi * phi
|
||||
|
||||
def f(x):
|
||||
ex = math.exp(x)
|
||||
num1 = ex * (delta_sq - phi_sq - v - ex)
|
||||
denom1 = 2.0 * (phi_sq + v + ex) ** 2
|
||||
return num1 / denom1 - (x - a) / (TAU * TAU)
|
||||
|
||||
# Set initial bounds
|
||||
A = a
|
||||
if delta_sq > phi_sq + v:
|
||||
B = math.log(delta_sq - phi_sq - v)
|
||||
else:
|
||||
k = 1
|
||||
while f(a - k * TAU) < 0:
|
||||
k += 1
|
||||
B = a - k * TAU
|
||||
|
||||
# Illinois algorithm
|
||||
f_A = f(A)
|
||||
f_B = f(B)
|
||||
|
||||
for _ in range(100): # Safety limit
|
||||
if abs(B - A) < CONVERGENCE_TOLERANCE:
|
||||
break
|
||||
C = A + (A - B) * f_A / (f_B - f_A)
|
||||
f_C = f(C)
|
||||
|
||||
if f_C * f_B <= 0:
|
||||
A = B
|
||||
f_A = f_B
|
||||
else:
|
||||
f_A /= 2.0
|
||||
|
||||
B = C
|
||||
f_B = f_C
|
||||
|
||||
return math.exp(A / 2.0)
|
||||
|
||||
|
||||
def update_rating(player: PlayerRating, opponents: list[tuple[float, float, float]]) -> PlayerRating:
|
||||
"""
|
||||
Update a single player's rating based on game results.
|
||||
|
||||
Args:
|
||||
player: Current player rating.
|
||||
opponents: List of (mu_j, phi_j, score) where score is 1.0 (win), 0.5 (draw), 0.0 (loss).
|
||||
|
||||
Returns:
|
||||
Updated PlayerRating.
|
||||
"""
|
||||
if not opponents:
|
||||
# No opponents - just increase RD for inactivity
|
||||
new_phi = math.sqrt(player.phi ** 2 + player.volatility ** 2)
|
||||
return PlayerRating(
|
||||
user_id=player.user_id,
|
||||
rating=player.rating,
|
||||
rd=min(new_phi * GLICKO2_SCALE, INITIAL_RD),
|
||||
volatility=player.volatility,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
mu = player.mu
|
||||
phi = player.phi
|
||||
sigma = player.volatility
|
||||
|
||||
opp_pairs = [(mu_j, phi_j) for mu_j, phi_j, _ in opponents]
|
||||
|
||||
v = _compute_variance(mu, opp_pairs)
|
||||
delta = _compute_delta(mu, opponents, v)
|
||||
|
||||
# New volatility
|
||||
new_sigma = _new_volatility(sigma, phi, v, delta)
|
||||
|
||||
# Update phi (pre-rating)
|
||||
phi_star = math.sqrt(phi ** 2 + new_sigma ** 2)
|
||||
|
||||
# New phi
|
||||
new_phi = 1.0 / math.sqrt(1.0 / (phi_star ** 2) + 1.0 / v)
|
||||
|
||||
# New mu
|
||||
improvement = 0.0
|
||||
for mu_j, phi_j, score in opponents:
|
||||
improvement += _g(phi_j) * (score - _E(mu, mu_j, phi_j))
|
||||
new_mu = mu + new_phi ** 2 * improvement
|
||||
|
||||
# Convert back to Glicko scale
|
||||
new_rating = new_mu * GLICKO2_SCALE + 1500
|
||||
new_rd = new_phi * GLICKO2_SCALE
|
||||
|
||||
# Clamp RD to reasonable range
|
||||
new_rd = max(30.0, min(new_rd, INITIAL_RD))
|
||||
|
||||
return PlayerRating(
|
||||
user_id=player.user_id,
|
||||
rating=max(100.0, new_rating), # Floor at 100
|
||||
rd=new_rd,
|
||||
volatility=new_sigma,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
class RatingService:
|
||||
"""
|
||||
Manages Glicko-2 ratings for players.
|
||||
|
||||
Ratings are only updated for standard-rules games.
|
||||
Multiplayer games are decomposed into pairwise comparisons.
|
||||
"""
|
||||
|
||||
def __init__(self, pool: asyncpg.Pool):
|
||||
self.pool = pool
|
||||
|
||||
async def get_rating(self, user_id: str) -> PlayerRating:
|
||||
"""Get a player's current rating."""
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT rating, rating_deviation, rating_volatility, rating_updated_at
|
||||
FROM player_stats
|
||||
WHERE user_id = $1
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
|
||||
if not row or row["rating"] is None:
|
||||
return PlayerRating(user_id=user_id)
|
||||
|
||||
return PlayerRating(
|
||||
user_id=user_id,
|
||||
rating=float(row["rating"]),
|
||||
rd=float(row["rating_deviation"]),
|
||||
volatility=float(row["rating_volatility"]),
|
||||
updated_at=row["rating_updated_at"],
|
||||
)
|
||||
|
||||
async def get_ratings_batch(self, user_ids: list[str]) -> dict[str, PlayerRating]:
|
||||
"""Get ratings for multiple players."""
|
||||
ratings = {}
|
||||
for uid in user_ids:
|
||||
ratings[uid] = await self.get_rating(uid)
|
||||
return ratings
|
||||
|
||||
async def update_ratings(
|
||||
self,
|
||||
player_results: list[tuple[str, int]],
|
||||
is_standard_rules: bool,
|
||||
) -> dict[str, PlayerRating]:
|
||||
"""
|
||||
Update ratings after a game.
|
||||
|
||||
Args:
|
||||
player_results: List of (user_id, total_score) for each human player.
|
||||
is_standard_rules: Whether the game used standard rules.
|
||||
|
||||
Returns:
|
||||
Dict of user_id -> updated PlayerRating.
|
||||
"""
|
||||
if not is_standard_rules:
|
||||
logger.debug("Skipping rating update for non-standard rules game")
|
||||
return {}
|
||||
|
||||
if len(player_results) < 2:
|
||||
logger.debug("Skipping rating update: fewer than 2 human players")
|
||||
return {}
|
||||
|
||||
# Get current ratings
|
||||
user_ids = [uid for uid, _ in player_results]
|
||||
current_ratings = await self.get_ratings_batch(user_ids)
|
||||
|
||||
# Sort by score (lower is better in Golf)
|
||||
sorted_results = sorted(player_results, key=lambda x: x[1])
|
||||
|
||||
# Build pairwise comparisons for each player
|
||||
updated_ratings = {}
|
||||
for uid, score in player_results:
|
||||
player = current_ratings[uid]
|
||||
opponents = []
|
||||
|
||||
for opp_uid, opp_score in player_results:
|
||||
if opp_uid == uid:
|
||||
continue
|
||||
|
||||
opp = current_ratings[opp_uid]
|
||||
|
||||
# Determine outcome (lower score wins in Golf)
|
||||
if score < opp_score:
|
||||
outcome = 1.0 # Win
|
||||
elif score == opp_score:
|
||||
outcome = 0.5 # Draw
|
||||
else:
|
||||
outcome = 0.0 # Loss
|
||||
|
||||
opponents.append((opp.mu, opp.phi, outcome))
|
||||
|
||||
updated = update_rating(player, opponents)
|
||||
updated_ratings[uid] = updated
|
||||
|
||||
# Persist updated ratings
|
||||
async with self.pool.acquire() as conn:
|
||||
for uid, rating in updated_ratings.items():
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE player_stats
|
||||
SET rating = $2,
|
||||
rating_deviation = $3,
|
||||
rating_volatility = $4,
|
||||
rating_updated_at = $5
|
||||
WHERE user_id = $1
|
||||
""",
|
||||
uid,
|
||||
rating.rating,
|
||||
rating.rd,
|
||||
rating.volatility,
|
||||
rating.updated_at,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Ratings updated for {len(updated_ratings)} players: "
|
||||
+ ", ".join(f"{uid[:8]}={r.rating:.0f}" for uid, r in updated_ratings.items())
|
||||
)
|
||||
|
||||
return updated_ratings
|
||||
@@ -37,6 +37,8 @@ class PlayerStats:
|
||||
wolfpacks: int = 0
|
||||
current_win_streak: int = 0
|
||||
best_win_streak: int = 0
|
||||
rating: float = 1500.0
|
||||
rating_deviation: float = 350.0
|
||||
first_game_at: Optional[datetime] = None
|
||||
last_game_at: Optional[datetime] = None
|
||||
achievements: List[str] = field(default_factory=list)
|
||||
@@ -156,6 +158,8 @@ class StatsService:
|
||||
wolfpacks=row["wolfpacks"] or 0,
|
||||
current_win_streak=row["current_win_streak"] or 0,
|
||||
best_win_streak=row["best_win_streak"] or 0,
|
||||
rating=float(row["rating"]) if row.get("rating") else 1500.0,
|
||||
rating_deviation=float(row["rating_deviation"]) if row.get("rating_deviation") else 350.0,
|
||||
first_game_at=row["first_game_at"].replace(tzinfo=timezone.utc) if row["first_game_at"] else None,
|
||||
last_game_at=row["last_game_at"].replace(tzinfo=timezone.utc) if row["last_game_at"] else None,
|
||||
achievements=[a["achievement_id"] for a in achievements],
|
||||
@@ -184,6 +188,7 @@ class StatsService:
|
||||
"avg_score": ("avg_score", "ASC"), # Lower is better
|
||||
"knockouts": ("knockouts", "DESC"),
|
||||
"streak": ("best_win_streak", "DESC"),
|
||||
"rating": ("rating", "DESC"),
|
||||
}
|
||||
|
||||
if metric not in order_map:
|
||||
@@ -203,6 +208,7 @@ class StatsService:
|
||||
SELECT
|
||||
user_id, username, games_played, games_won,
|
||||
win_rate, avg_score, knockouts, best_win_streak,
|
||||
COALESCE(rating, 1500) as rating,
|
||||
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
||||
FROM leaderboard_overall
|
||||
ORDER BY {column} {direction}
|
||||
@@ -216,6 +222,7 @@ class StatsService:
|
||||
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
|
||||
ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score,
|
||||
s.knockouts, s.best_win_streak,
|
||||
COALESCE(s.rating, 1500) as rating,
|
||||
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
||||
FROM player_stats s
|
||||
JOIN users_v2 u ON s.user_id = u.id
|
||||
|
||||
Reference in New Issue
Block a user