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:
adlee-was-taken
2026-02-21 20:02:10 -05:00
parent c59c1e28e2
commit f68d0bc26d
16 changed files with 1720 additions and 165 deletions

View File

@@ -145,9 +145,18 @@ class ServerConfig:
# Security (for future auth system)
SECRET_KEY: str = ""
INVITE_ONLY: bool = False
INVITE_ONLY: bool = True
# Bootstrap admin (for first-time setup when INVITE_ONLY=true)
BOOTSTRAP_ADMIN_USERNAME: str = ""
BOOTSTRAP_ADMIN_PASSWORD: str = ""
ADMIN_EMAILS: list[str] = field(default_factory=list)
# Matchmaking
MATCHMAKING_ENABLED: bool = True
MATCHMAKING_MIN_PLAYERS: int = 2
MATCHMAKING_MAX_PLAYERS: int = 4
# Rate limiting
RATE_LIMIT_ENABLED: bool = True
@@ -184,7 +193,12 @@ class ServerConfig:
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
SECRET_KEY=get_env("SECRET_KEY", ""),
INVITE_ONLY=get_env_bool("INVITE_ONLY", False),
INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
MATCHMAKING_MIN_PLAYERS=get_env_int("MATCHMAKING_MIN_PLAYERS", 2),
MATCHMAKING_MAX_PLAYERS=get_env_int("MATCHMAKING_MAX_PLAYERS", 4),
ADMIN_EMAILS=admin_emails,
RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True),
SENTRY_DSN=get_env("SENTRY_DSN", ""),

View File

@@ -12,6 +12,7 @@ from typing import Optional
from fastapi import WebSocket
from config import config
from game import GamePhase, GameOptions
from ai import GolfAI, get_all_profiles
from room import Room
@@ -53,6 +54,10 @@ def log_human_action(room: Room, player, action: str, card=None, position=None,
# ---------------------------------------------------------------------------
async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
if config.INVITE_ONLY and not ctx.authenticated_user:
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
return
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
await ctx.websocket.send_json({
"type": "error",
@@ -60,9 +65,8 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
})
return
player_name = data.get("player_name", "Player")
if ctx.authenticated_user and ctx.authenticated_user.display_name:
player_name = ctx.authenticated_user.display_name
# Use authenticated username as player name
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
room = room_manager.create_room()
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
ctx.current_room = room
@@ -81,8 +85,13 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
if config.INVITE_ONLY and not ctx.authenticated_user:
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
return
room_code = data.get("room_code", "").upper()
player_name = data.get("player_name", "Player")
# Use authenticated username as player name
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
await ctx.websocket.send_json({
@@ -104,8 +113,6 @@ async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager,
await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
return
if ctx.authenticated_user and ctx.authenticated_user.display_name:
player_name = ctx.authenticated_user.display_name
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
ctx.current_room = room
@@ -483,6 +490,65 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c
# Handler dispatch table
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Matchmaking handlers
# ---------------------------------------------------------------------------
async def handle_queue_join(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, rating_service=None, **kw) -> None:
if not matchmaking_service:
await ctx.websocket.send_json({"type": "error", "message": "Matchmaking not available"})
return
if not ctx.authenticated_user:
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to find a game"})
return
# Get player's rating
rating = 1500.0
if rating_service:
try:
player_rating = await rating_service.get_rating(ctx.auth_user_id)
rating = player_rating.rating
except Exception:
pass
status = await matchmaking_service.join_queue(
user_id=ctx.auth_user_id,
username=ctx.authenticated_user.username,
rating=rating,
websocket=ctx.websocket,
connection_id=ctx.connection_id,
)
await ctx.websocket.send_json({
"type": "queue_joined",
**status,
})
async def handle_queue_leave(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
if not matchmaking_service or not ctx.auth_user_id:
return
removed = await matchmaking_service.leave_queue(ctx.auth_user_id)
await ctx.websocket.send_json({
"type": "queue_left",
"was_queued": removed,
})
async def handle_queue_status(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
if not matchmaking_service or not ctx.auth_user_id:
await ctx.websocket.send_json({"type": "queue_status", "in_queue": False})
return
status = await matchmaking_service.get_queue_status(ctx.auth_user_id)
await ctx.websocket.send_json({
"type": "queue_status",
**status,
})
HANDLERS = {
"create_room": handle_create_room,
"join_room": handle_join_room,
@@ -503,4 +569,7 @@ HANDLERS = {
"leave_room": handle_leave_room,
"leave_game": handle_leave_game,
"end_game": handle_end_game,
"queue_join": handle_queue_join,
"queue_leave": handle_queue_leave,
"queue_status": handle_queue_status,
}

View File

@@ -59,6 +59,8 @@ _user_store = None
_auth_service = None
_admin_service = None
_stats_service = None
_rating_service = None
_matchmaking_service = None
_replay_service = None
_spectator_manager = None
_leaderboard_refresh_task = None
@@ -101,7 +103,7 @@ async def _init_redis():
async def _init_database_services():
"""Initialize all PostgreSQL-dependent services."""
global _user_store, _auth_service, _admin_service, _stats_service
global _user_store, _auth_service, _admin_service, _stats_service, _rating_service, _matchmaking_service
global _replay_service, _spectator_manager, _leaderboard_refresh_task
from stores.user_store import get_user_store
@@ -109,7 +111,7 @@ async def _init_database_services():
from services.auth_service import get_auth_service
from services.admin_service import get_admin_service
from services.stats_service import StatsService, set_stats_service
from routers.auth import set_auth_service
from routers.auth import set_auth_service, set_admin_service_for_auth
from routers.admin import set_admin_service
from routers.stats import set_stats_service as set_stats_router_service
from routers.stats import set_auth_service as set_stats_auth_service
@@ -127,6 +129,7 @@ async def _init_database_services():
state_cache=None,
)
set_admin_service(_admin_service)
set_admin_service_for_auth(_admin_service)
logger.info("Admin services initialized")
# Stats + event store
@@ -137,6 +140,23 @@ async def _init_database_services():
set_stats_auth_service(_auth_service)
logger.info("Stats services initialized")
# Rating service (Glicko-2)
from services.rating_service import RatingService
_rating_service = RatingService(_user_store.pool)
logger.info("Rating service initialized")
# Matchmaking service
if config.MATCHMAKING_ENABLED:
from services.matchmaking import MatchmakingService, MatchmakingConfig
mm_config = MatchmakingConfig(
enabled=True,
min_players=config.MATCHMAKING_MIN_PLAYERS,
max_players=config.MATCHMAKING_MAX_PLAYERS,
)
_matchmaking_service = MatchmakingService(_redis_client, mm_config)
await _matchmaking_service.start(room_manager, broadcast_game_state)
logger.info("Matchmaking service initialized")
# Game logger
_game_logger = GameLogger(_event_store)
set_logger(_game_logger)
@@ -165,12 +185,56 @@ async def _init_database_services():
logger.info("Leaderboard refresh task started")
async def _bootstrap_admin():
"""Create bootstrap admin user if no admins exist yet."""
import bcrypt
from models.user import UserRole
# Check if any admin already exists
existing = await _user_store.get_user_by_username(config.BOOTSTRAP_ADMIN_USERNAME)
if existing:
return
# Check if any admin exists at all
async with _user_store.pool.acquire() as conn:
admin_count = await conn.fetchval(
"SELECT COUNT(*) FROM users_v2 WHERE role = 'admin' AND deleted_at IS NULL"
)
if admin_count > 0:
return
# Create the bootstrap admin
password_hash = bcrypt.hashpw(
config.BOOTSTRAP_ADMIN_PASSWORD.encode("utf-8"),
bcrypt.gensalt(),
).decode("utf-8")
user = await _user_store.create_user(
username=config.BOOTSTRAP_ADMIN_USERNAME,
password_hash=password_hash,
role=UserRole.ADMIN,
)
if user:
logger.warning(
f"Bootstrap admin '{config.BOOTSTRAP_ADMIN_USERNAME}' created. "
"Change the password and remove BOOTSTRAP_ADMIN_* env vars."
)
else:
logger.error("Failed to create bootstrap admin user")
async def _shutdown_services():
"""Gracefully shut down all services."""
_shutdown_event.set()
await _close_all_websockets()
# Stop matchmaking
if _matchmaking_service:
await _matchmaking_service.stop()
await _matchmaking_service.cleanup()
# Clean up rooms and CPU profiles
for room in list(room_manager.rooms.values()):
for cpu in list(room.get_cpu_players()):
@@ -225,6 +289,10 @@ async def lifespan(app: FastAPI):
else:
logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work")
# Bootstrap admin user if needed (for first-time setup with INVITE_ONLY)
if config.POSTGRES_URL and config.BOOTSTRAP_ADMIN_USERNAME and config.BOOTSTRAP_ADMIN_PASSWORD:
await _bootstrap_admin()
# Set up health check dependencies
from routers.health import set_health_dependencies
set_health_dependencies(
@@ -458,7 +526,7 @@ def count_user_games(user_id: str) -> int:
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
# Extract token from query param for optional authentication
# Extract token from query param for authentication
token = websocket.query_params.get("token")
authenticated_user = None
if token and _auth_service:
@@ -467,6 +535,12 @@ async def websocket_endpoint(websocket: WebSocket):
except Exception as e:
logger.debug(f"WebSocket auth failed: {e}")
# Reject unauthenticated connections when invite-only
if config.INVITE_ONLY and not authenticated_user:
await websocket.send_json({"type": "error", "message": "Authentication required. Please log in."})
await websocket.close(code=4001, reason="Authentication required")
return
connection_id = str(uuid.uuid4())
auth_user_id = str(authenticated_user.id) if authenticated_user else None
@@ -492,6 +566,8 @@ async def websocket_endpoint(websocket: WebSocket):
check_and_run_cpu_turn=check_and_run_cpu_turn,
handle_player_leave=handle_player_leave,
cleanup_room_profiles=cleanup_room_profiles,
matchmaking_service=_matchmaking_service,
rating_service=_rating_service,
)
try:
@@ -534,6 +610,23 @@ async def _process_stats_safe(room: Room):
game_options=room.game.options,
)
logger.debug(f"Stats processed for room {room.code}")
# Update Glicko-2 ratings for human players
if _rating_service:
player_results = []
for game_player in room.game.players:
if game_player.id in player_user_ids:
player_results.append((
player_user_ids[game_player.id],
game_player.total_score,
))
if len(player_results) >= 2:
await _rating_service.update_ratings(
player_results=player_results,
is_standard_rules=room.game.options.is_standard_rules(),
)
except Exception as e:
logger.error(f"Failed to process game stats: {e}")

View File

@@ -11,8 +11,10 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Request
from pydantic import BaseModel, EmailStr
from config import config
from models.user import User
from services.auth_service import AuthService
from services.admin_service import AdminService
logger = logging.getLogger(__name__)
@@ -29,6 +31,7 @@ class RegisterRequest(BaseModel):
username: str
password: str
email: Optional[str] = None
invite_code: Optional[str] = None
class LoginRequest(BaseModel):
@@ -111,6 +114,7 @@ class SessionResponse(BaseModel):
# These will be set by main.py during startup
_auth_service: Optional[AuthService] = None
_admin_service: Optional[AdminService] = None
def set_auth_service(service: AuthService) -> None:
@@ -119,6 +123,12 @@ def set_auth_service(service: AuthService) -> None:
_auth_service = service
def set_admin_service_for_auth(service: AdminService) -> None:
"""Set the admin service instance for invite code validation (called from main.py)."""
global _admin_service
_admin_service = service
def get_auth_service_dep() -> AuthService:
"""Dependency to get auth service."""
if _auth_service is None:
@@ -201,6 +211,15 @@ async def register(
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Register a new user account."""
# Validate invite code when invite-only mode is enabled
if config.INVITE_ONLY:
if not request_body.invite_code:
raise HTTPException(status_code=400, detail="Invite code required")
if not _admin_service:
raise HTTPException(status_code=503, detail="Admin service not initialized")
if not await _admin_service.validate_invite_code(request_body.invite_code):
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
result = await auth_service.register(
username=request_body.username,
password=request_body.password,
@@ -210,6 +229,10 @@ async def register(
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
# Consume the invite code after successful registration
if config.INVITE_ONLY and request_body.invite_code:
await _admin_service.use_invite_code(request_body.invite_code)
if result.requires_verification:
# Return user info but note they need to verify
return {

View File

@@ -155,7 +155,7 @@ async def require_user(
@router.get("/leaderboard", response_model=LeaderboardResponse)
async def get_leaderboard(
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
service: StatsService = Depends(get_stats_service_dep),
@@ -226,7 +226,7 @@ async def get_player_stats(
@router.get("/players/{user_id}/rank", response_model=PlayerRankResponse)
async def get_player_rank(
user_id: str,
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
service: StatsService = Depends(get_stats_service_dep),
):
"""Get player's rank on a leaderboard."""
@@ -346,7 +346,7 @@ async def get_my_stats(
@router.get("/me/rank", response_model=PlayerRankResponse)
async def get_my_rank(
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
user: User = Depends(require_user),
service: StatsService = Depends(get_stats_service_dep),
):

View 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

View 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

View File

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

View File

@@ -204,6 +204,22 @@ BEGIN
WHERE table_name = 'player_stats' AND column_name = 'games_won_vs_humans') THEN
ALTER TABLE player_stats ADD COLUMN games_won_vs_humans INT DEFAULT 0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'player_stats' AND column_name = 'rating') THEN
ALTER TABLE player_stats ADD COLUMN rating DECIMAL(7,2) DEFAULT 1500.0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'player_stats' AND column_name = 'rating_deviation') THEN
ALTER TABLE player_stats ADD COLUMN rating_deviation DECIMAL(7,2) DEFAULT 350.0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'player_stats' AND column_name = 'rating_volatility') THEN
ALTER TABLE player_stats ADD COLUMN rating_volatility DECIMAL(8,6) DEFAULT 0.06;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'player_stats' AND column_name = 'rating_updated_at') THEN
ALTER TABLE player_stats ADD COLUMN rating_updated_at TIMESTAMPTZ;
END IF;
END $$;
-- Stats processing queue (for async stats processing)
@@ -265,9 +281,19 @@ CREATE TABLE IF NOT EXISTS system_metrics (
);
-- Leaderboard materialized view (refreshed periodically)
-- Note: Using DO block to handle case where view already exists
-- Drop and recreate if missing rating column (v3.1.0 migration)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
-- Check if rating column exists in the view
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'leaderboard_overall' AND column_name = 'rating'
) THEN
DROP MATERIALIZED VIEW leaderboard_overall;
END IF;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
EXECUTE '
CREATE MATERIALIZED VIEW leaderboard_overall AS
@@ -282,6 +308,7 @@ BEGIN
s.best_score as best_round_score,
s.knockouts,
s.best_win_streak,
COALESCE(s.rating, 1500) as rating,
s.last_game_at
FROM player_stats s
JOIN users_v2 u ON s.user_id = u.id
@@ -349,6 +376,9 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_score') THEN
CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_rating') THEN
CREATE INDEX idx_leaderboard_overall_rating ON leaderboard_overall(rating DESC);
END IF;
END IF;
END $$;
"""