19 Commits

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=
# 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
ADMIN_EMAILS=
@@ -104,5 +109,13 @@ CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing enabled
# Enable rate limiting (recommended for production)
# RATE_LIMIT_ENABLED=true
# Redis URL (required for matchmaking and rate limiting)
# REDIS_URL=redis://localhost:6379
# Base URL for email links
# 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
# Run with uvicorn
CMD ["python", "-m", "uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Run with uvicorn from the server directory (server uses relative imports)
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.maxDiscardHistory = 5;
// Mobile detection
this.isMobile = false;
this.initElements();
this.initAudio();
this.initCardTooltips();
this.bindEvents();
this.initMobileDetection();
this.checkUrlParams();
}
@@ -79,13 +83,57 @@ class GolfGame {
const roomCode = params.get('room');
if (roomCode) {
this.roomCodeInput.value = roomCode.toUpperCase();
// Focus name input so user can quickly enter name and join
this.playerNameInput.focus();
this.roomCodeInput.focus();
// Clean up URL without reloading
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() {
// Initialize audio context on first user interaction
const initCtx = () => {
@@ -367,16 +415,24 @@ class GolfGame {
initElements() {
// Screens
this.lobbyScreen = document.getElementById('lobby-screen');
this.matchmakingScreen = document.getElementById('matchmaking-screen');
this.waitingScreen = document.getElementById('waiting-screen');
this.gameScreen = document.getElementById('game-screen');
// Lobby elements
this.playerNameInput = document.getElementById('player-name');
this.roomCodeInput = document.getElementById('room-code');
this.findGameBtn = document.getElementById('find-game-btn');
this.createRoomBtn = document.getElementById('create-room-btn');
this.joinRoomBtn = document.getElementById('join-room-btn');
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
this.displayRoomCode = document.getElementById('display-room-code');
this.copyRoomCodeBtn = document.getElementById('copy-room-code');
@@ -470,6 +526,8 @@ class GolfGame {
}
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.joinRoomBtn.addEventListener('click', () => { this.playSound('click'); this.joinRoom(); });
this.startGameBtn.addEventListener('click', () => { this.playSound('success'); this.startGame(); });
@@ -502,9 +560,6 @@ class GolfGame {
});
// Enter key handlers
this.playerNameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.createRoomBtn.click();
});
this.roomCodeInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.joinRoomBtn.click();
});
@@ -612,7 +667,13 @@ class GolfGame {
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
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);
@@ -627,7 +688,10 @@ class GolfGame {
this.ws.onclose = () => {
console.log('Disconnected from server');
this.showError('Connection lost. Please refresh the page.');
if (!this._intentionalClose) {
this.showError('Connection lost. Please refresh the page.');
}
this._intentionalClose = false;
};
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) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
@@ -682,6 +754,7 @@ class GolfGame {
case 'round_started':
// Clear any countdown from previous hole
this.clearNextHoleCountdown();
this.dismissScoresheetModal();
this.nextRoundBtn.classList.remove('waiting');
// Clear round winner highlights
this.roundWinnerNames = new Set();
@@ -860,22 +933,88 @@ class GolfGame {
case 'game_ended':
// Host ended the game or player was kicked
this.ws.close();
this.showLobby();
this._intentionalClose = true;
if (this.ws) this.ws.close();
this.showScreen('lobby');
if (data.reason) {
this.showError(data.reason);
}
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':
this.showError(data.message);
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
createRoom() {
const name = this.playerNameInput.value.trim() || 'Player';
const name = this.authManager?.user?.username || 'Player';
this.connect();
this.ws.onopen = () => {
this.send({ type: 'create_room', player_name: name });
@@ -883,7 +1022,7 @@ class GolfGame {
}
joinRoom() {
const name = this.playerNameInput.value.trim() || 'Player';
const name = this.authManager?.user?.username || 'Player';
const code = this.roomCodeInput.value.trim().toUpperCase();
if (code.length !== 4) {
@@ -1509,20 +1648,260 @@ class GolfGame {
}
showKnockBanner(playerName) {
const banner = document.createElement('div');
banner.className = 'knock-banner';
banner.innerHTML = `<span>${playerName ? playerName + ' knocked!' : 'KNOCK!'}</span>`;
document.body.appendChild(banner);
const duration = window.TIMING?.knock?.statusDuration || 2500;
const message = playerName ? `${playerName} KNOCKED!` : 'KNOCK!';
this.setStatus(message, 'knock');
document.body.classList.add('screen-shake');
setTimeout(() => {
banner.classList.add('fading');
document.body.classList.remove('screen-shake');
}, 800);
setTimeout(() => {
banner.remove();
}, 1100);
}, 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(() => {
modal.querySelectorAll('.ss-column-paired').forEach(col => {
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 ---
@@ -1546,20 +1925,33 @@ class GolfGame {
this.dealAnimationInProgress = true;
if (window.cardAnimations) {
window.cardAnimations.animateDealing(
this.gameState,
(playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx),
() => {
// Deal complete - allow flip prompts
this.dealAnimationInProgress = false;
// Show real cards
this.playerCards.style.visibility = 'visible';
this.opponentsRow.style.visibility = 'visible';
this.renderGame();
// Stagger opponent initial flips right after dealing
this.animateOpponentInitialFlips();
}
);
// 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(
this.gameState,
(playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx),
() => {
// Deal complete - allow flip prompts
this.dealAnimationInProgress = false;
// Show real cards
this.playerCards.style.visibility = 'visible';
this.opponentsRow.style.visibility = 'visible';
this.renderGame();
// Stagger opponent initial flips right after dealing
this.animateOpponentInitialFlips();
}
);
});
});
} else {
// Fallback
this.dealAnimationInProgress = false;
@@ -1620,14 +2012,22 @@ class GolfGame {
getCardSlotRect(playerId, cardIdx) {
if (playerId === this.playerId) {
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 {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${playerId}"]`
);
if (area) {
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;
@@ -1641,8 +2041,8 @@ class GolfGame {
const newState = this.postRevealState || this.gameState;
if (!oldState || !newState) {
// Fallback: show scoreboard immediately
this.showScoreboard(scores, false, rankings);
// Fallback: show scoresheet immediately
this.showScoresheetModal(scores, this.gameState, rankings);
return;
}
@@ -1687,18 +2087,14 @@ class GolfGame {
this.highlightPlayerArea(player.id, false);
}
// All revealed - run score tally before showing scoreboard
// All revealed - show scoresheet modal
this.revealAnimationInProgress = false;
this.preRevealState = null;
this.postRevealState = null;
this.renderGame();
// V3_07: Animated score tallying
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
await this.runScoreTally(newState.players, knockerId);
}
this.showScoreboard(scores, false, rankings);
// V3_17: Scoresheet modal replaces tally + side panel scoreboard
this.showScoresheetModal(scores, newState, rankings);
}
getCardsToReveal(oldState, newState) {
@@ -2384,11 +2780,22 @@ class GolfGame {
// Use unified swap animation
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(
discardCard, // handCardData - card going to discard
newCardInHand, // heldCardData - card going to hand
sourceRect, // handRect - where the hand card is
null, // heldRect - use default holding position
heldRect, // heldRect - holding position, opponent card size
{
rotation: sourceRotation,
wasHandFaceDown: !wasFaceUp,
@@ -2558,6 +2965,7 @@ class GolfGame {
nextRound() {
this.clearNextHoleCountdown();
this.clearScoresheetCountdown();
this.send({ type: 'next_round' });
this.gameButtons.classList.add('hidden');
this.nextRoundBtn.classList.remove('waiting');
@@ -2585,7 +2993,19 @@ class GolfGame {
// UI Helpers
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.matchmakingScreen?.classList.remove('active');
this.waitingScreen.classList.remove('active');
this.gameScreen.classList.remove('active');
if (this.rulesScreen) {
@@ -2593,6 +3013,11 @@ class GolfGame {
}
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
const isGameScreen = screen === this.gameScreen;
const user = this.auth?.user;
@@ -3105,12 +3530,14 @@ class GolfGame {
this.heldCardFloating.style.top = `${cardTop}px`;
this.heldCardFloating.style.width = `${cardWidth}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
const scaledWidth = cardWidth * 1.15; // Account for scale transform
const scaledHeight = cardHeight * 1.15;
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
const buttonLeft = cardLeft + cardWidth; // Right edge of card (no gap)
const buttonTop = cardTop + cardHeight * 0.3; // Vertically centered on card
this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`;
@@ -3166,6 +3593,9 @@ class GolfGame {
this.heldCardFloating.style.top = `${cardTop}px`;
this.heldCardFloating.style.width = `${cardWidth}px`;
this.heldCardFloating.style.height = `${cardHeight}px`;
if (this.isMobile) {
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
}
this.heldCardFloatingContent.innerHTML = '';
this.heldCardFloating.classList.remove('hidden');
@@ -3311,6 +3741,7 @@ class GolfGame {
renderGame() {
if (!this.gameState) return;
if (this.dealAnimationInProgress) return;
// Update CPU considering visual state
this.updateCpuConsideringState();
@@ -3875,13 +4306,8 @@ class GolfGame {
return;
}
// Show game buttons
this.gameButtons.classList.remove('hidden');
this.newGameBtn.classList.add('hidden');
this.nextRoundBtn.classList.remove('hidden');
// Start countdown for next hole
this.startNextHoleCountdown();
// V3_17: Scoresheet modal handles Next Hole button/countdown now
// Side panel just updates data silently
}
startNextHoleCountdown() {
@@ -4130,6 +4556,7 @@ class GolfGame {
document.addEventListener('DOMContentLoaded', () => {
window.game = new GolfGame();
window.auth = new AuthManager(window.game);
window.game.authManager = window.auth;
});
@@ -4146,13 +4573,32 @@ class AuthManager {
this.initElements();
this.bindEvents();
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() {
this.authBar = document.getElementById('auth-bar');
this.authUsername = document.getElementById('auth-username');
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.signupBtn = document.getElementById('signup-btn');
this.modal = document.getElementById('auth-modal');
@@ -4164,6 +4610,7 @@ class AuthManager {
this.loginError = document.getElementById('login-error');
this.signupFormContainer = document.getElementById('signup-form-container');
this.signupForm = document.getElementById('signup-form');
this.signupInviteCode = document.getElementById('signup-invite-code');
this.signupUsername = document.getElementById('signup-username');
this.signupEmail = document.getElementById('signup-email');
this.signupPassword = document.getElementById('signup-password');
@@ -4249,10 +4696,6 @@ class AuthManager {
this.setAuth(data.token, data.user);
this.hideModal();
if (data.user.username && this.game.playerNameInput) {
this.game.playerNameInput.value = data.user.username;
}
} catch (err) {
this.loginError.textContent = 'Connection error';
}
@@ -4262,6 +4705,7 @@ class AuthManager {
e.preventDefault();
this.clearErrors();
const invite_code = this.signupInviteCode?.value.trim() || null;
const username = this.signupUsername.value.trim();
const email = this.signupEmail.value.trim() || null;
const password = this.signupPassword.value;
@@ -4270,7 +4714,7 @@ class AuthManager {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password }),
body: JSON.stringify({ invite_code, username, email, password }),
});
const data = await response.json();
@@ -4282,10 +4726,6 @@ class AuthManager {
this.setAuth(data.token, data.user);
this.hideModal();
if (data.user.username && this.game.playerNameInput) {
this.game.playerNameInput.value = data.user.username;
}
} catch (err) {
this.signupError.textContent = 'Connection error';
}
@@ -4310,16 +4750,15 @@ class AuthManager {
updateUI() {
if (this.user) {
this.authBar?.classList.remove('hidden');
this.authButtons?.classList.add('hidden');
this.authPrompt?.classList.add('hidden');
this.lobbyGameControls?.classList.remove('hidden');
if (this.authUsername) {
this.authUsername.textContent = this.user.username;
}
if (this.game.playerNameInput && !this.game.playerNameInput.value) {
this.game.playerNameInput.value = this.user.username;
}
} else {
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';
}
// 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
createAnimCard(rect, showBack = false, deckColor = null) {
const card = document.createElement('div');
@@ -92,6 +99,9 @@ class CardAnimations {
card.style.top = rect.top + 'px';
card.style.width = rect.width + '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
@@ -448,10 +458,6 @@ class CardAnimations {
const deckColor = this.getDeckColor();
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);
// Apply rotation to match arch layout
@@ -607,10 +613,6 @@ class CardAnimations {
const deckColor = this.getDeckColor();
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);
if (rotation) {
@@ -1164,6 +1166,9 @@ class CardAnimations {
});
// 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({
targets: travelingHand,
left: discardRect.left,
@@ -1178,6 +1183,16 @@ class CardAnimations {
easing: this.getEasing('arc'),
}, `-=${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)
timeline.add({
targets: travelingHeld,
@@ -1193,6 +1208,16 @@ class CardAnimations {
easing: this.getEasing('arc'),
}, `-=${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
timeline.add({
targets: [travelingHand, travelingHeld],
@@ -1404,6 +1429,9 @@ class CardAnimations {
card.style.top = rect.top + 'px';
card.style.width = rect.width + '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) {
card.style.transform = `rotate(${rotation}deg)`;
@@ -1444,9 +1472,8 @@ class CardAnimations {
try {
anime({
targets: element,
scale: [0.5, 1.25, 1.15],
opacity: [0, 1, 1],
duration: 300,
opacity: [0, 1],
duration: 200,
easing: 'easeOutQuad'
});
} catch (e) {

View File

@@ -126,6 +126,13 @@ class CardManager {
cardEl.style.width = `${rect.width}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) {
const moveDuration = window.TIMING?.card?.moving || 350;
setTimeout(() => cardEl.classList.remove('moving'), moveDuration);

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<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>
<link rel="stylesheet" href="style.css">
</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>
<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) -->
<div id="auth-buttons" class="auth-buttons hidden">
<button id="login-btn" class="btn btn-small">Login</button>
<button id="signup-btn" class="btn btn-small btn-primary">Sign Up</button>
<!-- Auth prompt for unauthenticated users -->
<div id="auth-prompt" class="auth-prompt">
<p>Log in or sign up to play.</p>
<div class="button-group">
<button id="login-btn" class="btn btn-primary">Login</button>
<button id="signup-btn" class="btn btn-secondary">Sign Up</button>
</div>
</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>
<!-- 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 class="button-group">
<button id="create-room-btn" class="btn btn-primary">Create Room</button>
</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">
<label for="room-code">Room Code</label>
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
</div>
<div class="form-group">
<label for="room-code">Join Private Room</label>
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
</div>
<div class="button-group">
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
<div class="button-group">
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
</div>
</div>
<p id="lobby-error" class="error"></p>
</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 -->
<div id="waiting-screen" class="screen">
<div class="room-code-banner">
@@ -381,6 +398,15 @@
</table>
</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>
<!-- 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="knockouts">Knockouts</button>
<button class="leaderboard-tab" data-metric="streak">Best Streak</button>
<button class="leaderboard-tab" data-metric="rating">Rating</button>
</div>
<div id="leaderboard-content">
@@ -816,6 +843,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div id="signup-form-container" class="hidden">
<h3>Sign Up</h3>
<form id="signup-form">
<div class="form-group">
<input type="text" id="signup-invite-code" placeholder="Invite Code" required>
</div>
<div class="form-group">
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
</div>

View File

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

View File

@@ -1132,8 +1132,8 @@ input::placeholder {
.draw-anim-front,
.draw-anim-back {
position: absolute;
width: 100%;
height: 100%;
width: 100% !important;
height: 100% !important;
backface-visibility: hidden;
border-radius: 8px;
}
@@ -1156,10 +1156,8 @@ input::placeholder {
top: 0;
left: 0;
z-index: 100;
transform: scale(1.15);
transform-origin: center bottom;
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;
/* No transition - anime.js handles animations */
}
@@ -1523,11 +1521,11 @@ input::placeholder {
@keyframes heldCardPulse {
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% {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 35px rgba(244, 164, 96, 1),
0 0 50px rgba(244, 164, 96, 0.5);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 30px rgba(244, 164, 96, 0.9),
0 0 45px rgba(244, 164, 96, 0.4);
}
}
@@ -3301,15 +3299,28 @@ input::placeholder {
display: none;
}
/* Auth buttons in lobby */
.auth-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 20px;
/* Auth prompt in lobby (shown when not logged in) */
.auth-prompt {
text-align: center;
margin: 20px 0;
padding: 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;
}
@@ -3393,6 +3404,48 @@ input::placeholder {
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
=========================================== */
@@ -4404,56 +4457,21 @@ input::placeholder {
flex: 1;
}
.knock-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
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;
/* V3_17: Knock status message - golden gradient with pulsing glow */
.status-message.knock {
background: linear-gradient(135deg, #f4a460 0%, #e67e22 50%, #d4750e 100%);
color: #1a1a2e;
font-size: 1.3em;
font-weight: 900;
color: #ffe082;
background: rgba(20, 20, 36, 0.95);
padding: 20px 50px;
border-radius: 12px;
border: 3px solid rgba(255, 215, 0, 0.5);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
letter-spacing: 0.15em;
letter-spacing: 0.08em;
text-transform: uppercase;
animation: knock-pulse 0.6s ease-in-out 3;
box-shadow: 0 0 15px rgba(244, 164, 96, 0.5);
}
@keyframes knock-banner-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
.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 knock-pulse {
0%, 100% { box-shadow: 0 0 15px rgba(244, 164, 96, 0.5); }
50% { box-shadow: 0 0 25px rgba(244, 164, 96, 0.8), 0 0 40px rgba(230, 126, 34, 0.3); }
}
@keyframes screen-shake {
@@ -4468,25 +4486,7 @@ body.screen-shake {
animation: screen-shake 0.3s ease-out;
}
.opponent-knock-banner {
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;
}
/* opponent-knock-banner removed in V3_17 - knock uses status bar now */
/* --- V3_07: Score Tallying Animation --- */
.card-value-overlay {
@@ -4662,9 +4662,705 @@ body.screen-shake {
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 --- */
.traveling-card {
position: fixed;
border-radius: 6px;
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)
},
// 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
playerSwap: {
flipToReveal: 400, // Initial flip to show card

View File

@@ -22,6 +22,7 @@ services:
dockerfile: Dockerfile
environment:
- POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
- DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
- REDIS_URL=redis://redis:6379
- SECRET_KEY=${SECRET_KEY}
- RESEND_API_KEY=${RESEND_API_KEY:-}
@@ -30,26 +31,31 @@ services:
- LOG_LEVEL=INFO
- BASE_URL=${BASE_URL:-https://golf.example.com}
- RATE_LIMIT_ENABLED=true
- INVITE_ONLY=true
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
- MATCHMAKING_ENABLED=true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
deploy:
replicas: 2
replicas: 1
restart_policy:
condition: on-failure
max_attempts: 3
resources:
limits:
memory: 512M
reservations:
memory: 256M
reservations:
memory: 64M
networks:
- internal
- web
labels:
- "traefik.enable=true"
- "traefik.docker.network=golfgame_web"
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)"
- "traefik.http.routers.golf.entrypoints=websecure"
- "traefik.http.routers.golf.tls=true"
@@ -77,13 +83,13 @@ services:
deploy:
resources:
limits:
memory: 512M
memory: 192M
reservations:
memory: 256M
memory: 64M
redis:
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:
- redis_data:/data
healthcheck:
@@ -96,14 +102,19 @@ services:
deploy:
resources:
limits:
memory: 192M
reservations:
memory: 64M
reservations:
memory: 16M
traefik:
image: traefik:v2.10
image: traefik:v3.6
environment:
- DOCKER_API_VERSION=1.44
command:
- "--api.dashboard=true"
- "--api.insecure=true"
- "--accesslog=true"
- "--log.level=WARN"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
@@ -125,7 +136,7 @@ services:
deploy:
resources:
limits:
memory: 128M
memory: 64M
volumes:
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_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_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]
name = "golfgame"
version = "2.0.1"
version = "3.1.1"
description = "6-Card Golf card game with AI opponents"
readme = "README.md"
requires-python = ">=3.11"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,393 @@
"""
Matchmaking service for public skill-based games.
Uses Redis sorted sets to maintain a queue of players looking for games,
grouped by rating. A background task periodically scans the queue and
creates matches when enough similar-skill players are available.
"""
import asyncio
import json
import logging
import time
from dataclasses import dataclass
from typing import Optional
from fastapi import WebSocket
logger = logging.getLogger(__name__)
@dataclass
class QueuedPlayer:
"""A player waiting in the matchmaking queue."""
user_id: str
username: str
rating: float
queued_at: float # time.time()
connection_id: str
@dataclass
class MatchmakingConfig:
"""Configuration for the matchmaking system."""
enabled: bool = True
min_players: int = 2
max_players: int = 4
initial_rating_window: int = 100 # +/- rating range to start
expand_interval: int = 15 # seconds between range expansions
expand_amount: int = 50 # rating points to expand by
max_rating_window: int = 500 # maximum +/- range
match_check_interval: float = 3.0 # seconds between match attempts
countdown_seconds: int = 5 # countdown before matched game starts
class MatchmakingService:
"""
Manages the matchmaking queue and creates matches.
Players join the queue with their rating. A background task
periodically scans for groups of similarly-rated players and
creates games when matches are found.
"""
def __init__(self, redis_client, config: Optional[MatchmakingConfig] = None):
self.redis = redis_client
self.config = config or MatchmakingConfig()
self._queue: dict[str, QueuedPlayer] = {} # user_id -> QueuedPlayer
self._websockets: dict[str, WebSocket] = {} # user_id -> WebSocket
self._connection_ids: dict[str, str] = {} # user_id -> connection_id
self._running = False
self._task: Optional[asyncio.Task] = None
async def join_queue(
self,
user_id: str,
username: str,
rating: float,
websocket: WebSocket,
connection_id: str,
) -> dict:
"""
Add a player to the matchmaking queue.
Returns:
Queue status dict.
"""
if user_id in self._queue:
return {"position": self._get_position(user_id), "queue_size": len(self._queue)}
player = QueuedPlayer(
user_id=user_id,
username=username,
rating=rating,
queued_at=time.time(),
connection_id=connection_id,
)
self._queue[user_id] = player
self._websockets[user_id] = websocket
self._connection_ids[user_id] = connection_id
# Also add to Redis for persistence across restarts
if self.redis:
try:
await self.redis.zadd("matchmaking:queue", {user_id: rating})
await self.redis.hset(
"matchmaking:players",
user_id,
json.dumps({
"username": username,
"rating": rating,
"queued_at": player.queued_at,
"connection_id": connection_id,
}),
)
except Exception as e:
logger.warning(f"Redis matchmaking write failed: {e}")
position = self._get_position(user_id)
logger.info(f"Player {username} ({user_id[:8]}) joined queue (rating={rating:.0f}, pos={position})")
return {"position": position, "queue_size": len(self._queue)}
async def leave_queue(self, user_id: str) -> bool:
"""Remove a player from the matchmaking queue."""
if user_id not in self._queue:
return False
player = self._queue.pop(user_id, None)
self._websockets.pop(user_id, None)
self._connection_ids.pop(user_id, None)
if self.redis:
try:
await self.redis.zrem("matchmaking:queue", user_id)
await self.redis.hdel("matchmaking:players", user_id)
except Exception as e:
logger.warning(f"Redis matchmaking remove failed: {e}")
if player:
logger.info(f"Player {player.username} ({user_id[:8]}) left queue")
return True
async def get_queue_status(self, user_id: str) -> dict:
"""Get current queue status for a player."""
if user_id not in self._queue:
return {"in_queue": False}
player = self._queue[user_id]
wait_time = time.time() - player.queued_at
current_window = self._get_rating_window(wait_time)
return {
"in_queue": True,
"position": self._get_position(user_id),
"queue_size": len(self._queue),
"wait_time": int(wait_time),
"rating_window": current_window,
}
async def find_matches(self, room_manager, broadcast_game_state_fn) -> list[dict]:
"""
Scan the queue and create matches.
Returns:
List of match info dicts for matches created.
"""
if len(self._queue) < self.config.min_players:
return []
matches_created = []
matched_user_ids = set()
# Sort players by rating
sorted_players = sorted(self._queue.values(), key=lambda p: p.rating)
for player in sorted_players:
if player.user_id in matched_user_ids:
continue
wait_time = time.time() - player.queued_at
window = self._get_rating_window(wait_time)
# Find compatible players
candidates = []
for other in sorted_players:
if other.user_id == player.user_id or other.user_id in matched_user_ids:
continue
if abs(other.rating - player.rating) <= window:
candidates.append(other)
# Include the player themselves
group = [player] + candidates
if len(group) >= self.config.min_players:
# Take up to max_players
match_group = group[:self.config.max_players]
matched_user_ids.update(p.user_id for p in match_group)
# Create the match
match_info = await self._create_match(match_group, room_manager)
if match_info:
matches_created.append(match_info)
return matches_created
async def _create_match(self, players: list[QueuedPlayer], room_manager) -> Optional[dict]:
"""
Create a room for matched players and notify them.
Returns:
Match info dict, or None if creation failed.
"""
try:
# Create room
room = room_manager.create_room()
# Add all matched players to the room
for player in players:
ws = self._websockets.get(player.user_id)
if not ws:
continue
room.add_player(
player.connection_id,
player.username,
ws,
player.user_id,
)
# Remove matched players from queue
for player in players:
await self.leave_queue(player.user_id)
# Notify all matched players
match_info = {
"room_code": room.code,
"players": [
{"username": p.username, "rating": round(p.rating)}
for p in players
],
}
for player in players:
ws = self._websockets.get(player.user_id)
if ws:
try:
await ws.send_json({
"type": "queue_matched",
"room_code": room.code,
"players": match_info["players"],
"countdown": self.config.countdown_seconds,
})
except Exception as e:
logger.warning(f"Failed to notify matched player {player.user_id[:8]}: {e}")
# Also send room_joined to each player so the client switches screens
for player in players:
ws = self._websockets.get(player.user_id)
if ws:
try:
await ws.send_json({
"type": "room_joined",
"room_code": room.code,
"player_id": player.connection_id,
"authenticated": True,
})
# Send player list
await ws.send_json({
"type": "player_joined",
"players": room.player_list(),
})
except Exception:
pass
avg_rating = sum(p.rating for p in players) / len(players)
logger.info(
f"Match created: room={room.code}, "
f"players={[p.username for p in players]}, "
f"avg_rating={avg_rating:.0f}"
)
# Schedule auto-start after countdown
asyncio.create_task(self._auto_start_game(room, self.config.countdown_seconds))
return match_info
except Exception as e:
logger.error(f"Failed to create match: {e}")
return None
async def _auto_start_game(self, room, countdown: int):
"""Auto-start a matched game after countdown."""
from game import GamePhase, GameOptions
await asyncio.sleep(countdown)
if room.game.phase != GamePhase.WAITING:
return # Game already started or room closed
if len(room.players) < 2:
return # Not enough players
# Standard rules for ranked games
options = GameOptions()
options.flip_mode = "never"
options.initial_flips = 2
try:
async with room.game_lock:
room.game.start_game(1, 9, options) # 1 deck, 9 rounds, standard rules
# Send game started to all players
for pid, rp in room.players.items():
if rp.websocket and not rp.is_cpu:
try:
state = room.game.get_state(pid)
await rp.websocket.send_json({
"type": "game_started",
"game_state": state,
})
except Exception:
pass
logger.info(f"Auto-started matched game in room {room.code}")
except Exception as e:
logger.error(f"Failed to auto-start matched game: {e}")
def _get_rating_window(self, wait_time: float) -> int:
"""Calculate the current rating window based on wait time."""
expansions = int(wait_time / self.config.expand_interval)
window = self.config.initial_rating_window + (expansions * self.config.expand_amount)
return min(window, self.config.max_rating_window)
def _get_position(self, user_id: str) -> int:
"""Get a player's position in the queue (1-indexed)."""
sorted_ids = sorted(
self._queue.keys(),
key=lambda uid: self._queue[uid].queued_at,
)
try:
return sorted_ids.index(user_id) + 1
except ValueError:
return 0
async def start(self, room_manager, broadcast_fn):
"""Start the matchmaking background task."""
if self._running:
return
self._running = True
self._task = asyncio.create_task(
self._matchmaking_loop(room_manager, broadcast_fn)
)
logger.info("Matchmaking service started")
async def stop(self):
"""Stop the matchmaking background task."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Matchmaking service stopped")
async def _matchmaking_loop(self, room_manager, broadcast_fn):
"""Background task that periodically checks for matches."""
while self._running:
try:
matches = await self.find_matches(room_manager, broadcast_fn)
if matches:
logger.info(f"Created {len(matches)} match(es)")
# Send queue status updates to all queued players
for user_id in list(self._queue.keys()):
ws = self._websockets.get(user_id)
if ws:
try:
status = await self.get_queue_status(user_id)
await ws.send_json({
"type": "queue_status",
**status,
})
except Exception:
# Player disconnected, remove from queue
await self.leave_queue(user_id)
except Exception as e:
logger.error(f"Matchmaking error: {e}")
await asyncio.sleep(self.config.match_check_interval)
async def cleanup(self):
"""Clean up Redis queue data on shutdown."""
if self.redis:
try:
await self.redis.delete("matchmaking:queue")
await self.redis.delete("matchmaking:players")
except Exception:
pass

View File

@@ -0,0 +1,322 @@
"""
Glicko-2 rating service for Golf game matchmaking.
Implements the Glicko-2 rating system adapted for multiplayer games.
Each game is treated as a set of pairwise comparisons between all players.
Reference: http://www.glicko.net/glicko/glicko2.pdf
"""
import logging
import math
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
import asyncpg
logger = logging.getLogger(__name__)
# Glicko-2 constants
INITIAL_RATING = 1500.0
INITIAL_RD = 350.0
INITIAL_VOLATILITY = 0.06
TAU = 0.5 # System constant (constrains volatility change)
CONVERGENCE_TOLERANCE = 0.000001
GLICKO2_SCALE = 173.7178 # Factor to convert between Glicko and Glicko-2 scales
@dataclass
class PlayerRating:
"""A player's Glicko-2 rating."""
user_id: str
rating: float = INITIAL_RATING
rd: float = INITIAL_RD
volatility: float = INITIAL_VOLATILITY
updated_at: Optional[datetime] = None
@property
def mu(self) -> float:
"""Convert rating to Glicko-2 scale."""
return (self.rating - 1500) / GLICKO2_SCALE
@property
def phi(self) -> float:
"""Convert RD to Glicko-2 scale."""
return self.rd / GLICKO2_SCALE
def to_dict(self) -> dict:
return {
"rating": round(self.rating, 1),
"rd": round(self.rd, 1),
"volatility": round(self.volatility, 6),
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
def _g(phi: float) -> float:
"""Glicko-2 g function."""
return 1.0 / math.sqrt(1.0 + 3.0 * phi * phi / (math.pi * math.pi))
def _E(mu: float, mu_j: float, phi_j: float) -> float:
"""Glicko-2 expected score."""
return 1.0 / (1.0 + math.exp(-_g(phi_j) * (mu - mu_j)))
def _compute_variance(mu: float, opponents: list[tuple[float, float]]) -> float:
"""
Compute the estimated variance of the player's rating
based on game outcomes.
opponents: list of (mu_j, phi_j) tuples
"""
v_inv = 0.0
for mu_j, phi_j in opponents:
g_phi = _g(phi_j)
e = _E(mu, mu_j, phi_j)
v_inv += g_phi * g_phi * e * (1.0 - e)
if v_inv == 0:
return float('inf')
return 1.0 / v_inv
def _compute_delta(mu: float, opponents: list[tuple[float, float, float]], v: float) -> float:
"""
Compute the estimated improvement in rating.
opponents: list of (mu_j, phi_j, score) tuples
"""
total = 0.0
for mu_j, phi_j, score in opponents:
total += _g(phi_j) * (score - _E(mu, mu_j, phi_j))
return v * total
def _new_volatility(sigma: float, phi: float, v: float, delta: float) -> float:
"""Compute new volatility using the Illinois algorithm (Glicko-2 Step 5)."""
a = math.log(sigma * sigma)
delta_sq = delta * delta
phi_sq = phi * phi
def f(x):
ex = math.exp(x)
num1 = ex * (delta_sq - phi_sq - v - ex)
denom1 = 2.0 * (phi_sq + v + ex) ** 2
return num1 / denom1 - (x - a) / (TAU * TAU)
# Set initial bounds
A = a
if delta_sq > phi_sq + v:
B = math.log(delta_sq - phi_sq - v)
else:
k = 1
while f(a - k * TAU) < 0:
k += 1
B = a - k * TAU
# Illinois algorithm
f_A = f(A)
f_B = f(B)
for _ in range(100): # Safety limit
if abs(B - A) < CONVERGENCE_TOLERANCE:
break
C = A + (A - B) * f_A / (f_B - f_A)
f_C = f(C)
if f_C * f_B <= 0:
A = B
f_A = f_B
else:
f_A /= 2.0
B = C
f_B = f_C
return math.exp(A / 2.0)
def update_rating(player: PlayerRating, opponents: list[tuple[float, float, float]]) -> PlayerRating:
"""
Update a single player's rating based on game results.
Args:
player: Current player rating.
opponents: List of (mu_j, phi_j, score) where score is 1.0 (win), 0.5 (draw), 0.0 (loss).
Returns:
Updated PlayerRating.
"""
if not opponents:
# No opponents - just increase RD for inactivity
new_phi = math.sqrt(player.phi ** 2 + player.volatility ** 2)
return PlayerRating(
user_id=player.user_id,
rating=player.rating,
rd=min(new_phi * GLICKO2_SCALE, INITIAL_RD),
volatility=player.volatility,
updated_at=datetime.now(timezone.utc),
)
mu = player.mu
phi = player.phi
sigma = player.volatility
opp_pairs = [(mu_j, phi_j) for mu_j, phi_j, _ in opponents]
v = _compute_variance(mu, opp_pairs)
delta = _compute_delta(mu, opponents, v)
# New volatility
new_sigma = _new_volatility(sigma, phi, v, delta)
# Update phi (pre-rating)
phi_star = math.sqrt(phi ** 2 + new_sigma ** 2)
# New phi
new_phi = 1.0 / math.sqrt(1.0 / (phi_star ** 2) + 1.0 / v)
# New mu
improvement = 0.0
for mu_j, phi_j, score in opponents:
improvement += _g(phi_j) * (score - _E(mu, mu_j, phi_j))
new_mu = mu + new_phi ** 2 * improvement
# Convert back to Glicko scale
new_rating = new_mu * GLICKO2_SCALE + 1500
new_rd = new_phi * GLICKO2_SCALE
# Clamp RD to reasonable range
new_rd = max(30.0, min(new_rd, INITIAL_RD))
return PlayerRating(
user_id=player.user_id,
rating=max(100.0, new_rating), # Floor at 100
rd=new_rd,
volatility=new_sigma,
updated_at=datetime.now(timezone.utc),
)
class RatingService:
"""
Manages Glicko-2 ratings for players.
Ratings are only updated for standard-rules games.
Multiplayer games are decomposed into pairwise comparisons.
"""
def __init__(self, pool: asyncpg.Pool):
self.pool = pool
async def get_rating(self, user_id: str) -> PlayerRating:
"""Get a player's current rating."""
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT rating, rating_deviation, rating_volatility, rating_updated_at
FROM player_stats
WHERE user_id = $1
""",
user_id,
)
if not row or row["rating"] is None:
return PlayerRating(user_id=user_id)
return PlayerRating(
user_id=user_id,
rating=float(row["rating"]),
rd=float(row["rating_deviation"]),
volatility=float(row["rating_volatility"]),
updated_at=row["rating_updated_at"],
)
async def get_ratings_batch(self, user_ids: list[str]) -> dict[str, PlayerRating]:
"""Get ratings for multiple players."""
ratings = {}
for uid in user_ids:
ratings[uid] = await self.get_rating(uid)
return ratings
async def update_ratings(
self,
player_results: list[tuple[str, int]],
is_standard_rules: bool,
) -> dict[str, PlayerRating]:
"""
Update ratings after a game.
Args:
player_results: List of (user_id, total_score) for each human player.
is_standard_rules: Whether the game used standard rules.
Returns:
Dict of user_id -> updated PlayerRating.
"""
if not is_standard_rules:
logger.debug("Skipping rating update for non-standard rules game")
return {}
if len(player_results) < 2:
logger.debug("Skipping rating update: fewer than 2 human players")
return {}
# Get current ratings
user_ids = [uid for uid, _ in player_results]
current_ratings = await self.get_ratings_batch(user_ids)
# Sort by score (lower is better in Golf)
sorted_results = sorted(player_results, key=lambda x: x[1])
# Build pairwise comparisons for each player
updated_ratings = {}
for uid, score in player_results:
player = current_ratings[uid]
opponents = []
for opp_uid, opp_score in player_results:
if opp_uid == uid:
continue
opp = current_ratings[opp_uid]
# Determine outcome (lower score wins in Golf)
if score < opp_score:
outcome = 1.0 # Win
elif score == opp_score:
outcome = 0.5 # Draw
else:
outcome = 0.0 # Loss
opponents.append((opp.mu, opp.phi, outcome))
updated = update_rating(player, opponents)
updated_ratings[uid] = updated
# Persist updated ratings
async with self.pool.acquire() as conn:
for uid, rating in updated_ratings.items():
await conn.execute(
"""
UPDATE player_stats
SET rating = $2,
rating_deviation = $3,
rating_volatility = $4,
rating_updated_at = $5
WHERE user_id = $1
""",
uid,
rating.rating,
rating.rd,
rating.volatility,
rating.updated_at,
)
logger.info(
f"Ratings updated for {len(updated_ratings)} players: "
+ ", ".join(f"{uid[:8]}={r.rating:.0f}" for uid, r in updated_ratings.items())
)
return updated_ratings

View File

@@ -37,6 +37,8 @@ class PlayerStats:
wolfpacks: int = 0
current_win_streak: int = 0
best_win_streak: int = 0
rating: float = 1500.0
rating_deviation: float = 350.0
first_game_at: Optional[datetime] = None
last_game_at: Optional[datetime] = None
achievements: List[str] = field(default_factory=list)
@@ -156,6 +158,8 @@ class StatsService:
wolfpacks=row["wolfpacks"] or 0,
current_win_streak=row["current_win_streak"] or 0,
best_win_streak=row["best_win_streak"] or 0,
rating=float(row["rating"]) if row.get("rating") else 1500.0,
rating_deviation=float(row["rating_deviation"]) if row.get("rating_deviation") else 350.0,
first_game_at=row["first_game_at"].replace(tzinfo=timezone.utc) if row["first_game_at"] else None,
last_game_at=row["last_game_at"].replace(tzinfo=timezone.utc) if row["last_game_at"] else None,
achievements=[a["achievement_id"] for a in achievements],
@@ -184,6 +188,7 @@ class StatsService:
"avg_score": ("avg_score", "ASC"), # Lower is better
"knockouts": ("knockouts", "DESC"),
"streak": ("best_win_streak", "DESC"),
"rating": ("rating", "DESC"),
}
if metric not in order_map:
@@ -203,6 +208,7 @@ class StatsService:
SELECT
user_id, username, games_played, games_won,
win_rate, avg_score, knockouts, best_win_streak,
COALESCE(rating, 1500) as rating,
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM leaderboard_overall
ORDER BY {column} {direction}
@@ -216,6 +222,7 @@ class StatsService:
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score,
s.knockouts, s.best_win_streak,
COALESCE(s.rating, 1500) as rating,
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM player_stats s
JOIN users_v2 u ON s.user_id = u.id

View File

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