Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7b21d8378 | ||
|
|
fb3bd53b0a | ||
|
|
4fcdf13f66 | ||
|
|
6673e63241 | ||
|
|
62e7d4e1dd | ||
|
|
bae5d8da3c | ||
|
|
62e3dc0395 | ||
|
|
bda88d8218 | ||
|
|
b5a8e1fe7b | ||
|
|
929ab0f320 | ||
|
|
7026d86081 | ||
|
|
b2ce6f5cf1 | ||
|
|
d4a39fe234 | ||
|
|
9966fd9470 | ||
|
|
050294754c | ||
|
|
1856019a95 | ||
|
|
f68d0bc26d | ||
|
|
c59c1e28e2 | ||
|
|
bfa94830a7 | ||
|
|
850b8d6abf | ||
|
|
e1cca98b8b | ||
|
|
df61d88ec6 |
15
.env.example
15
.env.example
@@ -55,7 +55,12 @@ ROOM_CODE_LENGTH=4
|
|||||||
SECRET_KEY=
|
SECRET_KEY=
|
||||||
|
|
||||||
# Enable invite-only mode (requires invitation to register)
|
# Enable invite-only mode (requires invitation to register)
|
||||||
INVITE_ONLY=false
|
INVITE_ONLY=true
|
||||||
|
|
||||||
|
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
|
||||||
|
# Remove these after first login!
|
||||||
|
# BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
# BOOTSTRAP_ADMIN_PASSWORD=changeme12345
|
||||||
|
|
||||||
# Comma-separated list of admin email addresses
|
# Comma-separated list of admin email addresses
|
||||||
ADMIN_EMAILS=
|
ADMIN_EMAILS=
|
||||||
@@ -104,5 +109,13 @@ CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing enabled
|
|||||||
# Enable rate limiting (recommended for production)
|
# Enable rate limiting (recommended for production)
|
||||||
# RATE_LIMIT_ENABLED=true
|
# RATE_LIMIT_ENABLED=true
|
||||||
|
|
||||||
|
# Redis URL (required for matchmaking and rate limiting)
|
||||||
|
# REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
# Base URL for email links
|
# Base URL for email links
|
||||||
# BASE_URL=https://your-domain.com
|
# BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
|
# Matchmaking (skill-based public games)
|
||||||
|
MATCHMAKING_ENABLED=true
|
||||||
|
MATCHMAKING_MIN_PLAYERS=2
|
||||||
|
MATCHMAKING_MAX_PLAYERS=4
|
||||||
|
|||||||
@@ -33,5 +33,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run with uvicorn
|
# Run with uvicorn from the server directory (server uses relative imports)
|
||||||
CMD ["python", "-m", "uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
WORKDIR /app/server
|
||||||
|
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
795
client/app.js
795
client/app.js
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,13 @@ class CardAnimations {
|
|||||||
return easings[type] || 'easeOutQuad';
|
return easings[type] || 'easeOutQuad';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Font size proportional to card width — consistent across all card types.
|
||||||
|
// Mobile uses a tighter ratio since cards are smaller and closer together.
|
||||||
|
cardFontSize(width) {
|
||||||
|
const ratio = document.body.classList.contains('mobile-portrait') ? 0.35 : 0.5;
|
||||||
|
return (width * ratio) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
// Create animated card element with 3D flip structure
|
// Create animated card element with 3D flip structure
|
||||||
createAnimCard(rect, showBack = false, deckColor = null) {
|
createAnimCard(rect, showBack = false, deckColor = null) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
@@ -92,6 +99,9 @@ class CardAnimations {
|
|||||||
card.style.top = rect.top + 'px';
|
card.style.top = rect.top + 'px';
|
||||||
card.style.width = rect.width + 'px';
|
card.style.width = rect.width + 'px';
|
||||||
card.style.height = rect.height + 'px';
|
card.style.height = rect.height + 'px';
|
||||||
|
// Scale font-size proportionally to card width
|
||||||
|
const front = card.querySelector('.draw-anim-front');
|
||||||
|
if (front) front.style.fontSize = this.cardFontSize(rect.width);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply deck color to back
|
// Apply deck color to back
|
||||||
@@ -1156,6 +1166,9 @@ class CardAnimations {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Hand card arcs to discard (apply counter-rotation to land flat)
|
// Hand card arcs to discard (apply counter-rotation to land flat)
|
||||||
|
const handFront = travelingHand.querySelector('.draw-anim-front');
|
||||||
|
const heldFront = travelingHeld.querySelector('.draw-anim-front');
|
||||||
|
|
||||||
timeline.add({
|
timeline.add({
|
||||||
targets: travelingHand,
|
targets: travelingHand,
|
||||||
left: discardRect.left,
|
left: discardRect.left,
|
||||||
@@ -1170,6 +1183,16 @@ class CardAnimations {
|
|||||||
easing: this.getEasing('arc'),
|
easing: this.getEasing('arc'),
|
||||||
}, `-=${T.lift / 2}`);
|
}, `-=${T.lift / 2}`);
|
||||||
|
|
||||||
|
// Scale hand card font to match discard size
|
||||||
|
if (handFront) {
|
||||||
|
timeline.add({
|
||||||
|
targets: handFront,
|
||||||
|
fontSize: this.cardFontSize(discardRect.width),
|
||||||
|
duration: T.arc,
|
||||||
|
easing: this.getEasing('arc'),
|
||||||
|
}, `-=${T.arc}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Held card arcs to hand slot (apply rotation to match hand position)
|
// Held card arcs to hand slot (apply rotation to match hand position)
|
||||||
timeline.add({
|
timeline.add({
|
||||||
targets: travelingHeld,
|
targets: travelingHeld,
|
||||||
@@ -1185,6 +1208,16 @@ class CardAnimations {
|
|||||||
easing: this.getEasing('arc'),
|
easing: this.getEasing('arc'),
|
||||||
}, `-=${T.arc + T.lift / 2}`);
|
}, `-=${T.arc + T.lift / 2}`);
|
||||||
|
|
||||||
|
// Scale held card font to match hand size
|
||||||
|
if (heldFront) {
|
||||||
|
timeline.add({
|
||||||
|
targets: heldFront,
|
||||||
|
fontSize: this.cardFontSize(handRect.width),
|
||||||
|
duration: T.arc,
|
||||||
|
easing: this.getEasing('arc'),
|
||||||
|
}, `-=${T.arc}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Settle with gentle overshoot
|
// Settle with gentle overshoot
|
||||||
timeline.add({
|
timeline.add({
|
||||||
targets: [travelingHand, travelingHeld],
|
targets: [travelingHand, travelingHeld],
|
||||||
@@ -1396,6 +1429,9 @@ class CardAnimations {
|
|||||||
card.style.top = rect.top + 'px';
|
card.style.top = rect.top + 'px';
|
||||||
card.style.width = rect.width + 'px';
|
card.style.width = rect.width + 'px';
|
||||||
card.style.height = rect.height + 'px';
|
card.style.height = rect.height + 'px';
|
||||||
|
// Scale font-size proportionally to card width
|
||||||
|
const front = card.querySelector('.draw-anim-front');
|
||||||
|
if (front) front.style.fontSize = this.cardFontSize(rect.width);
|
||||||
|
|
||||||
if (rotation) {
|
if (rotation) {
|
||||||
card.style.transform = `rotate(${rotation}deg)`;
|
card.style.transform = `rotate(${rotation}deg)`;
|
||||||
@@ -1436,9 +1472,8 @@ class CardAnimations {
|
|||||||
try {
|
try {
|
||||||
anime({
|
anime({
|
||||||
targets: element,
|
targets: element,
|
||||||
scale: [0.5, 1.25, 1.15],
|
opacity: [0, 1],
|
||||||
opacity: [0, 1, 1],
|
duration: 200,
|
||||||
duration: 300,
|
|
||||||
easing: 'easeOutQuad'
|
easing: 'easeOutQuad'
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -126,6 +126,13 @@ class CardManager {
|
|||||||
cardEl.style.width = `${rect.width}px`;
|
cardEl.style.width = `${rect.width}px`;
|
||||||
cardEl.style.height = `${rect.height}px`;
|
cardEl.style.height = `${rect.height}px`;
|
||||||
|
|
||||||
|
// On mobile, scale font proportional to card width so rank/suit fit
|
||||||
|
if (document.body.classList.contains('mobile-portrait')) {
|
||||||
|
cardEl.style.fontSize = `${rect.width * 0.35}px`;
|
||||||
|
} else {
|
||||||
|
cardEl.style.fontSize = '';
|
||||||
|
}
|
||||||
|
|
||||||
if (animate) {
|
if (animate) {
|
||||||
const moveDuration = window.TIMING?.card?.moving || 350;
|
const moveDuration = window.TIMING?.card?.moving || 350;
|
||||||
setTimeout(() => cardEl.classList.remove('moving'), moveDuration);
|
setTimeout(() => cardEl.classList.remove('moving'), moveDuration);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>Golf Card Game</title>
|
<title>Golf Card Game</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
@@ -19,35 +19,52 @@
|
|||||||
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span> <span class="golf-title">Golf</span></h1>
|
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span> <span class="golf-title">Golf</span></h1>
|
||||||
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
|
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
|
||||||
|
|
||||||
<!-- Auth buttons for guests (hidden until auth check confirms not logged in) -->
|
<!-- Auth prompt for unauthenticated users -->
|
||||||
<div id="auth-buttons" class="auth-buttons hidden">
|
<div id="auth-prompt" class="auth-prompt">
|
||||||
<button id="login-btn" class="btn btn-small">Login</button>
|
<p>Log in or sign up to play.</p>
|
||||||
<button id="signup-btn" class="btn btn-small btn-primary">Sign Up</button>
|
<div class="button-group">
|
||||||
|
<button id="login-btn" class="btn btn-primary">Login</button>
|
||||||
|
<button id="signup-btn" class="btn btn-secondary">Sign Up</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<!-- Game controls (shown only when authenticated) -->
|
||||||
<label for="player-name">Your Name</label>
|
<div id="lobby-game-controls" class="hidden">
|
||||||
<input type="text" id="player-name" placeholder="Enter your name" maxlength="12">
|
<div class="button-group">
|
||||||
</div>
|
<button id="find-game-btn" class="btn btn-primary">Find Game</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="divider">or</div>
|
||||||
<button id="create-room-btn" class="btn btn-primary">Create Room</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">
|
<div class="form-group">
|
||||||
<label for="room-code">Room Code</label>
|
<label for="room-code">Join Private Room</label>
|
||||||
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
|
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
|
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="lobby-error" class="error"></p>
|
<p id="lobby-error" class="error"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Matchmaking Screen -->
|
||||||
|
<div id="matchmaking-screen" class="screen">
|
||||||
|
<h2>Finding Game...</h2>
|
||||||
|
<div class="matchmaking-spinner"></div>
|
||||||
|
<p id="matchmaking-status">Searching for opponents...</p>
|
||||||
|
<p id="matchmaking-time" class="matchmaking-timer">0:00</p>
|
||||||
|
<p id="matchmaking-queue-info" class="matchmaking-info"></p>
|
||||||
|
<div class="button-group">
|
||||||
|
<button id="cancel-matchmaking-btn" class="btn btn-danger">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Waiting Room Screen -->
|
<!-- Waiting Room Screen -->
|
||||||
<div id="waiting-screen" class="screen">
|
<div id="waiting-screen" class="screen">
|
||||||
<div class="room-code-banner">
|
<div class="room-code-banner">
|
||||||
@@ -259,6 +276,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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>
|
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -380,6 +398,15 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile bottom bar (hidden on desktop) -->
|
||||||
|
<div id="mobile-bottom-bar">
|
||||||
|
<button class="mobile-bar-btn" data-drawer="standings-panel">Standings</button>
|
||||||
|
<button class="mobile-bar-btn" data-drawer="scoreboard">Scores</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer backdrop for mobile -->
|
||||||
|
<div id="drawer-backdrop" class="drawer-backdrop"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rules Screen -->
|
<!-- Rules Screen -->
|
||||||
@@ -547,12 +574,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
|||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Super Kings</h4>
|
<h4>Super Kings</h4>
|
||||||
<p>Kings are worth <strong>-2 points</strong> instead of 0.</p>
|
<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>
|
||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Ten Penny</h4>
|
<h4>Ten Penny</h4>
|
||||||
<p>10s are worth <strong>1 point</strong> instead of 10.</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -561,12 +588,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
|||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Standard Jokers</h4>
|
<h4>Standard Jokers</h4>
|
||||||
<p>2 Jokers per deck, each worth <strong>-2 points</strong>.</p>
|
<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>
|
||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Lucky Swing</h4>
|
<h4>Lucky Swing</h4>
|
||||||
<p>Only <strong>1 Joker</strong> in the entire deck, worth <strong>-5 points</strong>.</p>
|
<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>
|
||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Eagle Eye</h4>
|
<h4>Eagle Eye</h4>
|
||||||
@@ -580,12 +607,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
|||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Knock Penalty</h4>
|
<h4>Knock Penalty</h4>
|
||||||
<p><strong>+10 points</strong> if you go out but don't have the lowest score.</p>
|
<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>
|
||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Knock Bonus</h4>
|
<h4>Knock Bonus</h4>
|
||||||
<p><strong>-5 points</strong> for going out first (regardless of who wins).</p>
|
<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>
|
</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>
|
<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>
|
</div>
|
||||||
@@ -595,27 +622,27 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
|||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Underdog Bonus</h4>
|
<h4>Underdog Bonus</h4>
|
||||||
<p>Round winner gets <strong>-3 points</strong> extra.</p>
|
<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>
|
||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Tied Shame</h4>
|
<h4>Tied Shame</h4>
|
||||||
<p>If you tie another player's score, <strong>both get +5 penalty</strong>.</p>
|
<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>
|
||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Blackjack</h4>
|
<h4>Blackjack</h4>
|
||||||
<p>Score of exactly <strong>21 becomes 0</strong>.</p>
|
<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>
|
||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Wolfpack</h4>
|
<h4>Wolfpack</h4>
|
||||||
<p>Having <strong>all 4 Jacks</strong> (2 pairs) gives <strong>-20 bonus</strong>.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="rules-mode">
|
<div class="rules-mode">
|
||||||
<h3>New Variants</h3>
|
<h3>Game Variants</h3>
|
||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Flip as Action</h4>
|
<h4>Flip as Action</h4>
|
||||||
<p>Use your turn to flip one of your face-down cards without drawing. Ends your turn immediately.</p>
|
<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">
|
<div class="house-rule">
|
||||||
<h4>Four of a Kind</h4>
|
<h4>Four of a Kind</h4>
|
||||||
<p>Having 4 cards of the same rank across two columns scores <strong>-20 bonus</strong>.</p>
|
<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>
|
||||||
<div class="house-rule">
|
<div class="house-rule">
|
||||||
<h4>Negative Pairs Keep Value</h4>
|
<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="avg_score">Avg Score</button>
|
||||||
<button class="leaderboard-tab" data-metric="knockouts">Knockouts</button>
|
<button class="leaderboard-tab" data-metric="knockouts">Knockouts</button>
|
||||||
<button class="leaderboard-tab" data-metric="streak">Best Streak</button>
|
<button class="leaderboard-tab" data-metric="streak">Best Streak</button>
|
||||||
|
<button class="leaderboard-tab" data-metric="rating">Rating</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="leaderboard-content">
|
<div id="leaderboard-content">
|
||||||
@@ -815,6 +843,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
|||||||
<div id="signup-form-container" class="hidden">
|
<div id="signup-form-container" class="hidden">
|
||||||
<h3>Sign Up</h3>
|
<h3>Sign Up</h3>
|
||||||
<form id="signup-form">
|
<form id="signup-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="signup-invite-code" placeholder="Invite Code" required>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
|
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class LeaderboardComponent {
|
|||||||
avg_score: 'Avg Score',
|
avg_score: 'Avg Score',
|
||||||
knockouts: 'Knockouts',
|
knockouts: 'Knockouts',
|
||||||
streak: 'Best Streak',
|
streak: 'Best Streak',
|
||||||
|
rating: 'Rating',
|
||||||
};
|
};
|
||||||
|
|
||||||
this.metricFormats = {
|
this.metricFormats = {
|
||||||
@@ -34,6 +35,7 @@ class LeaderboardComponent {
|
|||||||
avg_score: (v) => v.toFixed(1),
|
avg_score: (v) => v.toFixed(1),
|
||||||
knockouts: (v) => v.toLocaleString(),
|
knockouts: (v) => v.toLocaleString(),
|
||||||
streak: (v) => v.toLocaleString(),
|
streak: (v) => v.toLocaleString(),
|
||||||
|
rating: (v) => Math.round(v).toLocaleString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
|
|||||||
884
client/style.css
884
client/style.css
@@ -837,6 +837,28 @@ input::placeholder {
|
|||||||
color: rgba(255, 255, 255, 0.9);
|
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 Styles */
|
||||||
.card {
|
.card {
|
||||||
width: clamp(65px, 5.5vw, 100px);
|
width: clamp(65px, 5.5vw, 100px);
|
||||||
@@ -1110,8 +1132,8 @@ input::placeholder {
|
|||||||
.draw-anim-front,
|
.draw-anim-front,
|
||||||
.draw-anim-back {
|
.draw-anim-back {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100% !important;
|
||||||
height: 100%;
|
height: 100% !important;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@@ -1134,10 +1156,8 @@ input::placeholder {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
transform: scale(1.15);
|
|
||||||
transform-origin: center bottom;
|
|
||||||
border: 3px solid #f4a460 !important;
|
border: 3px solid #f4a460 !important;
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7) !important;
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 20px rgba(244, 164, 96, 0.6) !important;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
/* No transition - anime.js handles animations */
|
/* No transition - anime.js handles animations */
|
||||||
}
|
}
|
||||||
@@ -1501,11 +1521,11 @@ input::placeholder {
|
|||||||
|
|
||||||
@keyframes heldCardPulse {
|
@keyframes heldCardPulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7);
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 20px rgba(244, 164, 96, 0.6);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 35px rgba(244, 164, 96, 1),
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 30px rgba(244, 164, 96, 0.9),
|
||||||
0 0 50px rgba(244, 164, 96, 0.5);
|
0 0 45px rgba(244, 164, 96, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3279,15 +3299,28 @@ input::placeholder {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Auth buttons in lobby */
|
/* Auth prompt in lobby (shown when not logged in) */
|
||||||
.auth-buttons {
|
.auth-prompt {
|
||||||
display: flex;
|
text-align: center;
|
||||||
justify-content: center;
|
margin: 20px 0;
|
||||||
gap: 10px;
|
padding: 20px;
|
||||||
margin-bottom: 20px;
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-buttons.hidden {
|
.auth-prompt p {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt .button-group {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3371,6 +3404,48 @@ input::placeholder {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
MATCHMAKING SCREEN
|
||||||
|
=========================================== */
|
||||||
|
|
||||||
|
#matchmaking-screen {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#matchmaking-screen h2 {
|
||||||
|
color: #f4a460;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matchmaking-spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top-color: #f4a460;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 20px auto;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.matchmaking-timer {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
margin: 15px 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matchmaking-info {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin: 10px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
LEADERBOARD COMPONENTS
|
LEADERBOARD COMPONENTS
|
||||||
=========================================== */
|
=========================================== */
|
||||||
@@ -4382,56 +4457,21 @@ input::placeholder {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.knock-banner {
|
/* V3_17: Knock status message - golden gradient with pulsing glow */
|
||||||
position: fixed;
|
.status-message.knock {
|
||||||
top: 0;
|
background: linear-gradient(135deg, #f4a460 0%, #e67e22 50%, #d4750e 100%);
|
||||||
left: 0;
|
color: #1a1a2e;
|
||||||
right: 0;
|
font-size: 1.3em;
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
z-index: 400;
|
|
||||||
pointer-events: none;
|
|
||||||
animation: knock-banner-in 0.3s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.knock-banner span {
|
|
||||||
display: block;
|
|
||||||
font-size: 4em;
|
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: #ffe082;
|
letter-spacing: 0.08em;
|
||||||
background: rgba(20, 20, 36, 0.95);
|
text-transform: uppercase;
|
||||||
padding: 20px 50px;
|
animation: knock-pulse 0.6s ease-in-out 3;
|
||||||
border-radius: 12px;
|
box-shadow: 0 0 15px rgba(244, 164, 96, 0.5);
|
||||||
border: 3px solid rgba(255, 215, 0, 0.5);
|
|
||||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
|
||||||
letter-spacing: 0.15em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes knock-banner-in {
|
@keyframes knock-pulse {
|
||||||
0% { opacity: 0; }
|
0%, 100% { box-shadow: 0 0 15px rgba(244, 164, 96, 0.5); }
|
||||||
100% { opacity: 1; }
|
50% { box-shadow: 0 0 25px rgba(244, 164, 96, 0.8), 0 0 40px rgba(230, 126, 34, 0.3); }
|
||||||
}
|
|
||||||
|
|
||||||
.knock-banner span {
|
|
||||||
animation: knock-text-in 0.3s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes knock-text-in {
|
|
||||||
0% { transform: scale(0); }
|
|
||||||
50% { transform: scale(1.1); }
|
|
||||||
100% { transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.knock-banner.fading {
|
|
||||||
animation: knock-banner-out 0.3s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes knock-banner-out {
|
|
||||||
0% { opacity: 1; }
|
|
||||||
100% { opacity: 0; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes screen-shake {
|
@keyframes screen-shake {
|
||||||
@@ -4446,25 +4486,7 @@ body.screen-shake {
|
|||||||
animation: screen-shake 0.3s ease-out;
|
animation: screen-shake 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opponent-knock-banner {
|
/* opponent-knock-banner removed in V3_17 - knock uses status bar now */
|
||||||
position: fixed;
|
|
||||||
top: 30%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 15px 30px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 1.2em;
|
|
||||||
font-weight: bold;
|
|
||||||
z-index: 200;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
||||||
animation: prompt-entrance 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opponent-knock-banner.fading {
|
|
||||||
animation: prompt-fade 0.3s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- V3_07: Score Tallying Animation --- */
|
/* --- V3_07: Score Tallying Animation --- */
|
||||||
.card-value-overlay {
|
.card-value-overlay {
|
||||||
@@ -4522,6 +4544,10 @@ body.screen-shake {
|
|||||||
25% { transform: translate(-50%, -50%) scale(1.1); opacity: 1; }
|
25% { transform: translate(-50%, -50%) scale(1.1); opacity: 1; }
|
||||||
100% { transform: translate(-50%, -60%) scale(1); opacity: 0; }
|
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 --- */
|
/* --- V3_10: Column Pair Indicator --- */
|
||||||
.card.paired {
|
.card.paired {
|
||||||
@@ -4636,9 +4662,705 @@ body.screen-shake {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- V3_17: Scoresheet Modal --- */
|
||||||
|
.scoresheet-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 300;
|
||||||
|
animation: fadeInBg 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInBg {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoresheet-content {
|
||||||
|
background: linear-gradient(145deg, #1a472a 0%, #0d3320 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 92%;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow:
|
||||||
|
0 16px 50px rgba(0, 0, 0, 0.6),
|
||||||
|
0 0 60px rgba(244, 164, 96, 0.1),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
border: 2px solid rgba(244, 164, 96, 0.25);
|
||||||
|
animation: modalSlideIn 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-header {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f4a460;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-players {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-player-row {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-player-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-player-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-badge-knock {
|
||||||
|
background: linear-gradient(135deg, #f4a460 0%, #e67e22 100%);
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-badge-low {
|
||||||
|
background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-columns {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-column-paired {
|
||||||
|
background: rgba(244, 164, 96, 0.08);
|
||||||
|
border: 1px solid rgba(244, 164, 96, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-column-paired.ss-pair-glow {
|
||||||
|
animation: ss-pair-glow-pulse 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ss-pair-glow-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 rgba(244, 164, 96, 0); }
|
||||||
|
50% { box-shadow: 0 0 12px rgba(244, 164, 96, 0.4); }
|
||||||
|
100% { box-shadow: 0 0 0 rgba(244, 164, 96, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-mini-card {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
background: #f5f0e8;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-mini-card.ss-red {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-mini-card.ss-black {
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-mini-card.ss-mini-paired {
|
||||||
|
opacity: 0.5;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-mini-card.ss-mini-back {
|
||||||
|
background: linear-gradient(135deg, #2c5f8a 0%, #1a3a5c 100%);
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-col-score {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-col-score.ss-pair {
|
||||||
|
color: #f4a460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-col-score.ss-negative {
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-bonuses {
|
||||||
|
margin: 4px 0 2px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-bonus {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #81d4fa;
|
||||||
|
background: rgba(100, 181, 246, 0.15);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-scores {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-scores strong {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ss-next-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- V3_11: Swap Animation --- */
|
/* --- V3_11: Swap Animation --- */
|
||||||
.traveling-card {
|
.traveling-card {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MOBILE PORTRAIT LAYOUT
|
||||||
|
============================================
|
||||||
|
All rules scoped under body.mobile-portrait.
|
||||||
|
Triggered by JS matchMedia on narrow portrait screens.
|
||||||
|
Desktop layout is completely untouched.
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Mobile bottom bar - hidden on desktop */
|
||||||
|
#mobile-bottom-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait {
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait #app {
|
||||||
|
padding: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile: Game screen fills viewport --- */
|
||||||
|
/* IMPORTANT: Must include .active to avoid overriding .screen { display: none } */
|
||||||
|
body.mobile-portrait #game-screen.active {
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .game-layout {
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .game-main {
|
||||||
|
flex: 1;
|
||||||
|
gap: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile: Compact header (single row) --- */
|
||||||
|
body.mobile-portrait .game-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
padding-top: calc(4px + env(safe-area-inset-top, 0px));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
min-height: 32px;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .header-col-left {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .header-col-center {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .header-col-right {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide non-essential header items on mobile */
|
||||||
|
body.mobile-portrait .active-rules-bar,
|
||||||
|
body.mobile-portrait .game-username,
|
||||||
|
body.mobile-portrait #game-logout-btn {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .status-message {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .round-info {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait #leave-game-btn {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .mute-btn {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .final-turn-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile: Game table — opponents pinned top, rest centered in remaining space --- */
|
||||||
|
body.mobile-portrait .game-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0 !important;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 4px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile: Opponents as flat horizontal strip, pinned to top --- */
|
||||||
|
body.mobile-portrait .opponents-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 0 !important;
|
||||||
|
padding: 2px 8px 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile: Player row gets remaining space, centered vertically --- */
|
||||||
|
body.mobile-portrait .player-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove all arch rotation and margin on mobile */
|
||||||
|
body.mobile-portrait .opponents-row .opponent-area {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
transform: none !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .opponent-area {
|
||||||
|
padding: 3px 5px 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .opponent-area .dealer-chip {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
border-width: 2px;
|
||||||
|
bottom: -6px;
|
||||||
|
left: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .opponent-area h4 {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
margin: 0 0 2px 0;
|
||||||
|
padding: 2px 4px;
|
||||||
|
max-width: 110px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .opponent-area .card-grid {
|
||||||
|
grid-template-columns: repeat(3, 32px) !important;
|
||||||
|
gap: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .opponent-area .card {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 45px !important;
|
||||||
|
font-size: 0.6rem !important;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .opponent-showing {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
padding: 0px 3px;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile: Deck/Discard area centered --- */
|
||||||
|
body.mobile-portrait .table-center {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .deck-area {
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .deck-area > .card,
|
||||||
|
body.mobile-portrait #deck,
|
||||||
|
body.mobile-portrait #discard {
|
||||||
|
width: 72px !important;
|
||||||
|
height: 101px !important;
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Held card floating should NOT be constrained to deck/discard size */
|
||||||
|
body.mobile-portrait .held-card-floating {
|
||||||
|
width: 72px !important;
|
||||||
|
height: 101px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .discard-stack {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Discard button - horizontal on mobile instead of vertical tab */
|
||||||
|
body.mobile-portrait #discard-btn {
|
||||||
|
position: fixed;
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
text-orientation: initial;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile: Player cards — explicit sizes for reliable layout --- */
|
||||||
|
body.mobile-portrait .player-section {
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .player-area {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: auto;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .player-area h4 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .player-showing {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player hand: fixed-size cards */
|
||||||
|
body.mobile-portrait .player-section .card-grid {
|
||||||
|
grid-template-columns: repeat(3, 72px) !important;
|
||||||
|
gap: 5px !important;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .player-section .card {
|
||||||
|
width: 72px !important;
|
||||||
|
height: 101px !important;
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Real cards: font-size is now set inline by card-manager.js (proportional to card width).
|
||||||
|
Override the desktop clamp values to inherit from the element. */
|
||||||
|
body.mobile-portrait .real-card .card-face-front,
|
||||||
|
body.mobile-portrait .real-card .card-face-back {
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile: Side panels become bottom drawers --- */
|
||||||
|
body.mobile-portrait .side-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 55vh;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||||
|
z-index: 600;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s ease-out;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .side-panel.left-panel,
|
||||||
|
body.mobile-portrait .side-panel.right-panel {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .side-panel.drawer-open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drawer handle */
|
||||||
|
body.mobile-portrait .side-panel::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 40px;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drawer backdrop */
|
||||||
|
body.mobile-portrait .drawer-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 599;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .drawer-backdrop.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Score table in drawer: full width */
|
||||||
|
body.mobile-portrait .side-panel table {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .side-panel th,
|
||||||
|
body.mobile-portrait .side-panel td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standings list in drawer */
|
||||||
|
body.mobile-portrait .standings-list .rank-row {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game buttons in drawer */
|
||||||
|
body.mobile-portrait .game-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile: Bottom bar --- */
|
||||||
|
body.mobile-portrait #mobile-bottom-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
padding: 6px 16px;
|
||||||
|
padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px));
|
||||||
|
width: 100%;
|
||||||
|
z-index: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-top: 1px solid rgba(244, 164, 96, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait #mobile-bottom-bar .mobile-bar-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait #mobile-bottom-bar .mobile-bar-btn:active {
|
||||||
|
background: rgba(244, 164, 96, 0.3);
|
||||||
|
color: #f4a460;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait #mobile-bottom-bar .mobile-bar-btn.active {
|
||||||
|
color: #f4a460;
|
||||||
|
background: rgba(244, 164, 96, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile: Non-game screens --- */
|
||||||
|
body.mobile-portrait #lobby-screen {
|
||||||
|
padding: 50px 12px 15px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait #waiting-screen {
|
||||||
|
padding: 10px 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile: Very short screens (e.g. iPhone SE) --- */
|
||||||
|
@media (max-height: 600px) {
|
||||||
|
body.mobile-portrait .opponents-row {
|
||||||
|
padding: 2px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .opponent-area .card-grid {
|
||||||
|
grid-template-columns: repeat(3, 26px) !important;
|
||||||
|
gap: 1px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .opponent-area .card {
|
||||||
|
width: 26px !important;
|
||||||
|
height: 36px !important;
|
||||||
|
font-size: 0.45rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .table-center {
|
||||||
|
padding: 3px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .deck-area > .card,
|
||||||
|
body.mobile-portrait #deck,
|
||||||
|
body.mobile-portrait #discard {
|
||||||
|
width: 60px !important;
|
||||||
|
height: 84px !important;
|
||||||
|
font-size: 1.3rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .held-card-floating {
|
||||||
|
width: 60px !important;
|
||||||
|
height: 84px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .player-row {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .player-area {
|
||||||
|
padding: 3px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .player-section .card-grid {
|
||||||
|
grid-template-columns: repeat(3, 60px) !important;
|
||||||
|
gap: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .player-section .card {
|
||||||
|
width: 60px !important;
|
||||||
|
height: 84px !important;
|
||||||
|
font-size: 1.3rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-portrait .player-area h4 {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,6 +128,18 @@ const TIMING = {
|
|||||||
pulseDelay: 200, // Delay before card appears (pulse visible first)
|
pulseDelay: 200, // Delay before card appears (pulse visible first)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// V3_17: Knock notification
|
||||||
|
knock: {
|
||||||
|
statusDuration: 2500, // How long the knock status message persists
|
||||||
|
},
|
||||||
|
|
||||||
|
// V3_17: Scoresheet modal
|
||||||
|
scoresheet: {
|
||||||
|
playerStagger: 150, // Delay between player row animations
|
||||||
|
columnStagger: 80, // Delay between column animations within a row
|
||||||
|
pairGlowDelay: 200, // Delay before paired columns glow
|
||||||
|
},
|
||||||
|
|
||||||
// Player swap animation steps - smooth continuous motion
|
// Player swap animation steps - smooth continuous motion
|
||||||
playerSwap: {
|
playerSwap: {
|
||||||
flipToReveal: 400, // Initial flip to show card
|
flipToReveal: 400, // Initial flip to show card
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
- POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
||||||
|
- DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||||
@@ -30,26 +31,31 @@ services:
|
|||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
- BASE_URL=${BASE_URL:-https://golf.example.com}
|
- BASE_URL=${BASE_URL:-https://golf.example.com}
|
||||||
- RATE_LIMIT_ENABLED=true
|
- RATE_LIMIT_ENABLED=true
|
||||||
|
- INVITE_ONLY=true
|
||||||
|
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
|
||||||
|
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
|
- MATCHMAKING_ENABLED=true
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
deploy:
|
deploy:
|
||||||
replicas: 2
|
replicas: 1
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 512M
|
|
||||||
reservations:
|
|
||||||
memory: 256M
|
memory: 256M
|
||||||
|
reservations:
|
||||||
|
memory: 64M
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
- web
|
- web
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=golfgame_web"
|
||||||
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)"
|
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)"
|
||||||
- "traefik.http.routers.golf.entrypoints=websecure"
|
- "traefik.http.routers.golf.entrypoints=websecure"
|
||||||
- "traefik.http.routers.golf.tls=true"
|
- "traefik.http.routers.golf.tls=true"
|
||||||
@@ -77,13 +83,13 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 512M
|
memory: 192M
|
||||||
reservations:
|
reservations:
|
||||||
memory: 256M
|
memory: 64M
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
|
command: redis-server --appendonly yes --maxmemory 32mb --maxmemory-policy allkeys-lru
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -96,14 +102,19 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 192M
|
|
||||||
reservations:
|
|
||||||
memory: 64M
|
memory: 64M
|
||||||
|
reservations:
|
||||||
|
memory: 16M
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v2.10
|
image: traefik:v3.6
|
||||||
|
environment:
|
||||||
|
- DOCKER_API_VERSION=1.44
|
||||||
command:
|
command:
|
||||||
- "--api.dashboard=true"
|
- "--api.dashboard=true"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--accesslog=true"
|
||||||
|
- "--log.level=WARN"
|
||||||
- "--providers.docker=true"
|
- "--providers.docker=true"
|
||||||
- "--providers.docker.exposedbydefault=false"
|
- "--providers.docker.exposedbydefault=false"
|
||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
@@ -125,7 +136,7 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 128M
|
memory: 64M
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ This plan is split into independent vertical slices ordered by priority and impa
|
|||||||
| `V3_14_ACTIVE_RULES_CONTEXT.md` | Contextual rule highlighting | Low | Low | None |
|
| `V3_14_ACTIVE_RULES_CONTEXT.md` | Contextual rule highlighting | Low | Low | None |
|
||||||
| `V3_15_DISCARD_PILE_HISTORY.md` | Show recent discards fanned | Low | Medium | None |
|
| `V3_15_DISCARD_PILE_HISTORY.md` | Show recent discards fanned | Low | Medium | None |
|
||||||
| `V3_16_REALISTIC_CARD_SOUNDS.md` | Improved audio feedback | Nice | Medium | None |
|
| `V3_16_REALISTIC_CARD_SOUNDS.md` | Improved audio feedback | Nice | Medium | None |
|
||||||
|
| `V3_17_MOBILE_PORTRAIT_LAYOUT.md` | Full mobile portrait layout + animation fixes | High | High | 02, 11 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
117
docs/v3/V3_17_MOBILE_PORTRAIT_LAYOUT.md
Normal file
117
docs/v3/V3_17_MOBILE_PORTRAIT_LAYOUT.md
Normal 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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "golfgame"
|
name = "golfgame"
|
||||||
version = "2.0.1"
|
version = "3.1.1"
|
||||||
description = "6-Card Golf card game with AI opponents"
|
description = "6-Card Golf card game with AI opponents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -145,9 +145,18 @@ class ServerConfig:
|
|||||||
|
|
||||||
# Security (for future auth system)
|
# Security (for future auth system)
|
||||||
SECRET_KEY: str = ""
|
SECRET_KEY: str = ""
|
||||||
INVITE_ONLY: bool = False
|
INVITE_ONLY: bool = True
|
||||||
|
|
||||||
|
# Bootstrap admin (for first-time setup when INVITE_ONLY=true)
|
||||||
|
BOOTSTRAP_ADMIN_USERNAME: str = ""
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD: str = ""
|
||||||
ADMIN_EMAILS: list[str] = field(default_factory=list)
|
ADMIN_EMAILS: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Matchmaking
|
||||||
|
MATCHMAKING_ENABLED: bool = True
|
||||||
|
MATCHMAKING_MIN_PLAYERS: int = 2
|
||||||
|
MATCHMAKING_MAX_PLAYERS: int = 4
|
||||||
|
|
||||||
# Rate limiting
|
# Rate limiting
|
||||||
RATE_LIMIT_ENABLED: bool = True
|
RATE_LIMIT_ENABLED: bool = True
|
||||||
|
|
||||||
@@ -184,7 +193,12 @@ class ServerConfig:
|
|||||||
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
|
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
|
||||||
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
||||||
SECRET_KEY=get_env("SECRET_KEY", ""),
|
SECRET_KEY=get_env("SECRET_KEY", ""),
|
||||||
INVITE_ONLY=get_env_bool("INVITE_ONLY", False),
|
INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
|
||||||
|
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
|
||||||
|
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
|
||||||
|
MATCHMAKING_MIN_PLAYERS=get_env_int("MATCHMAKING_MIN_PLAYERS", 2),
|
||||||
|
MATCHMAKING_MAX_PLAYERS=get_env_int("MATCHMAKING_MAX_PLAYERS", 4),
|
||||||
ADMIN_EMAILS=admin_emails,
|
ADMIN_EMAILS=admin_emails,
|
||||||
RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True),
|
RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True),
|
||||||
SENTRY_DSN=get_env("SENTRY_DSN", ""),
|
SENTRY_DSN=get_env("SENTRY_DSN", ""),
|
||||||
|
|||||||
@@ -512,6 +512,20 @@ class GameOptions:
|
|||||||
deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"])
|
deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"])
|
||||||
"""Colors for card backs from different decks (in order by deck_id)."""
|
"""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 = {
|
_ALLOWED_COLORS = {
|
||||||
"red", "blue", "gold", "teal", "purple", "orange", "yellow",
|
"red", "blue", "gold", "teal", "purple", "orange", "yellow",
|
||||||
"green", "pink", "cyan", "brown", "slate",
|
"green", "pink", "cyan", "brown", "slate",
|
||||||
@@ -1630,5 +1644,13 @@ class Game:
|
|||||||
"finisher_id": self.finisher_id,
|
"finisher_id": self.finisher_id,
|
||||||
"card_values": self.get_card_values(),
|
"card_values": self.get_card_values(),
|
||||||
"active_rules": active_rules,
|
"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,
|
"deck_colors": self.options.deck_colors,
|
||||||
|
"is_standard_rules": self.options.is_standard_rules(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
from config import config
|
||||||
from game import GamePhase, GameOptions
|
from game import GamePhase, GameOptions
|
||||||
from ai import GolfAI, get_all_profiles
|
from ai import GolfAI, get_all_profiles
|
||||||
from room import Room
|
from room import Room
|
||||||
@@ -53,6 +54,10 @@ def log_human_action(room: Room, player, action: str, card=None, position=None,
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
||||||
|
if config.INVITE_ONLY and not ctx.authenticated_user:
|
||||||
|
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
|
||||||
|
return
|
||||||
|
|
||||||
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
||||||
await ctx.websocket.send_json({
|
await ctx.websocket.send_json({
|
||||||
"type": "error",
|
"type": "error",
|
||||||
@@ -60,9 +65,8 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
player_name = data.get("player_name", "Player")
|
# Use authenticated username as player name
|
||||||
if ctx.authenticated_user and ctx.authenticated_user.display_name:
|
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
||||||
player_name = ctx.authenticated_user.display_name
|
|
||||||
room = room_manager.create_room()
|
room = room_manager.create_room()
|
||||||
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||||
ctx.current_room = room
|
ctx.current_room = room
|
||||||
@@ -81,8 +85,13 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
|
|||||||
|
|
||||||
|
|
||||||
async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
||||||
|
if config.INVITE_ONLY and not ctx.authenticated_user:
|
||||||
|
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
|
||||||
|
return
|
||||||
|
|
||||||
room_code = data.get("room_code", "").upper()
|
room_code = data.get("room_code", "").upper()
|
||||||
player_name = data.get("player_name", "Player")
|
# Use authenticated username as player name
|
||||||
|
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
||||||
|
|
||||||
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
||||||
await ctx.websocket.send_json({
|
await ctx.websocket.send_json({
|
||||||
@@ -104,8 +113,6 @@ async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager,
|
|||||||
await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
|
await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
|
||||||
return
|
return
|
||||||
|
|
||||||
if ctx.authenticated_user and ctx.authenticated_user.display_name:
|
|
||||||
player_name = ctx.authenticated_user.display_name
|
|
||||||
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||||
ctx.current_room = room
|
ctx.current_room = room
|
||||||
|
|
||||||
@@ -483,6 +490,65 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c
|
|||||||
# Handler dispatch table
|
# Handler dispatch table
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Matchmaking handlers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def handle_queue_join(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, rating_service=None, **kw) -> None:
|
||||||
|
if not matchmaking_service:
|
||||||
|
await ctx.websocket.send_json({"type": "error", "message": "Matchmaking not available"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not ctx.authenticated_user:
|
||||||
|
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to find a game"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get player's rating
|
||||||
|
rating = 1500.0
|
||||||
|
if rating_service:
|
||||||
|
try:
|
||||||
|
player_rating = await rating_service.get_rating(ctx.auth_user_id)
|
||||||
|
rating = player_rating.rating
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
status = await matchmaking_service.join_queue(
|
||||||
|
user_id=ctx.auth_user_id,
|
||||||
|
username=ctx.authenticated_user.username,
|
||||||
|
rating=rating,
|
||||||
|
websocket=ctx.websocket,
|
||||||
|
connection_id=ctx.connection_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await ctx.websocket.send_json({
|
||||||
|
"type": "queue_joined",
|
||||||
|
**status,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_queue_leave(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
|
||||||
|
if not matchmaking_service or not ctx.auth_user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
removed = await matchmaking_service.leave_queue(ctx.auth_user_id)
|
||||||
|
await ctx.websocket.send_json({
|
||||||
|
"type": "queue_left",
|
||||||
|
"was_queued": removed,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_queue_status(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
|
||||||
|
if not matchmaking_service or not ctx.auth_user_id:
|
||||||
|
await ctx.websocket.send_json({"type": "queue_status", "in_queue": False})
|
||||||
|
return
|
||||||
|
|
||||||
|
status = await matchmaking_service.get_queue_status(ctx.auth_user_id)
|
||||||
|
await ctx.websocket.send_json({
|
||||||
|
"type": "queue_status",
|
||||||
|
**status,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
HANDLERS = {
|
HANDLERS = {
|
||||||
"create_room": handle_create_room,
|
"create_room": handle_create_room,
|
||||||
"join_room": handle_join_room,
|
"join_room": handle_join_room,
|
||||||
@@ -503,4 +569,7 @@ HANDLERS = {
|
|||||||
"leave_room": handle_leave_room,
|
"leave_room": handle_leave_room,
|
||||||
"leave_game": handle_leave_game,
|
"leave_game": handle_leave_game,
|
||||||
"end_game": handle_end_game,
|
"end_game": handle_end_game,
|
||||||
|
"queue_join": handle_queue_join,
|
||||||
|
"queue_leave": handle_queue_leave,
|
||||||
|
"queue_status": handle_queue_status,
|
||||||
}
|
}
|
||||||
|
|||||||
102
server/main.py
102
server/main.py
@@ -59,6 +59,8 @@ _user_store = None
|
|||||||
_auth_service = None
|
_auth_service = None
|
||||||
_admin_service = None
|
_admin_service = None
|
||||||
_stats_service = None
|
_stats_service = None
|
||||||
|
_rating_service = None
|
||||||
|
_matchmaking_service = None
|
||||||
_replay_service = None
|
_replay_service = None
|
||||||
_spectator_manager = None
|
_spectator_manager = None
|
||||||
_leaderboard_refresh_task = None
|
_leaderboard_refresh_task = None
|
||||||
@@ -101,7 +103,7 @@ async def _init_redis():
|
|||||||
|
|
||||||
async def _init_database_services():
|
async def _init_database_services():
|
||||||
"""Initialize all PostgreSQL-dependent services."""
|
"""Initialize all PostgreSQL-dependent services."""
|
||||||
global _user_store, _auth_service, _admin_service, _stats_service
|
global _user_store, _auth_service, _admin_service, _stats_service, _rating_service, _matchmaking_service
|
||||||
global _replay_service, _spectator_manager, _leaderboard_refresh_task
|
global _replay_service, _spectator_manager, _leaderboard_refresh_task
|
||||||
|
|
||||||
from stores.user_store import get_user_store
|
from stores.user_store import get_user_store
|
||||||
@@ -109,7 +111,7 @@ async def _init_database_services():
|
|||||||
from services.auth_service import get_auth_service
|
from services.auth_service import get_auth_service
|
||||||
from services.admin_service import get_admin_service
|
from services.admin_service import get_admin_service
|
||||||
from services.stats_service import StatsService, set_stats_service
|
from services.stats_service import StatsService, set_stats_service
|
||||||
from routers.auth import set_auth_service
|
from routers.auth import set_auth_service, set_admin_service_for_auth
|
||||||
from routers.admin import set_admin_service
|
from routers.admin import set_admin_service
|
||||||
from routers.stats import set_stats_service as set_stats_router_service
|
from routers.stats import set_stats_service as set_stats_router_service
|
||||||
from routers.stats import set_auth_service as set_stats_auth_service
|
from routers.stats import set_auth_service as set_stats_auth_service
|
||||||
@@ -127,6 +129,7 @@ async def _init_database_services():
|
|||||||
state_cache=None,
|
state_cache=None,
|
||||||
)
|
)
|
||||||
set_admin_service(_admin_service)
|
set_admin_service(_admin_service)
|
||||||
|
set_admin_service_for_auth(_admin_service)
|
||||||
logger.info("Admin services initialized")
|
logger.info("Admin services initialized")
|
||||||
|
|
||||||
# Stats + event store
|
# Stats + event store
|
||||||
@@ -137,6 +140,23 @@ async def _init_database_services():
|
|||||||
set_stats_auth_service(_auth_service)
|
set_stats_auth_service(_auth_service)
|
||||||
logger.info("Stats services initialized")
|
logger.info("Stats services initialized")
|
||||||
|
|
||||||
|
# Rating service (Glicko-2)
|
||||||
|
from services.rating_service import RatingService
|
||||||
|
_rating_service = RatingService(_user_store.pool)
|
||||||
|
logger.info("Rating service initialized")
|
||||||
|
|
||||||
|
# Matchmaking service
|
||||||
|
if config.MATCHMAKING_ENABLED:
|
||||||
|
from services.matchmaking import MatchmakingService, MatchmakingConfig
|
||||||
|
mm_config = MatchmakingConfig(
|
||||||
|
enabled=True,
|
||||||
|
min_players=config.MATCHMAKING_MIN_PLAYERS,
|
||||||
|
max_players=config.MATCHMAKING_MAX_PLAYERS,
|
||||||
|
)
|
||||||
|
_matchmaking_service = MatchmakingService(_redis_client, mm_config)
|
||||||
|
await _matchmaking_service.start(room_manager, broadcast_game_state)
|
||||||
|
logger.info("Matchmaking service initialized")
|
||||||
|
|
||||||
# Game logger
|
# Game logger
|
||||||
_game_logger = GameLogger(_event_store)
|
_game_logger = GameLogger(_event_store)
|
||||||
set_logger(_game_logger)
|
set_logger(_game_logger)
|
||||||
@@ -165,12 +185,56 @@ async def _init_database_services():
|
|||||||
logger.info("Leaderboard refresh task started")
|
logger.info("Leaderboard refresh task started")
|
||||||
|
|
||||||
|
|
||||||
|
async def _bootstrap_admin():
|
||||||
|
"""Create bootstrap admin user if no admins exist yet."""
|
||||||
|
import bcrypt
|
||||||
|
from models.user import UserRole
|
||||||
|
|
||||||
|
# Check if any admin already exists
|
||||||
|
existing = await _user_store.get_user_by_username(config.BOOTSTRAP_ADMIN_USERNAME)
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if any admin exists at all
|
||||||
|
async with _user_store.pool.acquire() as conn:
|
||||||
|
admin_count = await conn.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM users_v2 WHERE role = 'admin' AND deleted_at IS NULL"
|
||||||
|
)
|
||||||
|
if admin_count > 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create the bootstrap admin
|
||||||
|
password_hash = bcrypt.hashpw(
|
||||||
|
config.BOOTSTRAP_ADMIN_PASSWORD.encode("utf-8"),
|
||||||
|
bcrypt.gensalt(),
|
||||||
|
).decode("utf-8")
|
||||||
|
|
||||||
|
user = await _user_store.create_user(
|
||||||
|
username=config.BOOTSTRAP_ADMIN_USERNAME,
|
||||||
|
password_hash=password_hash,
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
logger.warning(
|
||||||
|
f"Bootstrap admin '{config.BOOTSTRAP_ADMIN_USERNAME}' created. "
|
||||||
|
"Change the password and remove BOOTSTRAP_ADMIN_* env vars."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error("Failed to create bootstrap admin user")
|
||||||
|
|
||||||
|
|
||||||
async def _shutdown_services():
|
async def _shutdown_services():
|
||||||
"""Gracefully shut down all services."""
|
"""Gracefully shut down all services."""
|
||||||
_shutdown_event.set()
|
_shutdown_event.set()
|
||||||
|
|
||||||
await _close_all_websockets()
|
await _close_all_websockets()
|
||||||
|
|
||||||
|
# Stop matchmaking
|
||||||
|
if _matchmaking_service:
|
||||||
|
await _matchmaking_service.stop()
|
||||||
|
await _matchmaking_service.cleanup()
|
||||||
|
|
||||||
# Clean up rooms and CPU profiles
|
# Clean up rooms and CPU profiles
|
||||||
for room in list(room_manager.rooms.values()):
|
for room in list(room_manager.rooms.values()):
|
||||||
for cpu in list(room.get_cpu_players()):
|
for cpu in list(room.get_cpu_players()):
|
||||||
@@ -225,6 +289,10 @@ async def lifespan(app: FastAPI):
|
|||||||
else:
|
else:
|
||||||
logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work")
|
logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work")
|
||||||
|
|
||||||
|
# Bootstrap admin user if needed (for first-time setup with INVITE_ONLY)
|
||||||
|
if config.POSTGRES_URL and config.BOOTSTRAP_ADMIN_USERNAME and config.BOOTSTRAP_ADMIN_PASSWORD:
|
||||||
|
await _bootstrap_admin()
|
||||||
|
|
||||||
# Set up health check dependencies
|
# Set up health check dependencies
|
||||||
from routers.health import set_health_dependencies
|
from routers.health import set_health_dependencies
|
||||||
set_health_dependencies(
|
set_health_dependencies(
|
||||||
@@ -257,7 +325,7 @@ async def _close_all_websockets():
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Golf Card Game",
|
title="Golf Card Game",
|
||||||
debug=config.DEBUG,
|
debug=config.DEBUG,
|
||||||
version="2.0.1",
|
version="3.1.1",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -458,7 +526,7 @@ def count_user_games(user_id: str) -> int:
|
|||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
||||||
# Extract token from query param for optional authentication
|
# Extract token from query param for authentication
|
||||||
token = websocket.query_params.get("token")
|
token = websocket.query_params.get("token")
|
||||||
authenticated_user = None
|
authenticated_user = None
|
||||||
if token and _auth_service:
|
if token and _auth_service:
|
||||||
@@ -467,6 +535,12 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"WebSocket auth failed: {e}")
|
logger.debug(f"WebSocket auth failed: {e}")
|
||||||
|
|
||||||
|
# Reject unauthenticated connections when invite-only
|
||||||
|
if config.INVITE_ONLY and not authenticated_user:
|
||||||
|
await websocket.send_json({"type": "error", "message": "Authentication required. Please log in."})
|
||||||
|
await websocket.close(code=4001, reason="Authentication required")
|
||||||
|
return
|
||||||
|
|
||||||
connection_id = str(uuid.uuid4())
|
connection_id = str(uuid.uuid4())
|
||||||
auth_user_id = str(authenticated_user.id) if authenticated_user else None
|
auth_user_id = str(authenticated_user.id) if authenticated_user else None
|
||||||
|
|
||||||
@@ -492,6 +566,8 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
check_and_run_cpu_turn=check_and_run_cpu_turn,
|
check_and_run_cpu_turn=check_and_run_cpu_turn,
|
||||||
handle_player_leave=handle_player_leave,
|
handle_player_leave=handle_player_leave,
|
||||||
cleanup_room_profiles=cleanup_room_profiles,
|
cleanup_room_profiles=cleanup_room_profiles,
|
||||||
|
matchmaking_service=_matchmaking_service,
|
||||||
|
rating_service=_rating_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -531,8 +607,26 @@ async def _process_stats_safe(room: Room):
|
|||||||
winner_id=winner_id,
|
winner_id=winner_id,
|
||||||
num_rounds=room.game.num_rounds,
|
num_rounds=room.game.num_rounds,
|
||||||
player_user_ids=player_user_ids,
|
player_user_ids=player_user_ids,
|
||||||
|
game_options=room.game.options,
|
||||||
)
|
)
|
||||||
logger.debug(f"Stats processed for room {room.code}")
|
logger.debug(f"Stats processed for room {room.code}")
|
||||||
|
|
||||||
|
# Update Glicko-2 ratings for human players
|
||||||
|
if _rating_service:
|
||||||
|
player_results = []
|
||||||
|
for game_player in room.game.players:
|
||||||
|
if game_player.id in player_user_ids:
|
||||||
|
player_results.append((
|
||||||
|
player_user_ids[game_player.id],
|
||||||
|
game_player.total_score,
|
||||||
|
))
|
||||||
|
|
||||||
|
if len(player_results) >= 2:
|
||||||
|
await _rating_service.update_ratings(
|
||||||
|
player_results=player_results,
|
||||||
|
is_standard_rules=room.game.options.is_standard_rules(),
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to process game stats: {e}")
|
logger.error(f"Failed to process game stats: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -110,8 +110,10 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
# Add WebSocket URLs
|
# Add WebSocket URLs
|
||||||
if self.environment == "production":
|
if self.environment == "production":
|
||||||
|
connect_sources.append(f"ws://{host}")
|
||||||
connect_sources.append(f"wss://{host}")
|
connect_sources.append(f"wss://{host}")
|
||||||
for allowed_host in self.allowed_hosts:
|
for allowed_host in self.allowed_hosts:
|
||||||
|
connect_sources.append(f"ws://{allowed_host}")
|
||||||
connect_sources.append(f"wss://{allowed_host}")
|
connect_sources.append(f"wss://{allowed_host}")
|
||||||
else:
|
else:
|
||||||
# Development - allow ws:// and wss://
|
# Development - allow ws:// and wss://
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request
|
from fastapi import APIRouter, Depends, HTTPException, Header, Request
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
from config import config
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from services.auth_service import AuthService
|
from services.auth_service import AuthService
|
||||||
|
from services.admin_service import AdminService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ class RegisterRequest(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
invite_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
@@ -111,6 +114,7 @@ class SessionResponse(BaseModel):
|
|||||||
|
|
||||||
# These will be set by main.py during startup
|
# These will be set by main.py during startup
|
||||||
_auth_service: Optional[AuthService] = None
|
_auth_service: Optional[AuthService] = None
|
||||||
|
_admin_service: Optional[AdminService] = None
|
||||||
|
|
||||||
|
|
||||||
def set_auth_service(service: AuthService) -> None:
|
def set_auth_service(service: AuthService) -> None:
|
||||||
@@ -119,6 +123,12 @@ def set_auth_service(service: AuthService) -> None:
|
|||||||
_auth_service = service
|
_auth_service = service
|
||||||
|
|
||||||
|
|
||||||
|
def set_admin_service_for_auth(service: AdminService) -> None:
|
||||||
|
"""Set the admin service instance for invite code validation (called from main.py)."""
|
||||||
|
global _admin_service
|
||||||
|
_admin_service = service
|
||||||
|
|
||||||
|
|
||||||
def get_auth_service_dep() -> AuthService:
|
def get_auth_service_dep() -> AuthService:
|
||||||
"""Dependency to get auth service."""
|
"""Dependency to get auth service."""
|
||||||
if _auth_service is None:
|
if _auth_service is None:
|
||||||
@@ -201,6 +211,15 @@ async def register(
|
|||||||
auth_service: AuthService = Depends(get_auth_service_dep),
|
auth_service: AuthService = Depends(get_auth_service_dep),
|
||||||
):
|
):
|
||||||
"""Register a new user account."""
|
"""Register a new user account."""
|
||||||
|
# Validate invite code when invite-only mode is enabled
|
||||||
|
if config.INVITE_ONLY:
|
||||||
|
if not request_body.invite_code:
|
||||||
|
raise HTTPException(status_code=400, detail="Invite code required")
|
||||||
|
if not _admin_service:
|
||||||
|
raise HTTPException(status_code=503, detail="Admin service not initialized")
|
||||||
|
if not await _admin_service.validate_invite_code(request_body.invite_code):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
|
||||||
|
|
||||||
result = await auth_service.register(
|
result = await auth_service.register(
|
||||||
username=request_body.username,
|
username=request_body.username,
|
||||||
password=request_body.password,
|
password=request_body.password,
|
||||||
@@ -210,6 +229,10 @@ async def register(
|
|||||||
if not result.success:
|
if not result.success:
|
||||||
raise HTTPException(status_code=400, detail=result.error)
|
raise HTTPException(status_code=400, detail=result.error)
|
||||||
|
|
||||||
|
# Consume the invite code after successful registration
|
||||||
|
if config.INVITE_ONLY and request_body.invite_code:
|
||||||
|
await _admin_service.use_invite_code(request_body.invite_code)
|
||||||
|
|
||||||
if result.requires_verification:
|
if result.requires_verification:
|
||||||
# Return user info but note they need to verify
|
# Return user info but note they need to verify
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ async def require_user(
|
|||||||
|
|
||||||
@router.get("/leaderboard", response_model=LeaderboardResponse)
|
@router.get("/leaderboard", response_model=LeaderboardResponse)
|
||||||
async def get_leaderboard(
|
async def get_leaderboard(
|
||||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
service: StatsService = Depends(get_stats_service_dep),
|
service: StatsService = Depends(get_stats_service_dep),
|
||||||
@@ -226,7 +226,7 @@ async def get_player_stats(
|
|||||||
@router.get("/players/{user_id}/rank", response_model=PlayerRankResponse)
|
@router.get("/players/{user_id}/rank", response_model=PlayerRankResponse)
|
||||||
async def get_player_rank(
|
async def get_player_rank(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
|
||||||
service: StatsService = Depends(get_stats_service_dep),
|
service: StatsService = Depends(get_stats_service_dep),
|
||||||
):
|
):
|
||||||
"""Get player's rank on a leaderboard."""
|
"""Get player's rank on a leaderboard."""
|
||||||
@@ -346,7 +346,7 @@ async def get_my_stats(
|
|||||||
|
|
||||||
@router.get("/me/rank", response_model=PlayerRankResponse)
|
@router.get("/me/rank", response_model=PlayerRankResponse)
|
||||||
async def get_my_rank(
|
async def get_my_rank(
|
||||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
|
||||||
user: User = Depends(require_user),
|
user: User = Depends(require_user),
|
||||||
service: StatsService = Depends(get_stats_service_dep),
|
service: StatsService = Depends(get_stats_service_dep),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Usage:
|
|||||||
logger.log_move(game_id, player, is_cpu=False, action="swap", ...)
|
logger.log_move(game_id, player, is_cpu=False, action="swap", ...)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
@@ -46,6 +47,13 @@ class GameLogger:
|
|||||||
"""
|
"""
|
||||||
self.event_store = event_store
|
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
|
# Game Lifecycle
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -71,30 +79,12 @@ class GameLogger:
|
|||||||
"""
|
"""
|
||||||
game_id = str(uuid.uuid4())
|
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:
|
try:
|
||||||
await self.event_store.create_game(
|
await self.event_store.create_game(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
room_code=room_code,
|
room_code=room_code,
|
||||||
host_id="system",
|
host_id="system",
|
||||||
options=options_dict,
|
options=self._options_to_dict(options),
|
||||||
)
|
)
|
||||||
log.debug(f"Logged game start: {game_id} room={room_code}")
|
log.debug(f"Logged game start: {game_id} room={room_code}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -133,30 +123,12 @@ class GameLogger:
|
|||||||
options: "GameOptions",
|
options: "GameOptions",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Helper to log game start with pre-generated ID."""
|
"""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:
|
try:
|
||||||
await self.event_store.create_game(
|
await self.event_store.create_game(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
room_code=room_code,
|
room_code=room_code,
|
||||||
host_id="system",
|
host_id="system",
|
||||||
options=options_dict,
|
options=self._options_to_dict(options),
|
||||||
)
|
)
|
||||||
log.debug(f"Logged game start: {game_id} room={room_code}")
|
log.debug(f"Logged game start: {game_id} room={room_code}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
393
server/services/matchmaking.py
Normal file
393
server/services/matchmaking.py
Normal 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
|
||||||
322
server/services/rating_service.py
Normal file
322
server/services/rating_service.py
Normal 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
|
||||||
@@ -14,6 +14,7 @@ import asyncpg
|
|||||||
|
|
||||||
from stores.event_store import EventStore
|
from stores.event_store import EventStore
|
||||||
from models.events import EventType
|
from models.events import EventType
|
||||||
|
from game import GameOptions
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ class PlayerStats:
|
|||||||
wolfpacks: int = 0
|
wolfpacks: int = 0
|
||||||
current_win_streak: int = 0
|
current_win_streak: int = 0
|
||||||
best_win_streak: int = 0
|
best_win_streak: int = 0
|
||||||
|
rating: float = 1500.0
|
||||||
|
rating_deviation: float = 350.0
|
||||||
first_game_at: Optional[datetime] = None
|
first_game_at: Optional[datetime] = None
|
||||||
last_game_at: Optional[datetime] = None
|
last_game_at: Optional[datetime] = None
|
||||||
achievements: List[str] = field(default_factory=list)
|
achievements: List[str] = field(default_factory=list)
|
||||||
@@ -155,6 +158,8 @@ class StatsService:
|
|||||||
wolfpacks=row["wolfpacks"] or 0,
|
wolfpacks=row["wolfpacks"] or 0,
|
||||||
current_win_streak=row["current_win_streak"] or 0,
|
current_win_streak=row["current_win_streak"] or 0,
|
||||||
best_win_streak=row["best_win_streak"] or 0,
|
best_win_streak=row["best_win_streak"] or 0,
|
||||||
|
rating=float(row["rating"]) if row.get("rating") else 1500.0,
|
||||||
|
rating_deviation=float(row["rating_deviation"]) if row.get("rating_deviation") else 350.0,
|
||||||
first_game_at=row["first_game_at"].replace(tzinfo=timezone.utc) if row["first_game_at"] else None,
|
first_game_at=row["first_game_at"].replace(tzinfo=timezone.utc) if row["first_game_at"] else None,
|
||||||
last_game_at=row["last_game_at"].replace(tzinfo=timezone.utc) if row["last_game_at"] else None,
|
last_game_at=row["last_game_at"].replace(tzinfo=timezone.utc) if row["last_game_at"] else None,
|
||||||
achievements=[a["achievement_id"] for a in achievements],
|
achievements=[a["achievement_id"] for a in achievements],
|
||||||
@@ -183,6 +188,7 @@ class StatsService:
|
|||||||
"avg_score": ("avg_score", "ASC"), # Lower is better
|
"avg_score": ("avg_score", "ASC"), # Lower is better
|
||||||
"knockouts": ("knockouts", "DESC"),
|
"knockouts": ("knockouts", "DESC"),
|
||||||
"streak": ("best_win_streak", "DESC"),
|
"streak": ("best_win_streak", "DESC"),
|
||||||
|
"rating": ("rating", "DESC"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if metric not in order_map:
|
if metric not in order_map:
|
||||||
@@ -202,6 +208,7 @@ class StatsService:
|
|||||||
SELECT
|
SELECT
|
||||||
user_id, username, games_played, games_won,
|
user_id, username, games_played, games_won,
|
||||||
win_rate, avg_score, knockouts, best_win_streak,
|
win_rate, avg_score, knockouts, best_win_streak,
|
||||||
|
COALESCE(rating, 1500) as rating,
|
||||||
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
||||||
FROM leaderboard_overall
|
FROM leaderboard_overall
|
||||||
ORDER BY {column} {direction}
|
ORDER BY {column} {direction}
|
||||||
@@ -215,6 +222,7 @@ class StatsService:
|
|||||||
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
|
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
|
||||||
ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score,
|
ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score,
|
||||||
s.knockouts, s.best_win_streak,
|
s.knockouts, s.best_win_streak,
|
||||||
|
COALESCE(s.rating, 1500) as rating,
|
||||||
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
||||||
FROM player_stats s
|
FROM player_stats s
|
||||||
JOIN users_v2 u ON s.user_id = u.id
|
JOIN users_v2 u ON s.user_id = u.id
|
||||||
@@ -584,6 +592,47 @@ class StatsService:
|
|||||||
|
|
||||||
return data if data["num_rounds"] > 0 else None
|
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(
|
async def _check_achievements(
|
||||||
self,
|
self,
|
||||||
conn: asyncpg.Connection,
|
conn: asyncpg.Connection,
|
||||||
@@ -605,8 +654,6 @@ class StatsService:
|
|||||||
Returns:
|
Returns:
|
||||||
List of newly awarded achievement IDs.
|
List of newly awarded achievement IDs.
|
||||||
"""
|
"""
|
||||||
new_achievements = []
|
|
||||||
|
|
||||||
# Get current stats (after update)
|
# Get current stats (after update)
|
||||||
stats = await conn.fetchrow("""
|
stats = await conn.fetchrow("""
|
||||||
SELECT games_won, knockouts, best_win_streak, current_win_streak, perfect_rounds, wolfpacks
|
SELECT games_won, knockouts, best_win_streak, current_win_streak, perfect_rounds, wolfpacks
|
||||||
@@ -617,35 +664,15 @@ class StatsService:
|
|||||||
if not stats:
|
if not stats:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Get already earned achievements
|
earned_ids = await self._get_earned_ids(conn, user_id)
|
||||||
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
|
# Win/streak milestones (shared logic)
|
||||||
wins = stats["games_won"]
|
new_achievements = self._check_win_milestones(stats, earned_ids)
|
||||||
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
|
# Game-specific achievements (event path only)
|
||||||
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
|
|
||||||
if stats["knockouts"] >= 10 and "knockout_10" not in earned_ids:
|
if stats["knockouts"] >= 10 and "knockout_10" not in earned_ids:
|
||||||
new_achievements.append("knockout_10")
|
new_achievements.append("knockout_10")
|
||||||
|
|
||||||
# Check round-specific achievements from this game
|
|
||||||
best_round = player_data.get("best_round")
|
best_round = player_data.get("best_round")
|
||||||
if best_round is not None:
|
if best_round is not None:
|
||||||
if best_round <= 0 and "perfect_round" not in earned_ids:
|
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:
|
if best_round < 0 and "negative_round" not in earned_ids:
|
||||||
new_achievements.append("negative_round")
|
new_achievements.append("negative_round")
|
||||||
|
|
||||||
# Check wolfpack
|
|
||||||
if player_data.get("wolfpacks", 0) > 0 and "wolfpack" not in earned_ids:
|
if player_data.get("wolfpacks", 0) > 0 and "wolfpack" not in earned_ids:
|
||||||
new_achievements.append("wolfpack")
|
new_achievements.append("wolfpack")
|
||||||
|
|
||||||
# Award new achievements
|
await self._award_achievements(conn, user_id, new_achievements, game_id)
|
||||||
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}")
|
|
||||||
|
|
||||||
return new_achievements
|
return new_achievements
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -680,18 +696,21 @@ class StatsService:
|
|||||||
winner_id: Optional[str],
|
winner_id: Optional[str],
|
||||||
num_rounds: int,
|
num_rounds: int,
|
||||||
player_user_ids: dict[str, str] = None,
|
player_user_ids: dict[str, str] = None,
|
||||||
|
game_options: Optional[GameOptions] = None,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Process game stats directly from game state (for legacy games).
|
Process game stats directly from game state (for legacy games).
|
||||||
|
|
||||||
This is used when games don't have event sourcing. Stats are updated
|
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:
|
Args:
|
||||||
players: List of game.Player objects with final scores.
|
players: List of game.Player objects with final scores.
|
||||||
winner_id: Player ID of the winner.
|
winner_id: Player ID of the winner.
|
||||||
num_rounds: Total rounds played.
|
num_rounds: Total rounds played.
|
||||||
player_user_ids: Optional mapping of player_id to user_id (for authenticated players).
|
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:
|
Returns:
|
||||||
List of newly awarded achievement IDs.
|
List of newly awarded achievement IDs.
|
||||||
@@ -699,6 +718,11 @@ class StatsService:
|
|||||||
if not players:
|
if not players:
|
||||||
return []
|
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
|
# Count human players for has_human_opponents calculation
|
||||||
# For legacy games, we assume all players are human unless otherwise indicated
|
# For legacy games, we assume all players are human unless otherwise indicated
|
||||||
human_count = len(players)
|
human_count = len(players)
|
||||||
@@ -800,9 +824,6 @@ class StatsService:
|
|||||||
|
|
||||||
Only checks win-based achievements since we don't have round-level data.
|
Only checks win-based achievements since we don't have round-level data.
|
||||||
"""
|
"""
|
||||||
new_achievements = []
|
|
||||||
|
|
||||||
# Get current stats
|
|
||||||
stats = await conn.fetchrow("""
|
stats = await conn.fetchrow("""
|
||||||
SELECT games_won, current_win_streak FROM player_stats
|
SELECT games_won, current_win_streak FROM player_stats
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
@@ -811,41 +832,9 @@ class StatsService:
|
|||||||
if not stats:
|
if not stats:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Get already earned achievements
|
earned_ids = await self._get_earned_ids(conn, user_id)
|
||||||
earned = await conn.fetch("""
|
new_achievements = self._check_win_milestones(stats, earned_ids)
|
||||||
SELECT achievement_id FROM user_achievements WHERE user_id = $1
|
await self._award_achievements(conn, user_id, new_achievements)
|
||||||
""", 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}")
|
|
||||||
|
|
||||||
return new_achievements
|
return new_achievements
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -204,6 +204,22 @@ BEGIN
|
|||||||
WHERE table_name = 'player_stats' AND column_name = 'games_won_vs_humans') THEN
|
WHERE table_name = 'player_stats' AND column_name = 'games_won_vs_humans') THEN
|
||||||
ALTER TABLE player_stats ADD COLUMN games_won_vs_humans INT DEFAULT 0;
|
ALTER TABLE player_stats ADD COLUMN games_won_vs_humans INT DEFAULT 0;
|
||||||
END IF;
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'player_stats' AND column_name = 'rating') THEN
|
||||||
|
ALTER TABLE player_stats ADD COLUMN rating DECIMAL(7,2) DEFAULT 1500.0;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'player_stats' AND column_name = 'rating_deviation') THEN
|
||||||
|
ALTER TABLE player_stats ADD COLUMN rating_deviation DECIMAL(7,2) DEFAULT 350.0;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'player_stats' AND column_name = 'rating_volatility') THEN
|
||||||
|
ALTER TABLE player_stats ADD COLUMN rating_volatility DECIMAL(8,6) DEFAULT 0.06;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'player_stats' AND column_name = 'rating_updated_at') THEN
|
||||||
|
ALTER TABLE player_stats ADD COLUMN rating_updated_at TIMESTAMPTZ;
|
||||||
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- Stats processing queue (for async stats processing)
|
-- Stats processing queue (for async stats processing)
|
||||||
@@ -265,9 +281,19 @@ CREATE TABLE IF NOT EXISTS system_metrics (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Leaderboard materialized view (refreshed periodically)
|
-- Leaderboard materialized view (refreshed periodically)
|
||||||
-- Note: Using DO block to handle case where view already exists
|
-- Drop and recreate if missing rating column (v3.1.0 migration)
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
|
||||||
|
-- Check if rating column exists in the view
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'leaderboard_overall' AND column_name = 'rating'
|
||||||
|
) THEN
|
||||||
|
DROP MATERIALIZED VIEW leaderboard_overall;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
|
||||||
EXECUTE '
|
EXECUTE '
|
||||||
CREATE MATERIALIZED VIEW leaderboard_overall AS
|
CREATE MATERIALIZED VIEW leaderboard_overall AS
|
||||||
@@ -282,6 +308,7 @@ BEGIN
|
|||||||
s.best_score as best_round_score,
|
s.best_score as best_round_score,
|
||||||
s.knockouts,
|
s.knockouts,
|
||||||
s.best_win_streak,
|
s.best_win_streak,
|
||||||
|
COALESCE(s.rating, 1500) as rating,
|
||||||
s.last_game_at
|
s.last_game_at
|
||||||
FROM player_stats s
|
FROM player_stats s
|
||||||
JOIN users_v2 u ON s.user_id = u.id
|
JOIN users_v2 u ON s.user_id = u.id
|
||||||
@@ -349,6 +376,9 @@ BEGIN
|
|||||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_score') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_score') THEN
|
||||||
CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC);
|
CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC);
|
||||||
END IF;
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_rating') THEN
|
||||||
|
CREATE INDEX idx_leaderboard_overall_rating ON leaderboard_overall(rating DESC);
|
||||||
|
END IF;
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user