19 Commits
3.0 ... v3.1.1

Author SHA1 Message Date
adlee-was-taken
b7b21d8378 Bump version to 3.1.1, add mobile portrait layout documentation
- Update version from 2.0.1 to 3.1.1 in pyproject.toml and server/main.py
- Add V3_17_MOBILE_PORTRAIT_LAYOUT.md documenting all mobile improvements:
  responsive layout, animation sizing fixes, compact header, bottom drawers
- Add V3_17 entry to V3 master plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:14:06 -05:00
adlee-was-taken
fb3bd53b0a Fix mobile animation card sizing and layout polish
- Fix animation overlay cards rendering at wrong size: base .card CSS
  (clamp 65px min) was overriding the inline dimensions set by JS.
  Add !important to .draw-anim-front/.draw-anim-back width/height: 100%
  so overlays always match their parent container size.
- Size opponent swap held card to match opponent card dimensions instead
  of defaulting to deck size (looked oversized on mobile)
- Shrink dealer chip on mobile (38px -> 20px) to fit opponent areas
- Make header more compact: smaller fonts, tighter gaps, nowrap on badges
- Bump deck/discard to 72x101px to match player card size on mobile
- Add spacing between header/opponents, and between deck area/player cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:11:39 -05:00
adlee-was-taken
4fcdf13f66 Fix mobile portrait layout: lobby overlap, deal animation, card font sizes
- Add renderGame() guard during deal animation to prevent DOM destruction
  mid-animation causing cards to pile up at wrong positions
- Push lobby content below fixed auth-bar (padding 15px -> 50px top)
- Scale player card font-size to 1.5rem/1.3rem for readable text on mobile
- Add full mobile portrait layout: bottom drawers, compact header, responsive
  card grid sizing, safe-area insets, and mobile detection via matchMedia
- Add cardFontSize() helper for consistent proportional font scaling
- Add mobile bottom bar with drawer toggles for standings/scores

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:52:44 -05:00
adlee-was-taken
6673e63241 Enable HTTPS-only with HTTP->HTTPS redirect
SSL cert issued via Let's Encrypt. Remove HTTP fallback router,
enable redirect, reduce Traefik log level to WARN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:12:48 -05:00
adlee-was-taken
62e7d4e1dd Fix End Game showing false 'Connection lost' error
Add _intentionalClose flag to suppress error when server closes
WebSocket after game_ended broadcast. Clean transition to lobby.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:39:43 -05:00
adlee-was-taken
bae5d8da3c Wire authManager into GolfGame instance for WebSocket token auth
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:32:49 -05:00
adlee-was-taken
62e3dc0395 Allow ws:// in production CSP for pre-SSL WebSocket connections
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:30:29 -05:00
adlee-was-taken
bda88d8218 Add gap between login and signup buttons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:27:29 -05:00
adlee-was-taken
b5a8e1fe7b Fix Traefik network resolution - use golfgame_web not internal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:25:41 -05:00
adlee-was-taken
929ab0f320 Enable Traefik debug logging and access logs for troubleshooting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:23:44 -05:00
adlee-was-taken
7026d86081 Link HTTP fallback router to golf service explicitly
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:22:40 -05:00
adlee-was-taken
b2ce6f5cf1 Add HTTP fallback route for pre-DNS testing, disable redirect temporarily
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:20:24 -05:00
adlee-was-taken
d4a39fe234 Upgrade Traefik to v3.6 for Docker Engine v29 API negotiation fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:18:20 -05:00
adlee-was-taken
9966fd9470 Set DOCKER_API_VERSION for Traefik compatibility with Docker Engine v29
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:16:00 -05:00
adlee-was-taken
050294754c Upgrade Traefik v2.10 to v3.3 for Docker Engine v29 compatibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:15:09 -05:00
adlee-was-taken
1856019a95 Fix Dockerfile WORKDIR for server relative imports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:05:29 -05:00
adlee-was-taken
f68d0bc26d 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>
2026-02-21 20:02:10 -05:00
adlee-was-taken
c59c1e28e2 Smooth held card transition and scale font with card size
Remove scale(1.15) size jump on held card, keep gold border/glow highlight.
Set animation card font-size proportionally to card width so text matches
across deck, hand, and opponent card sizes. Animate font-size during swaps
so text scales smoothly as cards travel between different-sized positions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:14:27 -05:00
adlee-was-taken
bfa94830a7 Move V2_BUILD_PLAN.md to docs/v2/
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:59:48 -05:00
24 changed files with 2523 additions and 214 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

@@ -33,5 +33,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
EXPOSE 8000 EXPOSE 8000
# Run with uvicorn # Run with uvicorn from the server directory (server uses relative imports)
CMD ["python", "-m", "uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"] WORKDIR /app/server
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -66,10 +66,14 @@ class GolfGame {
this.discardHistory = []; this.discardHistory = [];
this.maxDiscardHistory = 5; this.maxDiscardHistory = 5;
// Mobile detection
this.isMobile = false;
this.initElements(); this.initElements();
this.initAudio(); this.initAudio();
this.initCardTooltips(); this.initCardTooltips();
this.bindEvents(); this.bindEvents();
this.initMobileDetection();
this.checkUrlParams(); this.checkUrlParams();
} }
@@ -79,13 +83,57 @@ 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);
} }
} }
initMobileDetection() {
const mql = window.matchMedia('(max-width: 500px) and (orientation: portrait)');
const update = (e) => {
this.isMobile = e.matches;
document.body.classList.toggle('mobile-portrait', e.matches);
// Close any open drawers on layout change
if (!e.matches) {
this.closeDrawers();
}
};
mql.addEventListener('change', update);
update(mql);
// Bottom bar drawer toggles
const bottomBar = document.getElementById('mobile-bottom-bar');
const backdrop = document.getElementById('drawer-backdrop');
if (bottomBar) {
bottomBar.addEventListener('click', (e) => {
const btn = e.target.closest('.mobile-bar-btn');
if (!btn) return;
const drawerId = btn.dataset.drawer;
const panel = document.getElementById(drawerId);
if (!panel) return;
const isOpen = panel.classList.contains('drawer-open');
this.closeDrawers();
if (!isOpen) {
panel.classList.add('drawer-open');
btn.classList.add('active');
if (backdrop) backdrop.classList.add('visible');
}
});
}
if (backdrop) {
backdrop.addEventListener('click', () => this.closeDrawers());
}
}
closeDrawers() {
document.querySelectorAll('.side-panel.drawer-open').forEach(p => p.classList.remove('drawer-open'));
document.querySelectorAll('.mobile-bar-btn.active').forEach(b => b.classList.remove('active'));
const backdrop = document.getElementById('drawer-backdrop');
if (backdrop) backdrop.classList.remove('visible');
}
initAudio() { initAudio() {
// Initialize audio context on first user interaction // Initialize audio context on first user interaction
const initCtx = () => { const initCtx = () => {
@@ -367,16 +415,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 +526,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 +560,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 +667,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);
@@ -627,7 +688,10 @@ class GolfGame {
this.ws.onclose = () => { this.ws.onclose = () => {
console.log('Disconnected from server'); console.log('Disconnected from server');
if (!this._intentionalClose) {
this.showError('Connection lost. Please refresh the page.'); this.showError('Connection lost. Please refresh the page.');
}
this._intentionalClose = false;
}; };
this.ws.onerror = (error) => { this.ws.onerror = (error) => {
@@ -636,6 +700,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 +754,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();
@@ -860,22 +933,88 @@ class GolfGame {
case 'game_ended': case 'game_ended':
// Host ended the game or player was kicked // Host ended the game or player was kicked
this.ws.close(); this._intentionalClose = true;
this.showLobby(); if (this.ws) this.ws.close();
this.showScreen('lobby');
if (data.reason) { if (data.reason) {
this.showError(data.reason); this.showError(data.reason);
} }
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 +1022,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 +1648,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 ---
@@ -1546,6 +1925,17 @@ class GolfGame {
this.dealAnimationInProgress = true; this.dealAnimationInProgress = true;
if (window.cardAnimations) { if (window.cardAnimations) {
// Use double-rAF to ensure layout is fully computed after renderGame().
// First rAF: browser computes styles. Second rAF: layout is painted.
// This is critical on mobile where CSS !important rules need to apply
// before getBoundingClientRect() returns correct card slot positions.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Verify rects are valid before starting animation
const testRect = this.getCardSlotRect(this.playerId, 0);
if (this.isMobile) {
console.log('[DEAL] Starting deal animation, test rect:', testRect);
}
window.cardAnimations.animateDealing( window.cardAnimations.animateDealing(
this.gameState, this.gameState,
(playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx), (playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx),
@@ -1560,6 +1950,8 @@ class GolfGame {
this.animateOpponentInitialFlips(); this.animateOpponentInitialFlips();
} }
); );
});
});
} else { } else {
// Fallback // Fallback
this.dealAnimationInProgress = false; this.dealAnimationInProgress = false;
@@ -1620,14 +2012,22 @@ class GolfGame {
getCardSlotRect(playerId, cardIdx) { getCardSlotRect(playerId, cardIdx) {
if (playerId === this.playerId) { if (playerId === this.playerId) {
const cards = this.playerCards.querySelectorAll('.card'); const cards = this.playerCards.querySelectorAll('.card');
return cards[cardIdx]?.getBoundingClientRect() || null; const rect = cards[cardIdx]?.getBoundingClientRect() || null;
if (this.isMobile && rect) {
console.log(`[DEAL-DEBUG] Player card[${cardIdx}] rect:`, {l: rect.left, t: rect.top, w: rect.width, h: rect.height});
}
return rect;
} else { } else {
const area = this.opponentsRow.querySelector( const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${playerId}"]` `.opponent-area[data-player-id="${playerId}"]`
); );
if (area) { if (area) {
const cards = area.querySelectorAll('.card'); const cards = area.querySelectorAll('.card');
return cards[cardIdx]?.getBoundingClientRect() || null; const rect = cards[cardIdx]?.getBoundingClientRect() || null;
if (this.isMobile && rect) {
console.log(`[DEAL-DEBUG] Opponent ${playerId} card[${cardIdx}] rect:`, {l: rect.left, t: rect.top, w: rect.width, h: rect.height});
}
return rect;
} }
} }
return null; return null;
@@ -1641,8 +2041,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 +2087,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) {
@@ -2384,11 +2780,22 @@ class GolfGame {
// Use unified swap animation // Use unified swap animation
if (window.cardAnimations) { if (window.cardAnimations) {
// For opponent swaps, size the held card to match the opponent card
// rather than the deck size (default holding rect uses deck dimensions,
// which looks oversized next to small opponent cards on mobile)
const holdingRect = window.cardAnimations.getHoldingRect();
const heldRect = holdingRect ? {
left: holdingRect.left,
top: holdingRect.top,
width: sourceRect.width,
height: sourceRect.height
} : null;
window.cardAnimations.animateUnifiedSwap( window.cardAnimations.animateUnifiedSwap(
discardCard, // handCardData - card going to discard discardCard, // handCardData - card going to discard
newCardInHand, // heldCardData - card going to hand newCardInHand, // heldCardData - card going to hand
sourceRect, // handRect - where the hand card is sourceRect, // handRect - where the hand card is
null, // heldRect - use default holding position heldRect, // heldRect - holding position, opponent card size
{ {
rotation: sourceRotation, rotation: sourceRotation,
wasHandFaceDown: !wasFaceUp, wasHandFaceDown: !wasFaceUp,
@@ -2558,6 +2965,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 +2993,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) {
@@ -2593,6 +3013,11 @@ class GolfGame {
} }
screen.classList.add('active'); screen.classList.add('active');
// Close mobile drawers on screen change
if (this.isMobile) {
this.closeDrawers();
}
// Handle auth bar visibility - hide global bar during game, show in-game controls instead // Handle auth bar visibility - hide global bar during game, show in-game controls instead
const isGameScreen = screen === this.gameScreen; const isGameScreen = screen === this.gameScreen;
const user = this.auth?.user; const user = this.auth?.user;
@@ -3105,12 +3530,14 @@ class GolfGame {
this.heldCardFloating.style.top = `${cardTop}px`; this.heldCardFloating.style.top = `${cardTop}px`;
this.heldCardFloating.style.width = `${cardWidth}px`; this.heldCardFloating.style.width = `${cardWidth}px`;
this.heldCardFloating.style.height = `${cardHeight}px`; this.heldCardFloating.style.height = `${cardHeight}px`;
// Scale font to card width (matches cardAnimations.cardFontSize ratio)
if (this.isMobile) {
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
}
// Position discard button attached to right side of held card // Position discard button attached to right side of held card
const scaledWidth = cardWidth * 1.15; // Account for scale transform const buttonLeft = cardLeft + cardWidth; // Right edge of card (no gap)
const scaledHeight = cardHeight * 1.15; const buttonTop = cardTop + cardHeight * 0.3; // Vertically centered on card
const buttonLeft = cardLeft + scaledWidth / 2 + cardWidth / 2; // Right edge of scaled card (no gap)
const buttonTop = cardTop + (scaledHeight - cardHeight) / 2 + cardHeight * 0.3; // Vertically centered on card
this.discardBtn.style.left = `${buttonLeft}px`; this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`; this.discardBtn.style.top = `${buttonTop}px`;
@@ -3166,6 +3593,9 @@ class GolfGame {
this.heldCardFloating.style.top = `${cardTop}px`; this.heldCardFloating.style.top = `${cardTop}px`;
this.heldCardFloating.style.width = `${cardWidth}px`; this.heldCardFloating.style.width = `${cardWidth}px`;
this.heldCardFloating.style.height = `${cardHeight}px`; this.heldCardFloating.style.height = `${cardHeight}px`;
if (this.isMobile) {
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
}
this.heldCardFloatingContent.innerHTML = ''; this.heldCardFloatingContent.innerHTML = '';
this.heldCardFloating.classList.remove('hidden'); this.heldCardFloating.classList.remove('hidden');
@@ -3311,6 +3741,7 @@ class GolfGame {
renderGame() { renderGame() {
if (!this.gameState) return; if (!this.gameState) return;
if (this.dealAnimationInProgress) return;
// Update CPU considering visual state // Update CPU considering visual state
this.updateCpuConsideringState(); this.updateCpuConsideringState();
@@ -3875,13 +4306,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() {
@@ -4130,6 +4556,7 @@ class GolfGame {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
window.game = new GolfGame(); window.game = new GolfGame();
window.auth = new AuthManager(window.game); window.auth = new AuthManager(window.game);
window.game.authManager = window.auth;
}); });
@@ -4146,13 +4573,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');
@@ -4164,6 +4610,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');
@@ -4249,10 +4696,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';
} }
@@ -4262,6 +4705,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;
@@ -4270,7 +4714,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();
@@ -4282,10 +4726,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';
} }
@@ -4310,16 +4750,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

@@ -75,6 +75,13 @@ class CardAnimations {
return easings[type] || 'easeOutQuad'; return easings[type] || 'easeOutQuad';
} }
// Font size proportional to card width — consistent across all card types.
// Mobile uses a tighter ratio since cards are smaller and closer together.
cardFontSize(width) {
const ratio = document.body.classList.contains('mobile-portrait') ? 0.35 : 0.5;
return (width * ratio) + 'px';
}
// Create animated card element with 3D flip structure // Create animated card element with 3D flip structure
createAnimCard(rect, showBack = false, deckColor = null) { createAnimCard(rect, showBack = false, deckColor = null) {
const card = document.createElement('div'); const card = document.createElement('div');
@@ -92,6 +99,9 @@ class CardAnimations {
card.style.top = rect.top + 'px'; card.style.top = rect.top + 'px';
card.style.width = rect.width + 'px'; card.style.width = rect.width + 'px';
card.style.height = rect.height + 'px'; card.style.height = rect.height + 'px';
// Scale font-size proportionally to card width
const front = card.querySelector('.draw-anim-front');
if (front) front.style.fontSize = this.cardFontSize(rect.width);
} }
// Apply deck color to back // Apply deck color to back
@@ -448,10 +458,6 @@ class CardAnimations {
const deckColor = this.getDeckColor(); const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(rect, true, deckColor); const animCard = this.createAnimCard(rect, true, deckColor);
// Match source card's font-size (opponent cards are smaller than default)
const srcFontSize = getComputedStyle(cardElement).fontSize;
const front = animCard.querySelector('.draw-anim-front');
if (front) front.style.fontSize = srcFontSize;
this.setCardContent(animCard, cardData); this.setCardContent(animCard, cardData);
// Apply rotation to match arch layout // Apply rotation to match arch layout
@@ -607,10 +613,6 @@ class CardAnimations {
const deckColor = this.getDeckColor(); const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(rect, true, deckColor); const animCard = this.createAnimCard(rect, true, deckColor);
// Match source card's font-size (opponent cards are smaller than default)
const srcFontSize = getComputedStyle(sourceCardElement).fontSize;
const front = animCard.querySelector('.draw-anim-front');
if (front) front.style.fontSize = srcFontSize;
this.setCardContent(animCard, discardCard); this.setCardContent(animCard, discardCard);
if (rotation) { if (rotation) {
@@ -1164,6 +1166,9 @@ class CardAnimations {
}); });
// Hand card arcs to discard (apply counter-rotation to land flat) // Hand card arcs to discard (apply counter-rotation to land flat)
const handFront = travelingHand.querySelector('.draw-anim-front');
const heldFront = travelingHeld.querySelector('.draw-anim-front');
timeline.add({ timeline.add({
targets: travelingHand, targets: travelingHand,
left: discardRect.left, left: discardRect.left,
@@ -1178,6 +1183,16 @@ class CardAnimations {
easing: this.getEasing('arc'), easing: this.getEasing('arc'),
}, `-=${T.lift / 2}`); }, `-=${T.lift / 2}`);
// Scale hand card font to match discard size
if (handFront) {
timeline.add({
targets: handFront,
fontSize: this.cardFontSize(discardRect.width),
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.arc}`);
}
// Held card arcs to hand slot (apply rotation to match hand position) // Held card arcs to hand slot (apply rotation to match hand position)
timeline.add({ timeline.add({
targets: travelingHeld, targets: travelingHeld,
@@ -1193,6 +1208,16 @@ class CardAnimations {
easing: this.getEasing('arc'), easing: this.getEasing('arc'),
}, `-=${T.arc + T.lift / 2}`); }, `-=${T.arc + T.lift / 2}`);
// Scale held card font to match hand size
if (heldFront) {
timeline.add({
targets: heldFront,
fontSize: this.cardFontSize(handRect.width),
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.arc}`);
}
// Settle with gentle overshoot // Settle with gentle overshoot
timeline.add({ timeline.add({
targets: [travelingHand, travelingHeld], targets: [travelingHand, travelingHeld],
@@ -1404,6 +1429,9 @@ class CardAnimations {
card.style.top = rect.top + 'px'; card.style.top = rect.top + 'px';
card.style.width = rect.width + 'px'; card.style.width = rect.width + 'px';
card.style.height = rect.height + 'px'; card.style.height = rect.height + 'px';
// Scale font-size proportionally to card width
const front = card.querySelector('.draw-anim-front');
if (front) front.style.fontSize = this.cardFontSize(rect.width);
if (rotation) { if (rotation) {
card.style.transform = `rotate(${rotation}deg)`; card.style.transform = `rotate(${rotation}deg)`;
@@ -1444,9 +1472,8 @@ class CardAnimations {
try { try {
anime({ anime({
targets: element, targets: element,
scale: [0.5, 1.25, 1.15], opacity: [0, 1],
opacity: [0, 1, 1], duration: 200,
duration: 300,
easing: 'easeOutQuad' easing: 'easeOutQuad'
}); });
} catch (e) { } catch (e) {

View File

@@ -126,6 +126,13 @@ class CardManager {
cardEl.style.width = `${rect.width}px`; cardEl.style.width = `${rect.width}px`;
cardEl.style.height = `${rect.height}px`; cardEl.style.height = `${rect.height}px`;
// On mobile, scale font proportional to card width so rank/suit fit
if (document.body.classList.contains('mobile-portrait')) {
cardEl.style.fontSize = `${rect.width * 0.35}px`;
} else {
cardEl.style.fontSize = '';
}
if (animate) { if (animate) {
const moveDuration = window.TIMING?.card?.moving || 350; const moveDuration = window.TIMING?.card?.moving || 350;
setTimeout(() => cardEl.classList.remove('moving'), moveDuration); setTimeout(() => cardEl.classList.remove('moving'), moveDuration);

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Golf Card Game</title> <title>Golf Card Game</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
@@ -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">
@@ -381,6 +398,15 @@
</table> </table>
</div> </div>
</div> </div>
<!-- Mobile bottom bar (hidden on desktop) -->
<div id="mobile-bottom-bar">
<button class="mobile-bar-btn" data-drawer="standings-panel">Standings</button>
<button class="mobile-bar-btn" data-drawer="scoreboard">Scores</button>
</div>
<!-- Drawer backdrop for mobile -->
<div id="drawer-backdrop" class="drawer-backdrop"></div>
</div> </div>
<!-- Rules Screen --> <!-- Rules Screen -->
@@ -717,6 +743,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 +843,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

@@ -1132,8 +1132,8 @@ input::placeholder {
.draw-anim-front, .draw-anim-front,
.draw-anim-back { .draw-anim-back {
position: absolute; position: absolute;
width: 100%; width: 100% !important;
height: 100%; height: 100% !important;
backface-visibility: hidden; backface-visibility: hidden;
border-radius: 8px; border-radius: 8px;
} }
@@ -1156,10 +1156,8 @@ input::placeholder {
top: 0; top: 0;
left: 0; left: 0;
z-index: 100; z-index: 100;
transform: scale(1.15);
transform-origin: center bottom;
border: 3px solid #f4a460 !important; border: 3px solid #f4a460 !important;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7) !important; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 20px rgba(244, 164, 96, 0.6) !important;
pointer-events: none; pointer-events: none;
/* No transition - anime.js handles animations */ /* No transition - anime.js handles animations */
} }
@@ -1523,11 +1521,11 @@ input::placeholder {
@keyframes heldCardPulse { @keyframes heldCardPulse {
0%, 100% { 0%, 100% {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 20px rgba(244, 164, 96, 0.6);
} }
50% { 50% {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 35px rgba(244, 164, 96, 1), box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 30px rgba(244, 164, 96, 0.9),
0 0 50px rgba(244, 164, 96, 0.5); 0 0 45px rgba(244, 164, 96, 0.4);
} }
} }
@@ -3301,15 +3299,28 @@ 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 {
display: flex;
justify-content: center;
gap: 12px;
}
.auth-prompt.hidden {
display: none; display: none;
} }
@@ -3393,6 +3404,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
=========================================== */ =========================================== */
@@ -4404,56 +4457,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 {
@@ -4468,25 +4486,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 {
@@ -4662,9 +4662,705 @@ 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;
border-radius: 6px; border-radius: 6px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
} }
/* ============================================
MOBILE PORTRAIT LAYOUT
============================================
All rules scoped under body.mobile-portrait.
Triggered by JS matchMedia on narrow portrait screens.
Desktop layout is completely untouched.
============================================ */
/* Mobile bottom bar - hidden on desktop */
#mobile-bottom-bar {
display: none;
}
body.mobile-portrait {
height: 100dvh;
overflow: hidden;
overscroll-behavior: contain;
touch-action: manipulation;
}
body.mobile-portrait #app {
padding: 0;
height: 100dvh;
overflow: hidden;
}
/* --- Mobile: Game screen fills viewport --- */
/* IMPORTANT: Must include .active to avoid overriding .screen { display: none } */
body.mobile-portrait #game-screen.active {
height: 100dvh;
overflow: hidden;
margin-left: 0;
width: 100%;
display: flex;
flex-direction: column;
}
body.mobile-portrait .game-layout {
flex: 1;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
body.mobile-portrait .game-main {
flex: 1;
gap: 0;
justify-content: flex-start;
overflow: hidden;
min-height: 0;
}
/* --- Mobile: Compact header (single row) --- */
body.mobile-portrait .game-header {
display: flex;
flex-direction: row;
align-items: center;
padding: 4px 8px;
padding-top: calc(4px + env(safe-area-inset-top, 0px));
font-size: 0.75rem;
min-height: 32px;
width: 100%;
margin-left: 0;
gap: 4px;
margin-bottom: 4px;
}
body.mobile-portrait .header-col-left {
flex: 0 0 auto;
gap: 6px;
}
body.mobile-portrait .header-col-center {
flex: 1;
min-width: 0;
}
body.mobile-portrait .header-col-right {
flex: 0 0 auto;
gap: 4px;
}
/* Hide non-essential header items on mobile */
body.mobile-portrait .active-rules-bar,
body.mobile-portrait .game-username,
body.mobile-portrait #game-logout-btn {
display: none !important;
}
body.mobile-portrait .status-message {
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
body.mobile-portrait .round-info {
font-size: 0.75rem;
white-space: nowrap;
}
body.mobile-portrait #leave-game-btn {
padding: 2px 6px;
font-size: 0.6rem;
white-space: nowrap;
}
body.mobile-portrait .mute-btn {
font-size: 0.85rem;
padding: 2px;
}
body.mobile-portrait .final-turn-badge {
font-size: 0.6rem;
padding: 2px 6px;
white-space: nowrap;
}
/* --- Mobile: Game table — opponents pinned top, rest centered in remaining space --- */
body.mobile-portrait .game-table {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0 !important;
flex: 1;
overflow: hidden;
padding: 0 4px;
min-height: 0;
}
/* --- Mobile: Opponents as flat horizontal strip, pinned to top --- */
body.mobile-portrait .opponents-row {
display: flex;
flex-wrap: nowrap;
justify-content: center;
align-items: flex-start;
gap: 6px;
min-height: 0 !important;
padding: 2px 8px 6px;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
flex-shrink: 0;
}
/* --- Mobile: Player row gets remaining space, centered vertically --- */
body.mobile-portrait .player-row {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
flex: 1;
min-height: 0;
}
/* Remove all arch rotation and margin on mobile */
body.mobile-portrait .opponents-row .opponent-area {
margin-bottom: 0 !important;
transform: none !important;
flex-shrink: 0;
}
body.mobile-portrait .opponent-area {
padding: 3px 5px 4px;
border-radius: 6px;
min-width: 0;
position: relative;
overflow: visible;
}
body.mobile-portrait .opponent-area .dealer-chip {
width: 20px;
height: 20px;
font-size: 10px;
border-width: 2px;
bottom: -6px;
left: -6px;
}
body.mobile-portrait .opponent-area h4 {
font-size: 0.6rem;
margin: 0 0 2px 0;
padding: 2px 4px;
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
}
body.mobile-portrait .opponent-area .card-grid {
grid-template-columns: repeat(3, 32px) !important;
gap: 2px !important;
}
body.mobile-portrait .opponent-area .card {
width: 32px !important;
height: 45px !important;
font-size: 0.6rem !important;
border-radius: 3px;
}
body.mobile-portrait .opponent-showing {
font-size: 0.55rem;
padding: 0px 3px;
margin-left: 3px;
}
/* --- Mobile: Deck/Discard area centered --- */
body.mobile-portrait .table-center {
padding: 5px 10px;
border-radius: 8px;
}
body.mobile-portrait .deck-area {
gap: 10px;
align-items: flex-start;
}
body.mobile-portrait .deck-area > .card,
body.mobile-portrait #deck,
body.mobile-portrait #discard {
width: 72px !important;
height: 101px !important;
font-size: 1.5rem !important;
}
/* Held card floating should NOT be constrained to deck/discard size */
body.mobile-portrait .held-card-floating {
width: 72px !important;
height: 101px !important;
}
body.mobile-portrait .discard-stack {
gap: 6px;
}
/* Discard button - horizontal on mobile instead of vertical tab */
body.mobile-portrait #discard-btn {
position: fixed;
writing-mode: horizontal-tb;
text-orientation: initial;
padding: 8px 16px;
font-size: 0.8rem;
border-radius: 8px;
}
/* --- Mobile: Player cards — explicit sizes for reliable layout --- */
body.mobile-portrait .player-section {
width: auto;
padding: 0;
}
body.mobile-portrait .player-area {
padding: 5px 8px;
border-radius: 8px;
width: auto;
display: inline-block;
}
body.mobile-portrait .player-area h4 {
font-size: 0.8rem;
padding: 3px 8px;
margin-bottom: 4px;
}
body.mobile-portrait .player-showing {
font-size: 0.75rem;
}
/* Player hand: fixed-size cards */
body.mobile-portrait .player-section .card-grid {
grid-template-columns: repeat(3, 72px) !important;
gap: 5px !important;
justify-content: center;
}
body.mobile-portrait .player-section .card {
width: 72px !important;
height: 101px !important;
font-size: 1.5rem !important;
}
/* Real cards: font-size is now set inline by card-manager.js (proportional to card width).
Override the desktop clamp values to inherit from the element. */
body.mobile-portrait .real-card .card-face-front,
body.mobile-portrait .real-card .card-face-back {
font-size: inherit;
line-height: 1;
}
/* --- Mobile: Side panels become bottom drawers --- */
body.mobile-portrait .side-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
max-height: 55vh;
border-radius: 16px 16px 0 0;
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
z-index: 600;
transform: translateY(100%);
transition: transform 0.3s ease-out;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
body.mobile-portrait .side-panel.left-panel,
body.mobile-portrait .side-panel.right-panel {
left: 0;
right: 0;
}
body.mobile-portrait .side-panel.drawer-open {
transform: translateY(0);
}
/* Drawer handle */
body.mobile-portrait .side-panel::before {
content: '';
display: block;
width: 40px;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
margin: 0 auto 10px;
}
/* Drawer backdrop */
body.mobile-portrait .drawer-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 599;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-out;
}
body.mobile-portrait .drawer-backdrop.visible {
opacity: 1;
pointer-events: auto;
}
/* Score table in drawer: full width */
body.mobile-portrait .side-panel table {
width: 100%;
font-size: 0.85rem;
}
body.mobile-portrait .side-panel th,
body.mobile-portrait .side-panel td {
padding: 4px 8px;
}
/* Standings list in drawer */
body.mobile-portrait .standings-list .rank-row {
font-size: 0.85rem;
padding: 3px 0;
}
/* Game buttons in drawer */
body.mobile-portrait .game-buttons {
display: flex;
gap: 8px;
justify-content: center;
padding: 8px 0;
}
/* --- Mobile: Bottom bar --- */
body.mobile-portrait #mobile-bottom-bar {
display: flex;
justify-content: space-around;
align-items: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
padding: 6px 16px;
padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px));
width: 100%;
z-index: 500;
flex-shrink: 0;
border-top: 1px solid rgba(244, 164, 96, 0.2);
}
body.mobile-portrait #mobile-bottom-bar .mobile-bar-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.8);
font-size: 0.75rem;
font-weight: 600;
padding: 6px 16px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
border-radius: 6px;
transition: background 0.15s, color 0.15s;
}
body.mobile-portrait #mobile-bottom-bar .mobile-bar-btn:active {
background: rgba(244, 164, 96, 0.3);
color: #f4a460;
}
body.mobile-portrait #mobile-bottom-bar .mobile-bar-btn.active {
color: #f4a460;
background: rgba(244, 164, 96, 0.15);
}
/* --- Mobile: Non-game screens --- */
body.mobile-portrait #lobby-screen {
padding: 50px 12px 15px;
overflow-y: auto;
max-height: 100dvh;
}
body.mobile-portrait #waiting-screen {
padding: 10px 12px;
overflow-y: auto;
max-height: 100dvh;
}
/* --- Mobile: Very short screens (e.g. iPhone SE) --- */
@media (max-height: 600px) {
body.mobile-portrait .opponents-row {
padding: 2px 8px 0;
}
body.mobile-portrait .opponent-area .card-grid {
grid-template-columns: repeat(3, 26px) !important;
gap: 1px !important;
}
body.mobile-portrait .opponent-area .card {
width: 26px !important;
height: 36px !important;
font-size: 0.45rem !important;
}
body.mobile-portrait .table-center {
padding: 3px 8px;
}
body.mobile-portrait .deck-area > .card,
body.mobile-portrait #deck,
body.mobile-portrait #discard {
width: 60px !important;
height: 84px !important;
font-size: 1.3rem !important;
}
body.mobile-portrait .held-card-floating {
width: 60px !important;
height: 84px !important;
}
body.mobile-portrait .player-row {
gap: 6px;
}
body.mobile-portrait .player-area {
padding: 3px 5px;
}
body.mobile-portrait .player-section .card-grid {
grid-template-columns: repeat(3, 60px) !important;
gap: 4px !important;
}
body.mobile-portrait .player-section .card {
width: 60px !important;
height: 84px !important;
font-size: 1.3rem !important;
}
body.mobile-portrait .player-area h4 {
font-size: 0.7rem;
padding: 2px 6px;
margin-bottom: 3px;
}
}

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,26 +31,31 @@ 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
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=golfgame_web"
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)" - "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)"
- "traefik.http.routers.golf.entrypoints=websecure" - "traefik.http.routers.golf.entrypoints=websecure"
- "traefik.http.routers.golf.tls=true" - "traefik.http.routers.golf.tls=true"
@@ -77,13 +83,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,14 +102,19 @@ 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:v3.6
environment:
- DOCKER_API_VERSION=1.44
command: command:
- "--api.dashboard=true" - "--api.dashboard=true"
- "--api.insecure=true"
- "--accesslog=true"
- "--log.level=WARN"
- "--providers.docker=true" - "--providers.docker=true"
- "--providers.docker.exposedbydefault=false" - "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
@@ -125,7 +136,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 128M memory: 64M
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -30,6 +30,7 @@ This plan is split into independent vertical slices ordered by priority and impa
| `V3_14_ACTIVE_RULES_CONTEXT.md` | Contextual rule highlighting | Low | Low | None | | `V3_14_ACTIVE_RULES_CONTEXT.md` | Contextual rule highlighting | Low | Low | None |
| `V3_15_DISCARD_PILE_HISTORY.md` | Show recent discards fanned | Low | Medium | None | | `V3_15_DISCARD_PILE_HISTORY.md` | Show recent discards fanned | Low | Medium | None |
| `V3_16_REALISTIC_CARD_SOUNDS.md` | Improved audio feedback | Nice | Medium | None | | `V3_16_REALISTIC_CARD_SOUNDS.md` | Improved audio feedback | Nice | Medium | None |
| `V3_17_MOBILE_PORTRAIT_LAYOUT.md` | Full mobile portrait layout + animation fixes | High | High | 02, 11 |
--- ---

View File

@@ -0,0 +1,117 @@
# V3.17: Mobile Portrait Layout
**Version:** 3.1.1
**Commits:** `4fcdf13`, `fb3bd53`
## Overview
Full mobile portrait layout for phones, triggered by JS `matchMedia` on narrow portrait screens (`max-width: 500px`, `orientation: portrait`). The desktop layout is completely untouched — all mobile rules are scoped under `body.mobile-portrait`.
## Key Features
### Responsive Game Layout
- Viewport fills 100dvh with no scroll; `overscroll-behavior: contain` prevents pull-to-refresh
- Game screen uses flexbox column: compact header → opponents row → player row → bottom bar
- Safe-area insets respected for notched devices (`env(safe-area-inset-top/bottom)`)
### Compact Header
- Single-row header with reduced font sizes (0.75rem) and tight gaps
- Non-essential items hidden on mobile: username display, logout button, active rules bar
- Status message, round info, final turn badge, and leave button all use `white-space: nowrap` with ellipsis overflow
### Opponent Cards
- Flat horizontal strip (no arch rotation) with horizontal scroll for 4+ opponents
- Cards scaled to 32x45px with 0.6rem font (26x36px on short screens)
- Dealer chip scaled from 38px to 20px diameter to fit compact opponent areas
- Showing score badge sized proportionally
### Deck/Discard Area
- Deck and discard cards match player card size (72x101px) for visual consistency
- Held card floating matches player card size with proportional font scaling
### Player Cards
- Fixed 72x101px cards with 1.5rem font in 3-column grid
- 60x84px with 1.3rem font on short screens (max-height: 600px)
- Font size set inline by `card-manager.js` proportional to card width (0.35x ratio on mobile)
### Side Panels as Bottom Drawers
- Standings and scoreboard panels slide up as bottom drawers from a mobile bottom bar
- Drawer backdrop overlay with tap-to-dismiss
- Drag handle visual indicator on each drawer
- Drawers auto-close on screen change or layout change back to desktop
### Short Screen Fallback
- `@media (max-height: 600px)` reduces all card sizes, gaps, and padding
- Opponent cards: 26x36px, deck/discard: 60x84px, player cards: 60x84px
## Animation Fixes
### Deal Animation Guard
- `renderGame()` returns early when `dealAnimationInProgress` is true
- Prevents WebSocket state updates from destroying card slot DOM elements mid-deal animation
- Cards were piling up at (0,0) because `getCardSlotRect()` read stale/null positions after `innerHTML = ''`
### Animation Overlay Card Sizing
- **Root cause:** Base `.card` CSS (`width: clamp(65px, 5.5vw, 100px)`) was leaking into animation overlay elements (`.draw-anim-front.card`), overriding the intended `width: 100%` inherited from the overlay container
- **Effect:** Opponent flip overlays appeared at 65px instead of 32px (too big); deck/discard draw overlays appeared at 65px instead of 72px (too small)
- **Fix:** Added `!important` to `.draw-anim-front/.draw-anim-back` `width` and `height` rules to ensure animation overlays always match their parent container's inline dimensions from JavaScript
### Opponent Swap Held Card Sizing
- `fireSwapAnimation()` now passes a `heldRect` sized to match the opponent card (32px) positioned at the holding location, instead of defaulting to deck dimensions (72px)
- The traveling held card no longer appears oversized relative to opponent cards during the swap arc
### Font Size Consistency
- `cardFontSize()` helper in `CardAnimations` uses 0.35x width ratio on mobile (vs 0.5x desktop)
- Applied consistently across all animation paths: `createAnimCard`, `createCardFromData`, and arc swap font transitions
- Held card floating gets inline font-size scaled to card width on mobile
## CSS Architecture
All mobile rules use the `body.mobile-portrait` scope:
```css
/* Applied by JS matchMedia, not CSS media query */
body.mobile-portrait .selector { ... }
/* Short screen fallback uses both */
@media (max-height: 600px) {
body.mobile-portrait .selector { ... }
}
```
Card sizing uses `!important` to override base `.card` clamp values:
```css
body.mobile-portrait .opponent-area .card {
width: 32px !important;
height: 45px !important;
}
```
Animation overlays use `!important` to override base `.card` leaking:
```css
.draw-anim-front,
.draw-anim-back {
width: 100% !important;
height: 100% !important;
}
```
## Files Modified
| File | Changes |
|------|---------|
| `client/style.css` | ~470 lines of mobile portrait CSS added at end of file |
| `client/app.js` | Mobile detection, drawer management, `renderGame()` guard, swap heldRect sizing, held card font scaling |
| `client/card-animations.js` | `cardFontSize()` helper, consistent font scaling across all animation paths |
| `client/card-manager.js` | Inline font-size on mobile for `updateCardElement()` |
| `client/index.html` | Mobile bottom bar, drawer backdrop, viewport-fit=cover |
## Testing
- **Desktop:** No visual changes — all rules scoped under `body.mobile-portrait`
- **Mobile portrait:** Verify game fits 100dvh, no scroll, cards properly sized
- **Deal animation:** Cards fly to correct grid positions (not piling up)
- **Draw/discard:** Animation overlay matches source card size
- **Opponent swap:** Flip and arc animations use opponent card dimensions
- **Short screens (iPhone SE):** All elements fit with reduced sizes
- **Orientation change:** Layout switches cleanly between mobile and desktop

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "golfgame" name = "golfgame"
version = "2.0.1" version = "3.1.1"
description = "6-Card Golf card game with AI opponents" description = "6-Card Golf card game with AI opponents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

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(
@@ -257,7 +325,7 @@ async def _close_all_websockets():
app = FastAPI( app = FastAPI(
title="Golf Card Game", title="Golf Card Game",
debug=config.DEBUG, debug=config.DEBUG,
version="2.0.1", version="3.1.1",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -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

@@ -110,8 +110,10 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# Add WebSocket URLs # Add WebSocket URLs
if self.environment == "production": if self.environment == "production":
connect_sources.append(f"ws://{host}")
connect_sources.append(f"wss://{host}") connect_sources.append(f"wss://{host}")
for allowed_host in self.allowed_hosts: for allowed_host in self.allowed_hosts:
connect_sources.append(f"ws://{allowed_host}")
connect_sources.append(f"wss://{allowed_host}") connect_sources.append(f"wss://{allowed_host}")
else: else:
# Development - allow ws:// and wss:// # Development - allow ws:// and wss://

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 $$;
""" """