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

@ -55,7 +55,12 @@ ROOM_CODE_LENGTH=4
SECRET_KEY= SECRET_KEY=
# Enable invite-only mode (requires invitation to register) # Enable invite-only mode (requires invitation to register)
INVITE_ONLY=false INVITE_ONLY=true
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
# Remove these after first login!
# BOOTSTRAP_ADMIN_USERNAME=admin
# BOOTSTRAP_ADMIN_PASSWORD=changeme12345
# Comma-separated list of admin email addresses # Comma-separated list of admin email addresses
ADMIN_EMAILS= ADMIN_EMAILS=
@ -104,5 +109,13 @@ CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing enabled
# Enable rate limiting (recommended for production) # Enable rate limiting (recommended for production)
# RATE_LIMIT_ENABLED=true # RATE_LIMIT_ENABLED=true
# Redis URL (required for matchmaking and rate limiting)
# REDIS_URL=redis://localhost:6379
# Base URL for email links # Base URL for email links
# BASE_URL=https://your-domain.com # BASE_URL=https://your-domain.com
# Matchmaking (skill-based public games)
MATCHMAKING_ENABLED=true
MATCHMAKING_MIN_PLAYERS=2
MATCHMAKING_MAX_PLAYERS=4

View File

@ -79,8 +79,7 @@ class GolfGame {
const roomCode = params.get('room'); const roomCode = params.get('room');
if (roomCode) { if (roomCode) {
this.roomCodeInput.value = roomCode.toUpperCase(); this.roomCodeInput.value = roomCode.toUpperCase();
// Focus name input so user can quickly enter name and join this.roomCodeInput.focus();
this.playerNameInput.focus();
// Clean up URL without reloading // Clean up URL without reloading
window.history.replaceState({}, '', window.location.pathname); window.history.replaceState({}, '', window.location.pathname);
} }
@ -367,16 +366,24 @@ class GolfGame {
initElements() { initElements() {
// Screens // Screens
this.lobbyScreen = document.getElementById('lobby-screen'); this.lobbyScreen = document.getElementById('lobby-screen');
this.matchmakingScreen = document.getElementById('matchmaking-screen');
this.waitingScreen = document.getElementById('waiting-screen'); this.waitingScreen = document.getElementById('waiting-screen');
this.gameScreen = document.getElementById('game-screen'); this.gameScreen = document.getElementById('game-screen');
// Lobby elements // Lobby elements
this.playerNameInput = document.getElementById('player-name');
this.roomCodeInput = document.getElementById('room-code'); this.roomCodeInput = document.getElementById('room-code');
this.findGameBtn = document.getElementById('find-game-btn');
this.createRoomBtn = document.getElementById('create-room-btn'); this.createRoomBtn = document.getElementById('create-room-btn');
this.joinRoomBtn = document.getElementById('join-room-btn'); this.joinRoomBtn = document.getElementById('join-room-btn');
this.lobbyError = document.getElementById('lobby-error'); this.lobbyError = document.getElementById('lobby-error');
// Matchmaking elements
this.matchmakingStatus = document.getElementById('matchmaking-status');
this.matchmakingTime = document.getElementById('matchmaking-time');
this.matchmakingQueueInfo = document.getElementById('matchmaking-queue-info');
this.cancelMatchmakingBtn = document.getElementById('cancel-matchmaking-btn');
this.matchmakingTimer = null;
// Waiting room elements // Waiting room elements
this.displayRoomCode = document.getElementById('display-room-code'); this.displayRoomCode = document.getElementById('display-room-code');
this.copyRoomCodeBtn = document.getElementById('copy-room-code'); this.copyRoomCodeBtn = document.getElementById('copy-room-code');
@ -470,6 +477,8 @@ class GolfGame {
} }
bindEvents() { bindEvents() {
this.findGameBtn?.addEventListener('click', () => { this.playSound('click'); this.findGame(); });
this.cancelMatchmakingBtn?.addEventListener('click', () => { this.playSound('click'); this.cancelMatchmaking(); });
this.createRoomBtn.addEventListener('click', () => { this.playSound('click'); this.createRoom(); }); this.createRoomBtn.addEventListener('click', () => { this.playSound('click'); this.createRoom(); });
this.joinRoomBtn.addEventListener('click', () => { this.playSound('click'); this.joinRoom(); }); this.joinRoomBtn.addEventListener('click', () => { this.playSound('click'); this.joinRoom(); });
this.startGameBtn.addEventListener('click', () => { this.playSound('success'); this.startGame(); }); this.startGameBtn.addEventListener('click', () => { this.playSound('success'); this.startGame(); });
@ -502,9 +511,6 @@ class GolfGame {
}); });
// Enter key handlers // Enter key handlers
this.playerNameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.createRoomBtn.click();
});
this.roomCodeInput.addEventListener('keypress', (e) => { this.roomCodeInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.joinRoomBtn.click(); if (e.key === 'Enter') this.joinRoomBtn.click();
}); });
@ -612,7 +618,13 @@ class GolfGame {
connect() { connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host || 'localhost:8000'; const host = window.location.host || 'localhost:8000';
const wsUrl = `${protocol}//${host}/ws`; let wsUrl = `${protocol}//${host}/ws`;
// Attach auth token if available
const token = this.authManager?.token;
if (token) {
wsUrl += `?token=${encodeURIComponent(token)}`;
}
this.ws = new WebSocket(wsUrl); this.ws = new WebSocket(wsUrl);
@ -636,6 +648,14 @@ class GolfGame {
}; };
} }
reconnect() {
if (this.ws) {
this.ws.onclose = null; // Prevent error message on intentional close
this.ws.close();
}
this.connect();
}
send(message) { send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) { if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message)); this.ws.send(JSON.stringify(message));
@ -682,6 +702,7 @@ class GolfGame {
case 'round_started': case 'round_started':
// Clear any countdown from previous hole // Clear any countdown from previous hole
this.clearNextHoleCountdown(); this.clearNextHoleCountdown();
this.dismissScoresheetModal();
this.nextRoundBtn.classList.remove('waiting'); this.nextRoundBtn.classList.remove('waiting');
// Clear round winner highlights // Clear round winner highlights
this.roundWinnerNames = new Set(); this.roundWinnerNames = new Set();
@ -867,15 +888,80 @@ class GolfGame {
} }
break; break;
case 'queue_joined':
this.showScreen('matchmaking');
this.startMatchmakingTimer();
this.updateMatchmakingStatus(data);
break;
case 'queue_status':
this.updateMatchmakingStatus(data);
break;
case 'queue_matched':
this.stopMatchmakingTimer();
if (this.matchmakingStatus) {
this.matchmakingStatus.textContent = 'Match found!';
}
break;
case 'queue_left':
this.stopMatchmakingTimer();
this.showScreen('lobby');
break;
case 'error': case 'error':
this.showError(data.message); this.showError(data.message);
break; break;
} }
} }
// Matchmaking
findGame() {
this.connect();
this.ws.onopen = () => {
this.send({ type: 'queue_join' });
};
}
cancelMatchmaking() {
this.send({ type: 'queue_leave' });
this.stopMatchmakingTimer();
this.showScreen('lobby');
}
startMatchmakingTimer() {
this.matchmakingStartTime = Date.now();
this.stopMatchmakingTimer();
this.matchmakingTimer = setInterval(() => {
const elapsed = Math.floor((Date.now() - this.matchmakingStartTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
if (this.matchmakingTime) {
this.matchmakingTime.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
}
}, 1000);
}
stopMatchmakingTimer() {
if (this.matchmakingTimer) {
clearInterval(this.matchmakingTimer);
this.matchmakingTimer = null;
}
}
updateMatchmakingStatus(data) {
if (this.matchmakingQueueInfo) {
const parts = [];
if (data.queue_size) parts.push(`${data.queue_size} player${data.queue_size !== 1 ? 's' : ''} in queue`);
if (data.position) parts.push(`Position: #${data.position}`);
this.matchmakingQueueInfo.textContent = parts.join(' \u2022 ');
}
}
// Room Actions // Room Actions
createRoom() { createRoom() {
const name = this.playerNameInput.value.trim() || 'Player'; const name = this.authManager?.user?.username || 'Player';
this.connect(); this.connect();
this.ws.onopen = () => { this.ws.onopen = () => {
this.send({ type: 'create_room', player_name: name }); this.send({ type: 'create_room', player_name: name });
@ -883,7 +969,7 @@ class GolfGame {
} }
joinRoom() { joinRoom() {
const name = this.playerNameInput.value.trim() || 'Player'; const name = this.authManager?.user?.username || 'Player';
const code = this.roomCodeInput.value.trim().toUpperCase(); const code = this.roomCodeInput.value.trim().toUpperCase();
if (code.length !== 4) { if (code.length !== 4) {
@ -1509,20 +1595,260 @@ class GolfGame {
} }
showKnockBanner(playerName) { showKnockBanner(playerName) {
const banner = document.createElement('div'); const duration = window.TIMING?.knock?.statusDuration || 2500;
banner.className = 'knock-banner'; const message = playerName ? `${playerName} KNOCKED!` : 'KNOCK!';
banner.innerHTML = `<span>${playerName ? playerName + ' knocked!' : 'KNOCK!'}</span>`;
document.body.appendChild(banner);
this.setStatus(message, 'knock');
document.body.classList.add('screen-shake'); document.body.classList.add('screen-shake');
setTimeout(() => { setTimeout(() => {
banner.classList.add('fading');
document.body.classList.remove('screen-shake'); document.body.classList.remove('screen-shake');
}, 800); }, 300);
// Restore normal status after duration
this._knockStatusTimeout = setTimeout(() => {
// Only clear if still showing knock status
if (this.statusMessage.classList.contains('knock')) {
this.setStatus('Final turn!', 'opponent-turn');
}
}, duration);
}
// --- V3_17: Scoresheet Modal ---
showScoresheetModal(scores, gameState, rankings) {
// Remove existing modal if any
const existing = document.getElementById('scoresheet-modal');
if (existing) existing.remove();
// Also update side panel data (silently)
this.showScoreboard(scores, false, rankings);
const cardValues = gameState?.card_values || this.getDefaultCardValues();
const scoringRules = gameState?.scoring_rules || {};
const knockerId = gameState?.finisher_id;
const currentRound = gameState?.current_round || '?';
const totalRounds = gameState?.total_rounds || '?';
// Find round winner(s)
const roundScores = scores.map(s => s.score);
const minRoundScore = Math.min(...roundScores);
// Build player rows - knocker first, then others
const ordered = [...gameState.players].sort((a, b) => {
if (a.id === knockerId) return -1;
if (b.id === knockerId) return 1;
return 0;
});
const playerRowsHtml = ordered.map(player => {
const scoreData = scores.find(s => s.name === player.name) || {};
const result = this.calculateColumnScores(player.cards, cardValues, scoringRules, false);
const isKnocker = player.id === knockerId;
const isLowScore = scoreData.score === minRoundScore;
const colIndices = [[0, 3], [1, 4], [2, 5]];
// Badge
let badge = '';
if (isKnocker) badge = '<span class="ss-badge ss-badge-knock">KNOCKED</span>';
else if (isLowScore) badge = '<span class="ss-badge ss-badge-low">LOW SCORE</span>';
// Build columns
const columnsHtml = colIndices.map((indices, c) => {
const col = result.columns[c];
const topCard = player.cards[indices[0]];
const bottomCard = player.cards[indices[1]];
const isPair = col.isPair;
const topMini = this.renderMiniCard(topCard, isPair);
const bottomMini = this.renderMiniCard(bottomCard, isPair);
let colScore;
if (isPair) {
const pairLabel = col.pairValue !== 0 ? `PAIR ${col.pairValue}` : 'PAIR 0';
colScore = `<div class="ss-col-score ss-pair">${pairLabel}</div>`;
} else {
const val = col.topValue + col.bottomValue;
const cls = val < 0 ? 'ss-negative' : '';
colScore = `<div class="ss-col-score ${cls}">${val >= 0 ? '+' + val : val}</div>`;
}
return `<div class="ss-column${isPair ? ' ss-column-paired' : ''}">
${topMini}${bottomMini}
${colScore}
</div>`;
}).join('');
// Bonuses
let bonusHtml = '';
if (result.bonuses.length > 0) {
bonusHtml = result.bonuses.map(b => {
const label = b.type === 'wolfpack' ? 'WOLFPACK' : 'FOUR OF A KIND';
return `<span class="ss-bonus">${label} ${b.value}</span>`;
}).join(' ');
}
const roundScore = scoreData.score !== undefined ? scoreData.score : '-';
const totalScore = scoreData.total !== undefined ? scoreData.total : '-';
return `<div class="ss-player-row">
<div class="ss-player-header">
<span class="ss-player-name">${player.name}</span>
${badge}
</div>
<div class="ss-columns">${columnsHtml}</div>
${bonusHtml ? `<div class="ss-bonuses">${bonusHtml}</div>` : ''}
<div class="ss-scores">
<span>Hole: <strong>${roundScore}</strong></span>
<span>Total: <strong>${totalScore}</strong></span>
</div>
</div>`;
}).join('');
// Create modal
const modal = document.createElement('div');
modal.id = 'scoresheet-modal';
modal.className = 'scoresheet-modal';
modal.innerHTML = `
<div class="scoresheet-content">
<div class="ss-header">Hole ${currentRound} of ${totalRounds}</div>
<div class="ss-players">${playerRowsHtml}</div>
<button class="btn btn-primary ss-next-btn" id="ss-next-btn">Next Hole</button>
</div>
`;
document.body.appendChild(modal);
this.setStatus('Hole complete');
// Bind next button
const nextBtn = document.getElementById('ss-next-btn');
nextBtn.addEventListener('click', () => {
this.playSound('click');
this.dismissScoresheetModal();
this.nextRound();
});
// Start countdown
this.startScoresheetCountdown(nextBtn);
// Animate entrance
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
this.animateScoresheetEntrance(modal);
}
}
renderMiniCard(card, isPaired) {
if (!card || !card.rank) return '<div class="ss-mini-card ss-mini-back"></div>';
const suit = card.suit;
const isRed = suit === 'hearts' || suit === 'diamonds';
const symbol = this.getSuitSymbol(suit);
const rank = card.rank === '★' ? '★' : card.rank;
const classes = [
'ss-mini-card',
isRed ? 'ss-red' : 'ss-black',
isPaired ? 'ss-mini-paired' : ''
].filter(Boolean).join(' ');
return `<div class="${classes}">${rank}${symbol}</div>`;
}
animateScoresheetEntrance(modal) {
const T = window.TIMING?.scoresheet || {};
const playerRows = modal.querySelectorAll('.ss-player-row');
const nextBtn = modal.querySelector('.ss-next-btn');
// Start everything hidden
playerRows.forEach(row => {
row.style.opacity = '0';
row.style.transform = 'translateY(10px)';
});
if (nextBtn) {
nextBtn.style.opacity = '0';
}
// Stagger player rows in
if (window.anime) {
anime({
targets: Array.from(playerRows),
opacity: [0, 1],
translateY: [10, 0],
delay: anime.stagger(T.playerStagger || 150),
duration: 300,
easing: 'easeOutCubic',
complete: () => {
// Animate paired columns glow
setTimeout(() => { setTimeout(() => {
banner.remove(); modal.querySelectorAll('.ss-column-paired').forEach(col => {
}, 1100); col.classList.add('ss-pair-glow');
});
}, T.pairGlowDelay || 200);
}
});
// Fade in button after rows
const totalRowDelay = (playerRows.length - 1) * (T.playerStagger || 150) + 300;
anime({
targets: nextBtn,
opacity: [0, 1],
delay: totalRowDelay,
duration: 200,
easing: 'easeOutCubic',
});
} else {
// No anime.js - show immediately
playerRows.forEach(row => {
row.style.opacity = '1';
row.style.transform = '';
});
if (nextBtn) nextBtn.style.opacity = '1';
}
}
startScoresheetCountdown(btn) {
this.clearScoresheetCountdown();
const COUNTDOWN_SECONDS = 15;
let remaining = COUNTDOWN_SECONDS;
const update = () => {
if (this.isHost) {
btn.textContent = `Next Hole (${remaining}s)`;
btn.disabled = false;
} else {
btn.textContent = `Next hole in ${remaining}s...`;
btn.disabled = true;
}
};
update();
this.scoresheetCountdownInterval = setInterval(() => {
remaining--;
if (remaining <= 0) {
this.clearScoresheetCountdown();
if (this.isHost) {
this.dismissScoresheetModal();
this.nextRound();
} else {
btn.textContent = 'Waiting for host...';
}
} else {
update();
}
}, 1000);
}
clearScoresheetCountdown() {
if (this.scoresheetCountdownInterval) {
clearInterval(this.scoresheetCountdownInterval);
this.scoresheetCountdownInterval = null;
}
}
dismissScoresheetModal() {
this.clearScoresheetCountdown();
const modal = document.getElementById('scoresheet-modal');
if (modal) modal.remove();
} }
// --- V3_02: Dealing Animation --- // --- V3_02: Dealing Animation ---
@ -1641,8 +1967,8 @@ class GolfGame {
const newState = this.postRevealState || this.gameState; const newState = this.postRevealState || this.gameState;
if (!oldState || !newState) { if (!oldState || !newState) {
// Fallback: show scoreboard immediately // Fallback: show scoresheet immediately
this.showScoreboard(scores, false, rankings); this.showScoresheetModal(scores, this.gameState, rankings);
return; return;
} }
@ -1687,18 +2013,14 @@ class GolfGame {
this.highlightPlayerArea(player.id, false); this.highlightPlayerArea(player.id, false);
} }
// All revealed - run score tally before showing scoreboard // All revealed - show scoresheet modal
this.revealAnimationInProgress = false; this.revealAnimationInProgress = false;
this.preRevealState = null; this.preRevealState = null;
this.postRevealState = null; this.postRevealState = null;
this.renderGame(); this.renderGame();
// V3_07: Animated score tallying // V3_17: Scoresheet modal replaces tally + side panel scoreboard
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { this.showScoresheetModal(scores, newState, rankings);
await this.runScoreTally(newState.players, knockerId);
}
this.showScoreboard(scores, false, rankings);
} }
getCardsToReveal(oldState, newState) { getCardsToReveal(oldState, newState) {
@ -2558,6 +2880,7 @@ class GolfGame {
nextRound() { nextRound() {
this.clearNextHoleCountdown(); this.clearNextHoleCountdown();
this.clearScoresheetCountdown();
this.send({ type: 'next_round' }); this.send({ type: 'next_round' });
this.gameButtons.classList.add('hidden'); this.gameButtons.classList.add('hidden');
this.nextRoundBtn.classList.remove('waiting'); this.nextRoundBtn.classList.remove('waiting');
@ -2585,7 +2908,19 @@ class GolfGame {
// UI Helpers // UI Helpers
showScreen(screen) { showScreen(screen) {
// Accept string names or DOM elements
if (typeof screen === 'string') {
const screenMap = {
'lobby': this.lobbyScreen,
'matchmaking': this.matchmakingScreen,
'waiting': this.waitingScreen,
'game': this.gameScreen,
};
screen = screenMap[screen] || screen;
}
this.lobbyScreen.classList.remove('active'); this.lobbyScreen.classList.remove('active');
this.matchmakingScreen?.classList.remove('active');
this.waitingScreen.classList.remove('active'); this.waitingScreen.classList.remove('active');
this.gameScreen.classList.remove('active'); this.gameScreen.classList.remove('active');
if (this.rulesScreen) { if (this.rulesScreen) {
@ -3873,13 +4208,8 @@ class GolfGame {
return; return;
} }
// Show game buttons // V3_17: Scoresheet modal handles Next Hole button/countdown now
this.gameButtons.classList.remove('hidden'); // Side panel just updates data silently
this.newGameBtn.classList.add('hidden');
this.nextRoundBtn.classList.remove('hidden');
// Start countdown for next hole
this.startNextHoleCountdown();
} }
startNextHoleCountdown() { startNextHoleCountdown() {
@ -4144,13 +4474,32 @@ class AuthManager {
this.initElements(); this.initElements();
this.bindEvents(); this.bindEvents();
this.updateUI(); this.updateUI();
// Validate stored token on load
if (this.token) {
this.validateToken();
}
}
async validateToken() {
try {
const response = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${this.token}` },
});
if (!response.ok) {
this.logout();
}
} catch {
// Network error - keep token, will fail on next action
}
} }
initElements() { initElements() {
this.authBar = document.getElementById('auth-bar'); this.authBar = document.getElementById('auth-bar');
this.authUsername = document.getElementById('auth-username'); this.authUsername = document.getElementById('auth-username');
this.logoutBtn = document.getElementById('auth-logout-btn'); this.logoutBtn = document.getElementById('auth-logout-btn');
this.authButtons = document.getElementById('auth-buttons'); this.authPrompt = document.getElementById('auth-prompt');
this.lobbyGameControls = document.getElementById('lobby-game-controls');
this.loginBtn = document.getElementById('login-btn'); this.loginBtn = document.getElementById('login-btn');
this.signupBtn = document.getElementById('signup-btn'); this.signupBtn = document.getElementById('signup-btn');
this.modal = document.getElementById('auth-modal'); this.modal = document.getElementById('auth-modal');
@ -4162,6 +4511,7 @@ class AuthManager {
this.loginError = document.getElementById('login-error'); this.loginError = document.getElementById('login-error');
this.signupFormContainer = document.getElementById('signup-form-container'); this.signupFormContainer = document.getElementById('signup-form-container');
this.signupForm = document.getElementById('signup-form'); this.signupForm = document.getElementById('signup-form');
this.signupInviteCode = document.getElementById('signup-invite-code');
this.signupUsername = document.getElementById('signup-username'); this.signupUsername = document.getElementById('signup-username');
this.signupEmail = document.getElementById('signup-email'); this.signupEmail = document.getElementById('signup-email');
this.signupPassword = document.getElementById('signup-password'); this.signupPassword = document.getElementById('signup-password');
@ -4247,10 +4597,6 @@ class AuthManager {
this.setAuth(data.token, data.user); this.setAuth(data.token, data.user);
this.hideModal(); this.hideModal();
if (data.user.username && this.game.playerNameInput) {
this.game.playerNameInput.value = data.user.username;
}
} catch (err) { } catch (err) {
this.loginError.textContent = 'Connection error'; this.loginError.textContent = 'Connection error';
} }
@ -4260,6 +4606,7 @@ class AuthManager {
e.preventDefault(); e.preventDefault();
this.clearErrors(); this.clearErrors();
const invite_code = this.signupInviteCode?.value.trim() || null;
const username = this.signupUsername.value.trim(); const username = this.signupUsername.value.trim();
const email = this.signupEmail.value.trim() || null; const email = this.signupEmail.value.trim() || null;
const password = this.signupPassword.value; const password = this.signupPassword.value;
@ -4268,7 +4615,7 @@ class AuthManager {
const response = await fetch('/api/auth/register', { const response = await fetch('/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password }), body: JSON.stringify({ invite_code, username, email, password }),
}); });
const data = await response.json(); const data = await response.json();
@ -4280,10 +4627,6 @@ class AuthManager {
this.setAuth(data.token, data.user); this.setAuth(data.token, data.user);
this.hideModal(); this.hideModal();
if (data.user.username && this.game.playerNameInput) {
this.game.playerNameInput.value = data.user.username;
}
} catch (err) { } catch (err) {
this.signupError.textContent = 'Connection error'; this.signupError.textContent = 'Connection error';
} }
@ -4308,16 +4651,15 @@ class AuthManager {
updateUI() { updateUI() {
if (this.user) { if (this.user) {
this.authBar?.classList.remove('hidden'); this.authBar?.classList.remove('hidden');
this.authButtons?.classList.add('hidden'); this.authPrompt?.classList.add('hidden');
this.lobbyGameControls?.classList.remove('hidden');
if (this.authUsername) { if (this.authUsername) {
this.authUsername.textContent = this.user.username; this.authUsername.textContent = this.user.username;
} }
if (this.game.playerNameInput && !this.game.playerNameInput.value) {
this.game.playerNameInput.value = this.user.username;
}
} else { } else {
this.authBar?.classList.add('hidden'); this.authBar?.classList.add('hidden');
this.authButtons?.classList.remove('hidden'); this.authPrompt?.classList.remove('hidden');
this.lobbyGameControls?.classList.add('hidden');
} }
} }
} }

View File

@ -19,35 +19,52 @@
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span> <span class="golf-title">Golf</span></h1> <h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span> <span class="golf-title">Golf</span></h1>
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p> <p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
<!-- Auth buttons for guests (hidden until auth check confirms not logged in) --> <!-- Auth prompt for unauthenticated users -->
<div id="auth-buttons" class="auth-buttons hidden"> <div id="auth-prompt" class="auth-prompt">
<button id="login-btn" class="btn btn-small">Login</button> <p>Log in or sign up to play.</p>
<button id="signup-btn" class="btn btn-small btn-primary">Sign Up</button>
</div>
<div class="form-group">
<label for="player-name">Your Name</label>
<input type="text" id="player-name" placeholder="Enter your name" maxlength="12">
</div>
<div class="button-group"> <div class="button-group">
<button id="create-room-btn" class="btn btn-primary">Create Room</button> <button id="login-btn" class="btn btn-primary">Login</button>
<button id="signup-btn" class="btn btn-secondary">Sign Up</button>
</div>
</div>
<!-- Game controls (shown only when authenticated) -->
<div id="lobby-game-controls" class="hidden">
<div class="button-group">
<button id="find-game-btn" class="btn btn-primary">Find Game</button>
</div> </div>
<div class="divider">or</div> <div class="divider">or</div>
<div class="button-group">
<button id="create-room-btn" class="btn btn-secondary">Create Private Room</button>
</div>
<div class="form-group"> <div class="form-group">
<label for="room-code">Room Code</label> <label for="room-code">Join Private Room</label>
<input type="text" id="room-code" placeholder="ABCD" maxlength="4"> <input type="text" id="room-code" placeholder="ABCD" maxlength="4">
</div> </div>
<div class="button-group"> <div class="button-group">
<button id="join-room-btn" class="btn btn-secondary">Join Room</button> <button id="join-room-btn" class="btn btn-secondary">Join Room</button>
</div> </div>
</div>
<p id="lobby-error" class="error"></p> <p id="lobby-error" class="error"></p>
</div> </div>
<!-- Matchmaking Screen -->
<div id="matchmaking-screen" class="screen">
<h2>Finding Game...</h2>
<div class="matchmaking-spinner"></div>
<p id="matchmaking-status">Searching for opponents...</p>
<p id="matchmaking-time" class="matchmaking-timer">0:00</p>
<p id="matchmaking-queue-info" class="matchmaking-info"></p>
<div class="button-group">
<button id="cancel-matchmaking-btn" class="btn btn-danger">Cancel</button>
</div>
</div>
<!-- Waiting Room Screen --> <!-- Waiting Room Screen -->
<div id="waiting-screen" class="screen"> <div id="waiting-screen" class="screen">
<div class="room-code-banner"> <div class="room-code-banner">
@ -717,6 +734,7 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<button class="leaderboard-tab" data-metric="avg_score">Avg Score</button> <button class="leaderboard-tab" data-metric="avg_score">Avg Score</button>
<button class="leaderboard-tab" data-metric="knockouts">Knockouts</button> <button class="leaderboard-tab" data-metric="knockouts">Knockouts</button>
<button class="leaderboard-tab" data-metric="streak">Best Streak</button> <button class="leaderboard-tab" data-metric="streak">Best Streak</button>
<button class="leaderboard-tab" data-metric="rating">Rating</button>
</div> </div>
<div id="leaderboard-content"> <div id="leaderboard-content">
@ -816,6 +834,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div id="signup-form-container" class="hidden"> <div id="signup-form-container" class="hidden">
<h3>Sign Up</h3> <h3>Sign Up</h3>
<form id="signup-form"> <form id="signup-form">
<div class="form-group">
<input type="text" id="signup-invite-code" placeholder="Invite Code" required>
</div>
<div class="form-group"> <div class="form-group">
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20"> <input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
</div> </div>

View File

@ -26,6 +26,7 @@ class LeaderboardComponent {
avg_score: 'Avg Score', avg_score: 'Avg Score',
knockouts: 'Knockouts', knockouts: 'Knockouts',
streak: 'Best Streak', streak: 'Best Streak',
rating: 'Rating',
}; };
this.metricFormats = { this.metricFormats = {
@ -34,6 +35,7 @@ class LeaderboardComponent {
avg_score: (v) => v.toFixed(1), avg_score: (v) => v.toFixed(1),
knockouts: (v) => v.toLocaleString(), knockouts: (v) => v.toLocaleString(),
streak: (v) => v.toLocaleString(), streak: (v) => v.toLocaleString(),
rating: (v) => Math.round(v).toLocaleString(),
}; };
this.init(); this.init();

View File

@ -3299,15 +3299,26 @@ input::placeholder {
display: none; display: none;
} }
/* Auth buttons in lobby */ /* Auth prompt in lobby (shown when not logged in) */
.auth-buttons { .auth-prompt {
display: flex; text-align: center;
justify-content: center; margin: 20px 0;
gap: 10px; padding: 20px;
margin-bottom: 20px; border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
} }
.auth-buttons.hidden { .auth-prompt p {
margin-bottom: 15px;
color: #ccc;
font-size: 1.1em;
}
.auth-prompt .button-group {
justify-content: center;
}
.auth-prompt.hidden {
display: none; display: none;
} }
@ -3391,6 +3402,48 @@ input::placeholder {
text-align: center; text-align: center;
} }
/* ===========================================
MATCHMAKING SCREEN
=========================================== */
#matchmaking-screen {
text-align: center;
padding: 40px 20px;
}
#matchmaking-screen h2 {
color: #f4a460;
margin-bottom: 20px;
}
.matchmaking-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-top-color: #f4a460;
border-radius: 50%;
margin: 20px auto;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.matchmaking-timer {
font-size: 2em;
font-weight: bold;
color: #fff;
margin: 15px 0;
font-variant-numeric: tabular-nums;
}
.matchmaking-info {
color: rgba(255, 255, 255, 0.6);
font-size: 0.9em;
margin: 10px 0 20px;
}
/* =========================================== /* ===========================================
LEADERBOARD COMPONENTS LEADERBOARD COMPONENTS
=========================================== */ =========================================== */
@ -4402,56 +4455,21 @@ input::placeholder {
flex: 1; flex: 1;
} }
.knock-banner { /* V3_17: Knock status message - golden gradient with pulsing glow */
position: fixed; .status-message.knock {
top: 0; background: linear-gradient(135deg, #f4a460 0%, #e67e22 50%, #d4750e 100%);
left: 0; color: #1a1a2e;
right: 0; font-size: 1.3em;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
z-index: 400;
pointer-events: none;
animation: knock-banner-in 0.3s ease-out forwards;
}
.knock-banner span {
display: block;
font-size: 4em;
font-weight: 900; font-weight: 900;
color: #ffe082; letter-spacing: 0.08em;
background: rgba(20, 20, 36, 0.95); text-transform: uppercase;
padding: 20px 50px; animation: knock-pulse 0.6s ease-in-out 3;
border-radius: 12px; box-shadow: 0 0 15px rgba(244, 164, 96, 0.5);
border: 3px solid rgba(255, 215, 0, 0.5);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
letter-spacing: 0.15em;
} }
@keyframes knock-banner-in { @keyframes knock-pulse {
0% { opacity: 0; } 0%, 100% { box-shadow: 0 0 15px rgba(244, 164, 96, 0.5); }
100% { opacity: 1; } 50% { box-shadow: 0 0 25px rgba(244, 164, 96, 0.8), 0 0 40px rgba(230, 126, 34, 0.3); }
}
.knock-banner span {
animation: knock-text-in 0.3s ease-out forwards;
}
@keyframes knock-text-in {
0% { transform: scale(0); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.knock-banner.fading {
animation: knock-banner-out 0.3s ease-out forwards;
}
@keyframes knock-banner-out {
0% { opacity: 1; }
100% { opacity: 0; }
} }
@keyframes screen-shake { @keyframes screen-shake {
@ -4466,25 +4484,7 @@ body.screen-shake {
animation: screen-shake 0.3s ease-out; animation: screen-shake 0.3s ease-out;
} }
.opponent-knock-banner { /* opponent-knock-banner removed in V3_17 - knock uses status bar now */
position: fixed;
top: 30%;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
color: white;
padding: 15px 30px;
border-radius: 12px;
font-size: 1.2em;
font-weight: bold;
z-index: 200;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: prompt-entrance 0.3s ease-out;
}
.opponent-knock-banner.fading {
animation: prompt-fade 0.3s ease-out forwards;
}
/* --- V3_07: Score Tallying Animation --- */ /* --- V3_07: Score Tallying Animation --- */
.card-value-overlay { .card-value-overlay {
@ -4660,6 +4660,215 @@ body.screen-shake {
margin-top: 2px; margin-top: 2px;
} }
/* --- V3_17: Scoresheet Modal --- */
.scoresheet-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
z-index: 300;
animation: fadeInBg 0.3s ease;
}
@keyframes fadeInBg {
from { opacity: 0; }
to { opacity: 1; }
}
.scoresheet-content {
background: linear-gradient(145deg, #1a472a 0%, #0d3320 100%);
border-radius: 16px;
padding: 24px 28px;
max-width: 520px;
width: 92%;
max-height: 85vh;
overflow-y: auto;
box-shadow:
0 16px 50px rgba(0, 0, 0, 0.6),
0 0 60px rgba(244, 164, 96, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
border: 2px solid rgba(244, 164, 96, 0.25);
animation: modalSlideIn 0.4s ease;
}
.ss-header {
text-align: center;
font-size: 1.1rem;
font-weight: 700;
color: #f4a460;
margin-bottom: 18px;
letter-spacing: 0.05em;
}
.ss-players {
display: flex;
flex-direction: column;
gap: 14px;
}
.ss-player-row {
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.ss-player-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.ss-player-name {
font-weight: 700;
font-size: 0.95rem;
color: #e8e8e8;
}
.ss-badge {
font-size: 0.65rem;
font-weight: 800;
padding: 2px 7px;
border-radius: 4px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.ss-badge-knock {
background: linear-gradient(135deg, #f4a460 0%, #e67e22 100%);
color: #1a1a2e;
}
.ss-badge-low {
background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%);
color: #fff;
}
.ss-columns {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 6px;
}
.ss-column {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 4px 6px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
}
.ss-column-paired {
background: rgba(244, 164, 96, 0.08);
border: 1px solid rgba(244, 164, 96, 0.15);
}
.ss-column-paired.ss-pair-glow {
animation: ss-pair-glow-pulse 0.5s ease-out;
}
@keyframes ss-pair-glow-pulse {
0% { box-shadow: 0 0 0 rgba(244, 164, 96, 0); }
50% { box-shadow: 0 0 12px rgba(244, 164, 96, 0.4); }
100% { box-shadow: 0 0 0 rgba(244, 164, 96, 0); }
}
.ss-mini-card {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 28px;
border-radius: 3px;
font-size: 0.72rem;
font-weight: 700;
line-height: 1;
background: #f5f0e8;
color: #1a1a2e;
border: 1px solid rgba(0, 0, 0, 0.15);
letter-spacing: -0.02em;
}
.ss-mini-card.ss-red {
color: #c0392b;
}
.ss-mini-card.ss-black {
color: #1a1a2e;
}
.ss-mini-card.ss-mini-paired {
opacity: 0.5;
text-decoration: line-through;
}
.ss-mini-card.ss-mini-back {
background: linear-gradient(135deg, #2c5f8a 0%, #1a3a5c 100%);
color: transparent;
}
.ss-col-score {
font-size: 0.7rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.7);
text-align: center;
margin-top: 1px;
}
.ss-col-score.ss-pair {
color: #f4a460;
}
.ss-col-score.ss-negative {
color: #27ae60;
}
.ss-bonuses {
margin: 4px 0 2px;
text-align: center;
}
.ss-bonus {
display: inline-block;
font-size: 0.7rem;
font-weight: 800;
color: #81d4fa;
background: rgba(100, 181, 246, 0.15);
padding: 2px 8px;
border-radius: 4px;
margin: 0 4px;
}
.ss-scores {
display: flex;
justify-content: flex-end;
gap: 16px;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.6);
margin-top: 4px;
}
.ss-scores strong {
color: #fff;
}
.ss-next-btn {
display: block;
width: 100%;
margin-top: 18px;
padding: 10px;
font-size: 0.95rem;
}
/* --- V3_11: Swap Animation --- */ /* --- V3_11: Swap Animation --- */
.traveling-card { .traveling-card {
position: fixed; position: fixed;

View File

@ -128,6 +128,18 @@ const TIMING = {
pulseDelay: 200, // Delay before card appears (pulse visible first) pulseDelay: 200, // Delay before card appears (pulse visible first)
}, },
// V3_17: Knock notification
knock: {
statusDuration: 2500, // How long the knock status message persists
},
// V3_17: Scoresheet modal
scoresheet: {
playerStagger: 150, // Delay between player row animations
columnStagger: 80, // Delay between column animations within a row
pairGlowDelay: 200, // Delay before paired columns glow
},
// Player swap animation steps - smooth continuous motion // Player swap animation steps - smooth continuous motion
playerSwap: { playerSwap: {
flipToReveal: 400, // Initial flip to show card flipToReveal: 400, // Initial flip to show card

View File

@ -22,6 +22,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
environment: environment:
- POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf - POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
- DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- RESEND_API_KEY=${RESEND_API_KEY:-} - RESEND_API_KEY=${RESEND_API_KEY:-}
@ -30,21 +31,25 @@ services:
- LOG_LEVEL=INFO - LOG_LEVEL=INFO
- BASE_URL=${BASE_URL:-https://golf.example.com} - BASE_URL=${BASE_URL:-https://golf.example.com}
- RATE_LIMIT_ENABLED=true - RATE_LIMIT_ENABLED=true
- INVITE_ONLY=true
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
- MATCHMAKING_ENABLED=true
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
deploy: deploy:
replicas: 2 replicas: 1
restart_policy: restart_policy:
condition: on-failure condition: on-failure
max_attempts: 3 max_attempts: 3
resources: resources:
limits: limits:
memory: 512M
reservations:
memory: 256M memory: 256M
reservations:
memory: 64M
networks: networks:
- internal - internal
- web - web
@ -77,13 +82,13 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 512M memory: 192M
reservations: reservations:
memory: 256M memory: 64M
redis: redis:
image: redis:7-alpine image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru command: redis-server --appendonly yes --maxmemory 32mb --maxmemory-policy allkeys-lru
volumes: volumes:
- redis_data:/data - redis_data:/data
healthcheck: healthcheck:
@ -96,9 +101,9 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 192M
reservations:
memory: 64M memory: 64M
reservations:
memory: 16M
traefik: traefik:
image: traefik:v2.10 image: traefik:v2.10
@ -125,7 +130,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 128M memory: 64M
volumes: volumes:
postgres_data: postgres_data:

View File

@ -145,9 +145,18 @@ class ServerConfig:
# Security (for future auth system) # Security (for future auth system)
SECRET_KEY: str = "" 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) 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 limiting
RATE_LIMIT_ENABLED: bool = True RATE_LIMIT_ENABLED: bool = True
@ -184,7 +193,12 @@ class ServerConfig:
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60), ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4), ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
SECRET_KEY=get_env("SECRET_KEY", ""), 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, ADMIN_EMAILS=admin_emails,
RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True), RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True),
SENTRY_DSN=get_env("SENTRY_DSN", ""), SENTRY_DSN=get_env("SENTRY_DSN", ""),

View File

@ -12,6 +12,7 @@ from typing import Optional
from fastapi import WebSocket from fastapi import WebSocket
from config import config
from game import GamePhase, GameOptions from game import GamePhase, GameOptions
from ai import GolfAI, get_all_profiles from ai import GolfAI, get_all_profiles
from room import Room 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: 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: if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
await ctx.websocket.send_json({ await ctx.websocket.send_json({
"type": "error", "type": "error",
@ -60,9 +65,8 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
}) })
return return
player_name = data.get("player_name", "Player") # Use authenticated username as player name
if ctx.authenticated_user and ctx.authenticated_user.display_name: player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
player_name = ctx.authenticated_user.display_name
room = room_manager.create_room() room = room_manager.create_room()
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id) room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
ctx.current_room = room 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: 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() 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: if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
await ctx.websocket.send_json({ 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"}) await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
return 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) room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
ctx.current_room = room ctx.current_room = room
@ -483,6 +490,65 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c
# Handler dispatch table # 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 = { HANDLERS = {
"create_room": handle_create_room, "create_room": handle_create_room,
"join_room": handle_join_room, "join_room": handle_join_room,
@ -503,4 +569,7 @@ HANDLERS = {
"leave_room": handle_leave_room, "leave_room": handle_leave_room,
"leave_game": handle_leave_game, "leave_game": handle_leave_game,
"end_game": handle_end_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 _auth_service = None
_admin_service = None _admin_service = None
_stats_service = None _stats_service = None
_rating_service = None
_matchmaking_service = None
_replay_service = None _replay_service = None
_spectator_manager = None _spectator_manager = None
_leaderboard_refresh_task = None _leaderboard_refresh_task = None
@ -101,7 +103,7 @@ async def _init_redis():
async def _init_database_services(): async def _init_database_services():
"""Initialize all PostgreSQL-dependent 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 global _replay_service, _spectator_manager, _leaderboard_refresh_task
from stores.user_store import get_user_store 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.auth_service import get_auth_service
from services.admin_service import get_admin_service from services.admin_service import get_admin_service
from services.stats_service import StatsService, set_stats_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.admin import set_admin_service
from routers.stats import set_stats_service as set_stats_router_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 from routers.stats import set_auth_service as set_stats_auth_service
@ -127,6 +129,7 @@ async def _init_database_services():
state_cache=None, state_cache=None,
) )
set_admin_service(_admin_service) set_admin_service(_admin_service)
set_admin_service_for_auth(_admin_service)
logger.info("Admin services initialized") logger.info("Admin services initialized")
# Stats + event store # Stats + event store
@ -137,6 +140,23 @@ async def _init_database_services():
set_stats_auth_service(_auth_service) set_stats_auth_service(_auth_service)
logger.info("Stats services initialized") 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
_game_logger = GameLogger(_event_store) _game_logger = GameLogger(_event_store)
set_logger(_game_logger) set_logger(_game_logger)
@ -165,12 +185,56 @@ async def _init_database_services():
logger.info("Leaderboard refresh task started") 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(): async def _shutdown_services():
"""Gracefully shut down all services.""" """Gracefully shut down all services."""
_shutdown_event.set() _shutdown_event.set()
await _close_all_websockets() await _close_all_websockets()
# Stop matchmaking
if _matchmaking_service:
await _matchmaking_service.stop()
await _matchmaking_service.cleanup()
# Clean up rooms and CPU profiles # Clean up rooms and CPU profiles
for room in list(room_manager.rooms.values()): for room in list(room_manager.rooms.values()):
for cpu in list(room.get_cpu_players()): for cpu in list(room.get_cpu_players()):
@ -225,6 +289,10 @@ async def lifespan(app: FastAPI):
else: else:
logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work") 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 # Set up health check dependencies
from routers.health import set_health_dependencies from routers.health import set_health_dependencies
set_health_dependencies( set_health_dependencies(
@ -458,7 +526,7 @@ def count_user_games(user_id: str) -> int:
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
await websocket.accept() await websocket.accept()
# Extract token from query param for optional authentication # Extract token from query param for authentication
token = websocket.query_params.get("token") token = websocket.query_params.get("token")
authenticated_user = None authenticated_user = None
if token and _auth_service: if token and _auth_service:
@ -467,6 +535,12 @@ async def websocket_endpoint(websocket: WebSocket):
except Exception as e: except Exception as e:
logger.debug(f"WebSocket auth failed: {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()) connection_id = str(uuid.uuid4())
auth_user_id = str(authenticated_user.id) if authenticated_user else None 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, check_and_run_cpu_turn=check_and_run_cpu_turn,
handle_player_leave=handle_player_leave, handle_player_leave=handle_player_leave,
cleanup_room_profiles=cleanup_room_profiles, cleanup_room_profiles=cleanup_room_profiles,
matchmaking_service=_matchmaking_service,
rating_service=_rating_service,
) )
try: try:
@ -534,6 +610,23 @@ async def _process_stats_safe(room: Room):
game_options=room.game.options, game_options=room.game.options,
) )
logger.debug(f"Stats processed for room {room.code}") 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: except Exception as e:
logger.error(f"Failed to process game stats: {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 fastapi import APIRouter, Depends, HTTPException, Header, Request
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from config import config
from models.user import User from models.user import User
from services.auth_service import AuthService from services.auth_service import AuthService
from services.admin_service import AdminService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -29,6 +31,7 @@ class RegisterRequest(BaseModel):
username: str username: str
password: str password: str
email: Optional[str] = None email: Optional[str] = None
invite_code: Optional[str] = None
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
@ -111,6 +114,7 @@ class SessionResponse(BaseModel):
# These will be set by main.py during startup # These will be set by main.py during startup
_auth_service: Optional[AuthService] = None _auth_service: Optional[AuthService] = None
_admin_service: Optional[AdminService] = None
def set_auth_service(service: AuthService) -> None: def set_auth_service(service: AuthService) -> None:
@ -119,6 +123,12 @@ def set_auth_service(service: AuthService) -> None:
_auth_service = service _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: def get_auth_service_dep() -> AuthService:
"""Dependency to get auth service.""" """Dependency to get auth service."""
if _auth_service is None: if _auth_service is None:
@ -201,6 +211,15 @@ async def register(
auth_service: AuthService = Depends(get_auth_service_dep), auth_service: AuthService = Depends(get_auth_service_dep),
): ):
"""Register a new user account.""" """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( result = await auth_service.register(
username=request_body.username, username=request_body.username,
password=request_body.password, password=request_body.password,
@ -210,6 +229,10 @@ async def register(
if not result.success: if not result.success:
raise HTTPException(status_code=400, detail=result.error) 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: if result.requires_verification:
# Return user info but note they need to verify # Return user info but note they need to verify
return { return {

View File

@ -155,7 +155,7 @@ async def require_user(
@router.get("/leaderboard", response_model=LeaderboardResponse) @router.get("/leaderboard", response_model=LeaderboardResponse)
async def get_leaderboard( 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), limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
service: StatsService = Depends(get_stats_service_dep), 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) @router.get("/players/{user_id}/rank", response_model=PlayerRankResponse)
async def get_player_rank( async def get_player_rank(
user_id: str, 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), service: StatsService = Depends(get_stats_service_dep),
): ):
"""Get player's rank on a leaderboard.""" """Get player's rank on a leaderboard."""
@ -346,7 +346,7 @@ async def get_my_stats(
@router.get("/me/rank", response_model=PlayerRankResponse) @router.get("/me/rank", response_model=PlayerRankResponse)
async def get_my_rank( 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), user: User = Depends(require_user),
service: StatsService = Depends(get_stats_service_dep), 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 wolfpacks: int = 0
current_win_streak: int = 0 current_win_streak: int = 0
best_win_streak: int = 0 best_win_streak: int = 0
rating: float = 1500.0
rating_deviation: float = 350.0
first_game_at: Optional[datetime] = None first_game_at: Optional[datetime] = None
last_game_at: Optional[datetime] = None last_game_at: Optional[datetime] = None
achievements: List[str] = field(default_factory=list) achievements: List[str] = field(default_factory=list)
@ -156,6 +158,8 @@ class StatsService:
wolfpacks=row["wolfpacks"] or 0, wolfpacks=row["wolfpacks"] or 0,
current_win_streak=row["current_win_streak"] or 0, current_win_streak=row["current_win_streak"] or 0,
best_win_streak=row["best_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, 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, 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], achievements=[a["achievement_id"] for a in achievements],
@ -184,6 +188,7 @@ class StatsService:
"avg_score": ("avg_score", "ASC"), # Lower is better "avg_score": ("avg_score", "ASC"), # Lower is better
"knockouts": ("knockouts", "DESC"), "knockouts": ("knockouts", "DESC"),
"streak": ("best_win_streak", "DESC"), "streak": ("best_win_streak", "DESC"),
"rating": ("rating", "DESC"),
} }
if metric not in order_map: if metric not in order_map:
@ -203,6 +208,7 @@ class StatsService:
SELECT SELECT
user_id, username, games_played, games_won, user_id, username, games_played, games_won,
win_rate, avg_score, knockouts, best_win_streak, win_rate, avg_score, knockouts, best_win_streak,
COALESCE(rating, 1500) as rating,
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM leaderboard_overall FROM leaderboard_overall
ORDER BY {column} {direction} 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.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, ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score,
s.knockouts, s.best_win_streak, s.knockouts, s.best_win_streak,
COALESCE(s.rating, 1500) as rating,
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM player_stats s FROM player_stats s
JOIN users_v2 u ON s.user_id = u.id 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 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; ALTER TABLE player_stats ADD COLUMN games_won_vs_humans INT DEFAULT 0;
END IF; 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 $$; END $$;
-- Stats processing queue (for async stats processing) -- Stats processing queue (for async stats processing)
@ -265,9 +281,19 @@ CREATE TABLE IF NOT EXISTS system_metrics (
); );
-- Leaderboard materialized view (refreshed periodically) -- 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 $$ DO $$
BEGIN 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 IF NOT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
EXECUTE ' EXECUTE '
CREATE MATERIALIZED VIEW leaderboard_overall AS CREATE MATERIALIZED VIEW leaderboard_overall AS
@ -282,6 +308,7 @@ BEGIN
s.best_score as best_round_score, s.best_score as best_round_score,
s.knockouts, s.knockouts,
s.best_win_streak, s.best_win_streak,
COALESCE(s.rating, 1500) as rating,
s.last_game_at s.last_game_at
FROM player_stats s FROM player_stats s
JOIN users_v2 u ON s.user_id = u.id 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 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); CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC);
END IF; 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 IF;
END $$; END $$;
""" """