22 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
adlee-was-taken
850b8d6abf Standard-rules-only leaderboard with client unranked indicators
Only standard-rules games now count toward leaderboard stats. Games
with any house rule variant are marked "Unranked" in the active rules
bar, and a notice appears in the lobby when house rules are selected.
Also fixes game_logger duplicate options dicts (now uses dataclasses.asdict,
capturing all options including previously missing ones) and refactors
duplicated achievement-checking logic into shared helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:16:45 -05:00
adlee-was-taken
e1cca98b8b Fix client scoring to respect house rules for column pairs
Client-side scoring (points badge and score tally animation) ignored
house rules that modify pair behavior. Extract shared
calculateColumnScores() helper that mirrors server logic for
eagle_eye, negative_pairs_keep_value, wolfpack, four_of_a_kind,
and one_eyed_jacks rules. Server now sends scoring_rules flags
in game state.

Also fix opponent flip animation card font-size matching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:34:40 -05:00
adlee-was-taken
df61d88ec6 Revise rules page strategic impact descriptions for accuracy
Rename "New Variants" to "Game Variants", fix descriptions that
contradicted game mechanics (impossible card scenarios, misleading
value assessments), and clarify Underdog Bonus catch-up intent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:18:26 -05:00
26 changed files with 2818 additions and 379 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"]

File diff suppressed because it is too large Load Diff

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
@@ -1156,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,
@@ -1170,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,
@@ -1185,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],
@@ -1396,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)`;
@@ -1436,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>
</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>
<!-- 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="create-room-btn" class="btn btn-primary">Create Room</button>
<button id="login-btn" class="btn btn-primary">Login</button>
<button id="signup-btn" class="btn btn-secondary">Sign Up</button>
</div>
</div>
<!-- Game controls (shown only when authenticated) -->
<div id="lobby-game-controls" class="hidden">
<div class="button-group">
<button id="find-game-btn" class="btn btn-primary">Find Game</button>
</div>
<div 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>
<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>
</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">
@@ -259,6 +276,7 @@
</div>
</details>
<div id="unranked-notice" class="unranked-notice hidden">Games with house rules are unranked and won't affect leaderboard stats.</div>
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
</div>
@@ -380,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 -->
@@ -547,12 +574,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule">
<h4>Super Kings</h4>
<p>Kings are worth <strong>-2 points</strong> instead of 0.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Kings become valuable to keep unpaired, not just pairing fodder. Creates interesting decisions - do you pair Kings for 0, or keep them separate for -4 total?</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Pairing Kings now has a real cost — two Kings in separate columns score -4 total, but paired they score 0. Makes you think twice before completing a King pair.</p>
</div>
<div class="house-rule">
<h4>Ten Penny</h4>
<p>10s are worth <strong>1 point</strong> instead of 10.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Removes the "10 disaster" - drawing a 10 is no longer a crisis. Queens and Jacks become the only truly bad cards. Makes the game more forgiving.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Drawing a 10 is no longer a crisis Queens and Jacks become the only truly dangerous cards. Reduces the penalty spread between mid-range and high cards.</p>
</div>
</div>
@@ -561,12 +588,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule">
<h4>Standard Jokers</h4>
<p>2 Jokers per deck, each worth <strong>-2 points</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Jokers are great to find but pairing them is wasteful (0 points instead of -4). Best kept in different columns. Adds 2 premium cards to hunt for.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Jokers are premium finds, but pairing them wastes their value (0 points instead of -4). Best placed in different columns.</p>
</div>
<div class="house-rule">
<h4>Lucky Swing</h4>
<p>Only <strong>1 Joker</strong> in the entire deck, worth <strong>-5 points</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> High variance. Whoever finds this rare card gets a significant advantage. Increases the luck factor - sometimes you get it, sometimes your opponent does.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> With only one Joker in the deck, finding it is a major swing. Raises the stakes on every draw from the deck.</p>
</div>
<div class="house-rule">
<h4>Eagle Eye</h4>
@@ -580,12 +607,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule">
<h4>Knock Penalty</h4>
<p><strong>+10 points</strong> if you go out but don't have the lowest score.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Discourages reckless rushing. You need to be confident you're winning before going out. Rewards patience and reading your opponents' likely scores. Can backfire spectacularly if you misjudge.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> You need to be confident you have the lowest score before going out. Rewards patience and reading your opponents' likely hands.</p>
</div>
<div class="house-rule">
<h4>Knock Bonus</h4>
<p><strong>-5 points</strong> for going out first (regardless of who wins).</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Encourages racing to finish, even with a mediocre hand. The 5-point bonus might make up for a slightly worse score. Speeds up gameplay.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Rewards racing to finish. The 5-point bonus can offset a slightly worse hand, creating a tension between improving your score and ending the round quickly.</p>
</div>
<p class="combo-note"><em>Combining Knock Penalty + Knock Bonus creates high-stakes "going out" decisions: -5 if you win, +10 if you lose!</em></p>
</div>
@@ -595,27 +622,27 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule">
<h4>Underdog Bonus</h4>
<p>Round winner gets <strong>-3 points</strong> extra.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Amplifies winning - the best player each round pulls further ahead. Can lead to snowballing leads over multiple holes. Rewards consistency.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Gives trailing players a way to close the gap — win a round and claw back 3 extra points. Over multiple holes, a player who's behind can mount a comeback by stringing together strong rounds.</p>
</div>
<div class="house-rule">
<h4>Tied Shame</h4>
<p>If you tie another player's score, <strong>both get +5 penalty</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Punishes playing it safe. If you suspect a tie, you need to take risks to differentiate your score. Creates interesting late-round decisions.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Punishes playing it safe. If you suspect a tie, you need to take risks to break it — a last-turn swap you'd normally skip becomes worth considering.</p>
</div>
<div class="house-rule">
<h4>Blackjack</h4>
<p>Score of exactly <strong>21 becomes 0</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> A "hail mary" comeback. If you're stuck at 21, you're suddenly in great shape. Mostly luck, but adds exciting moments when it happens.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Turns a bad round into a great one. If your score lands on exactly 21, you walk away with 0 instead. Worth keeping in mind before making that last swap.</p>
</div>
<div class="house-rule">
<h4>Wolfpack</h4>
<p>Having <strong>all 4 Jacks</strong> (2 pairs) gives <strong>-20 bonus</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Extremely rare but now a significant reward! Turns a potential disaster (40 points of Jacks) into a triumph. The huge bonus makes it worth celebrating when achieved, though still not worth actively pursuing.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Turns a potential disaster (40 points of Jacks) into a triumph. If you already have a pair of Jacks in one column and a third Jack appears, the -20 bonus makes it worth grabbing and hunting for the fourth.</p>
</div>
</div>
<div class="rules-mode">
<h3>New Variants</h3>
<h3>Game Variants</h3>
<div class="house-rule">
<h4>Flip as Action</h4>
<p>Use your turn to flip one of your face-down cards without drawing. Ends your turn immediately.</p>
@@ -624,7 +651,7 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule">
<h4>Four of a Kind</h4>
<p>Having 4 cards of the same rank across two columns scores <strong>-20 bonus</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Rewards collecting matching cards beyond just column pairs. Changes whether you should take a third or fourth copy of a rank. If you already have two pairs of 8s, that's -20 extra! Stacks with Wolfpack: four Jacks = -40 total.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Rewards collecting matching cards beyond column pairs. Once you have a pair in one column, grabbing a third or fourth of that rank for another column becomes worthwhile. Stacks with Wolfpack: four Jacks = -40 total.</p>
</div>
<div class="house-rule">
<h4>Negative Pairs Keep Value</h4>
@@ -716,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">
@@ -815,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

@@ -837,6 +837,28 @@ input::placeholder {
color: rgba(255, 255, 255, 0.9);
}
.active-rules-bar .rule-tag.unranked {
background: rgba(220, 80, 80, 0.3);
color: #f08080;
border: 1px solid rgba(220, 80, 80, 0.4);
}
/* Unranked notice in waiting room */
.unranked-notice {
background: rgba(220, 80, 80, 0.15);
border: 1px solid rgba(220, 80, 80, 0.3);
color: #f0a0a0;
font-size: 0.8rem;
padding: 6px 12px;
border-radius: 6px;
margin: 8px 0;
text-align: center;
}
.unranked-notice.hidden {
display: none;
}
/* Card Styles */
.card {
width: clamp(65px, 5.5vw, 100px);
@@ -1110,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;
}
@@ -1134,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 */
}
@@ -1501,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);
}
}
@@ -3279,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;
}
@@ -3371,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
=========================================== */
@@ -4382,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 {
@@ -4446,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 {
@@ -4522,6 +4544,10 @@ body.screen-shake {
25% { transform: translate(-50%, -50%) scale(1.1); opacity: 1; }
100% { transform: translate(-50%, -60%) scale(1); opacity: 0; }
}
.pair-cancel-overlay.negative {
color: #81d4fa;
border-color: rgba(100, 181, 246, 0.4);
}
/* --- V3_10: Column Pair Indicator --- */
.card.paired {
@@ -4636,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

@@ -512,6 +512,20 @@ class GameOptions:
deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"])
"""Colors for card backs from different decks (in order by deck_id)."""
def is_standard_rules(self) -> bool:
"""Check if all rules are standard (no house rules active)."""
return not any([
self.flip_mode != "never",
self.initial_flips != 2,
self.knock_penalty,
self.use_jokers,
self.lucky_swing, self.super_kings, self.ten_penny,
self.knock_bonus, self.underdog_bonus, self.tied_shame,
self.blackjack, self.wolfpack, self.eagle_eye,
self.flip_as_action, self.four_of_a_kind,
self.negative_pairs_keep_value, self.one_eyed_jacks, self.knock_early,
])
_ALLOWED_COLORS = {
"red", "blue", "gold", "teal", "purple", "orange", "yellow",
"green", "pink", "cyan", "brown", "slate",
@@ -1630,5 +1644,13 @@ class Game:
"finisher_id": self.finisher_id,
"card_values": self.get_card_values(),
"active_rules": active_rules,
"scoring_rules": {
"negative_pairs_keep_value": self.options.negative_pairs_keep_value,
"eagle_eye": self.options.eagle_eye,
"wolfpack": self.options.wolfpack,
"four_of_a_kind": self.options.four_of_a_kind,
"one_eyed_jacks": self.options.one_eyed_jacks,
},
"deck_colors": self.options.deck_colors,
"is_standard_rules": self.options.is_standard_rules(),
}

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:
@@ -531,8 +607,26 @@ async def _process_stats_safe(room: Room):
winner_id=winner_id,
num_rounds=room.game.num_rounds,
player_user_ids=player_user_ids,
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

@@ -17,6 +17,7 @@ Usage:
logger.log_move(game_id, player, is_cpu=False, action="swap", ...)
"""
from dataclasses import asdict
from typing import Optional, TYPE_CHECKING
import asyncio
import uuid
@@ -46,6 +47,13 @@ class GameLogger:
"""
self.event_store = event_store
@staticmethod
def _options_to_dict(options: "GameOptions") -> dict:
"""Convert GameOptions to dict for storage, excluding non-rule fields."""
d = asdict(options)
d.pop("deck_colors", None)
return d
# -------------------------------------------------------------------------
# Game Lifecycle
# -------------------------------------------------------------------------
@@ -71,30 +79,12 @@ class GameLogger:
"""
game_id = str(uuid.uuid4())
options_dict = {
"flip_mode": options.flip_mode,
"initial_flips": options.initial_flips,
"knock_penalty": options.knock_penalty,
"use_jokers": options.use_jokers,
"lucky_swing": options.lucky_swing,
"super_kings": options.super_kings,
"ten_penny": options.ten_penny,
"knock_bonus": options.knock_bonus,
"underdog_bonus": options.underdog_bonus,
"tied_shame": options.tied_shame,
"blackjack": options.blackjack,
"eagle_eye": options.eagle_eye,
"negative_pairs_keep_value": getattr(options, "negative_pairs_keep_value", False),
"four_of_a_kind": getattr(options, "four_of_a_kind", False),
"wolfpack": getattr(options, "wolfpack", False),
}
try:
await self.event_store.create_game(
game_id=game_id,
room_code=room_code,
host_id="system",
options=options_dict,
options=self._options_to_dict(options),
)
log.debug(f"Logged game start: {game_id} room={room_code}")
except Exception as e:
@@ -133,30 +123,12 @@ class GameLogger:
options: "GameOptions",
) -> None:
"""Helper to log game start with pre-generated ID."""
options_dict = {
"flip_mode": options.flip_mode,
"initial_flips": options.initial_flips,
"knock_penalty": options.knock_penalty,
"use_jokers": options.use_jokers,
"lucky_swing": options.lucky_swing,
"super_kings": options.super_kings,
"ten_penny": options.ten_penny,
"knock_bonus": options.knock_bonus,
"underdog_bonus": options.underdog_bonus,
"tied_shame": options.tied_shame,
"blackjack": options.blackjack,
"eagle_eye": options.eagle_eye,
"negative_pairs_keep_value": getattr(options, "negative_pairs_keep_value", False),
"four_of_a_kind": getattr(options, "four_of_a_kind", False),
"wolfpack": getattr(options, "wolfpack", False),
}
try:
await self.event_store.create_game(
game_id=game_id,
room_code=room_code,
host_id="system",
options=options_dict,
options=self._options_to_dict(options),
)
log.debug(f"Logged game start: {game_id} room={room_code}")
except Exception as e:

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

@@ -14,6 +14,7 @@ import asyncpg
from stores.event_store import EventStore
from models.events import EventType
from game import GameOptions
logger = logging.getLogger(__name__)
@@ -36,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)
@@ -155,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],
@@ -183,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:
@@ -202,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}
@@ -215,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
@@ -584,6 +592,47 @@ class StatsService:
return data if data["num_rounds"] > 0 else None
@staticmethod
def _check_win_milestones(stats_row, earned_ids: set) -> List[str]:
"""Check win/streak achievement milestones. Shared by event and legacy paths."""
new = []
wins = stats_row["games_won"]
for threshold, achievement_id in [(1, "first_win"), (10, "win_10"), (50, "win_50"), (100, "win_100")]:
if wins >= threshold and achievement_id not in earned_ids:
new.append(achievement_id)
streak = stats_row["current_win_streak"]
for threshold, achievement_id in [(5, "streak_5"), (10, "streak_10")]:
if streak >= threshold and achievement_id not in earned_ids:
new.append(achievement_id)
return new
@staticmethod
async def _get_earned_ids(conn: asyncpg.Connection, user_id: str) -> set:
"""Get set of already-earned achievement IDs for a user."""
earned = await conn.fetch(
"SELECT achievement_id FROM user_achievements WHERE user_id = $1",
user_id,
)
return {e["achievement_id"] for e in earned}
@staticmethod
async def _award_achievements(
conn: asyncpg.Connection,
user_id: str,
achievement_ids: List[str],
game_id: Optional[str] = None,
) -> None:
"""Insert achievement records for a user."""
for achievement_id in achievement_ids:
try:
await conn.execute("""
INSERT INTO user_achievements (user_id, achievement_id, game_id)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
""", user_id, achievement_id, game_id)
except Exception as e:
logger.error(f"Failed to award achievement {achievement_id}: {e}")
async def _check_achievements(
self,
conn: asyncpg.Connection,
@@ -605,8 +654,6 @@ class StatsService:
Returns:
List of newly awarded achievement IDs.
"""
new_achievements = []
# Get current stats (after update)
stats = await conn.fetchrow("""
SELECT games_won, knockouts, best_win_streak, current_win_streak, perfect_rounds, wolfpacks
@@ -617,35 +664,15 @@ class StatsService:
if not stats:
return []
# Get already earned achievements
earned = await conn.fetch("""
SELECT achievement_id FROM user_achievements WHERE user_id = $1
""", user_id)
earned_ids = {e["achievement_id"] for e in earned}
earned_ids = await self._get_earned_ids(conn, user_id)
# Check win milestones
wins = stats["games_won"]
if wins >= 1 and "first_win" not in earned_ids:
new_achievements.append("first_win")
if wins >= 10 and "win_10" not in earned_ids:
new_achievements.append("win_10")
if wins >= 50 and "win_50" not in earned_ids:
new_achievements.append("win_50")
if wins >= 100 and "win_100" not in earned_ids:
new_achievements.append("win_100")
# Win/streak milestones (shared logic)
new_achievements = self._check_win_milestones(stats, earned_ids)
# Check streak achievements
streak = stats["current_win_streak"]
if streak >= 5 and "streak_5" not in earned_ids:
new_achievements.append("streak_5")
if streak >= 10 and "streak_10" not in earned_ids:
new_achievements.append("streak_10")
# Check knockout achievements
# Game-specific achievements (event path only)
if stats["knockouts"] >= 10 and "knockout_10" not in earned_ids:
new_achievements.append("knockout_10")
# Check round-specific achievements from this game
best_round = player_data.get("best_round")
if best_round is not None:
if best_round <= 0 and "perfect_round" not in earned_ids:
@@ -653,21 +680,10 @@ class StatsService:
if best_round < 0 and "negative_round" not in earned_ids:
new_achievements.append("negative_round")
# Check wolfpack
if player_data.get("wolfpacks", 0) > 0 and "wolfpack" not in earned_ids:
new_achievements.append("wolfpack")
# Award new achievements
for achievement_id in new_achievements:
try:
await conn.execute("""
INSERT INTO user_achievements (user_id, achievement_id, game_id)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
""", user_id, achievement_id, game_id)
except Exception as e:
logger.error(f"Failed to award achievement {achievement_id}: {e}")
await self._award_achievements(conn, user_id, new_achievements, game_id)
return new_achievements
# -------------------------------------------------------------------------
@@ -680,18 +696,21 @@ class StatsService:
winner_id: Optional[str],
num_rounds: int,
player_user_ids: dict[str, str] = None,
game_options: Optional[GameOptions] = None,
) -> List[str]:
"""
Process game stats directly from game state (for legacy games).
This is used when games don't have event sourcing. Stats are updated
based on final game state.
based on final game state. Only standard-rules games count toward
leaderboard stats.
Args:
players: List of game.Player objects with final scores.
winner_id: Player ID of the winner.
num_rounds: Total rounds played.
player_user_ids: Optional mapping of player_id to user_id (for authenticated players).
game_options: Optional game options to check for standard rules.
Returns:
List of newly awarded achievement IDs.
@@ -699,6 +718,11 @@ class StatsService:
if not players:
return []
# Only track stats for standard-rules games
if game_options and not game_options.is_standard_rules():
logger.debug("Skipping stats for non-standard rules game")
return []
# Count human players for has_human_opponents calculation
# For legacy games, we assume all players are human unless otherwise indicated
human_count = len(players)
@@ -800,9 +824,6 @@ class StatsService:
Only checks win-based achievements since we don't have round-level data.
"""
new_achievements = []
# Get current stats
stats = await conn.fetchrow("""
SELECT games_won, current_win_streak FROM player_stats
WHERE user_id = $1
@@ -811,41 +832,9 @@ class StatsService:
if not stats:
return []
# Get already earned achievements
earned = await conn.fetch("""
SELECT achievement_id FROM user_achievements WHERE user_id = $1
""", user_id)
earned_ids = {e["achievement_id"] for e in earned}
# Check win milestones
wins = stats["games_won"]
if wins >= 1 and "first_win" not in earned_ids:
new_achievements.append("first_win")
if wins >= 10 and "win_10" not in earned_ids:
new_achievements.append("win_10")
if wins >= 50 and "win_50" not in earned_ids:
new_achievements.append("win_50")
if wins >= 100 and "win_100" not in earned_ids:
new_achievements.append("win_100")
# Check streak achievements
streak = stats["current_win_streak"]
if streak >= 5 and "streak_5" not in earned_ids:
new_achievements.append("streak_5")
if streak >= 10 and "streak_10" not in earned_ids:
new_achievements.append("streak_10")
# Award new achievements
for achievement_id in new_achievements:
try:
await conn.execute("""
INSERT INTO user_achievements (user_id, achievement_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
""", user_id, achievement_id)
except Exception as e:
logger.error(f"Failed to award achievement {achievement_id}: {e}")
earned_ids = await self._get_earned_ids(conn, user_id)
new_achievements = self._check_win_milestones(stats, earned_ids)
await self._award_achievements(conn, user_id, new_achievements)
return new_achievements
# -------------------------------------------------------------------------

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