Compare commits
166 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ca52eb7d1 | ||
|
|
3c63af91f2 | ||
|
|
5fcf8bab60 | ||
|
|
8bc8595b39 | ||
|
|
7c58543ec8 | ||
|
|
4b00094140 | ||
|
|
65d6598a51 | ||
|
|
baa471307e | ||
|
|
26778e4b02 | ||
|
|
cce2d661a2 | ||
|
|
1b748470a0 | ||
|
|
d32ae83ce2 | ||
|
|
e542cadedf | ||
|
|
cd2d7535e3 | ||
|
|
4dff1da875 | ||
|
|
8f21a40a6a | ||
|
|
0ae999aca6 | ||
|
|
a87cd7f4b0 | ||
|
|
eb072dbfb4 | ||
|
|
4c16147ace | ||
|
|
cac1e26bac | ||
|
|
31dcb70fc8 | ||
|
|
15339d390f | ||
|
|
c523b144f5 | ||
|
|
0f3ae992f9 | ||
|
|
ce6b276c11 | ||
|
|
231e666407 | ||
|
|
7842de3a96 | ||
|
|
aab41c5413 | ||
|
|
625320992e | ||
|
|
61713f28c8 | ||
|
|
0eac6d443c | ||
|
|
dc936d7e1c | ||
|
|
1cdf1cf281 | ||
|
|
17f7d8ce7a | ||
|
|
9a5bc888cb | ||
|
|
3dcad3dfdf | ||
|
|
b129aa4f29 | ||
|
|
86697dd454 | ||
|
|
77cbefc30c | ||
|
|
e2c7a55dac | ||
|
|
8d5b2ee655 | ||
|
|
06b15f002d | ||
|
|
76f80f3f44 | ||
|
|
0a9993a82f | ||
|
|
e463d929e3 | ||
|
|
1b923838e0 | ||
|
|
4503198021 | ||
|
|
cb49fd545b | ||
|
|
cb311ec0da | ||
|
|
873bdfc75a | ||
|
|
bd41afbca8 | ||
|
|
21985b7e9b | ||
|
|
56305424ff | ||
|
|
0bfe9d5f9f | ||
|
|
a0bb28d5eb | ||
|
|
55006d6ff4 | ||
|
|
adcc59b6fc | ||
|
|
7e0c006f5e | ||
|
|
02f9b3c44d | ||
|
|
9f75cdb0dc | ||
|
|
519d08a2a6 | ||
|
|
9419cb562e | ||
|
|
17c8e574ab | ||
|
|
94edb685a7 | ||
|
|
6b7d6c459e | ||
|
|
1de282afc2 | ||
|
|
9b0a8295eb | ||
|
|
28a0f90374 | ||
|
|
0df451aa99 | ||
|
|
8d7b024525 | ||
|
|
9c08b4735a | ||
|
|
49916e6a6c | ||
|
|
e0641de449 | ||
|
|
e2a90c0f34 | ||
|
|
86f5222746 | ||
|
|
60997e8ad4 | ||
|
|
3e133b17c0 | ||
|
|
9866fb8e92 | ||
|
|
4a5cfb68f1 | ||
|
|
ebb00f613c | ||
|
|
98aa0823ed | ||
|
|
4a3d62e26e | ||
|
|
d958258066 | ||
|
|
26bc151458 | ||
|
|
0d5c0c613d | ||
|
|
e9692de6c6 | ||
|
|
3414bfad1a | ||
|
|
ecad259db2 | ||
|
|
932e9ca4ef | ||
|
|
10825e8b82 | ||
|
|
53abde53ac | ||
|
|
d7ba3154a1 | ||
|
|
197595fc4d | ||
|
|
e38d8c1561 | ||
|
|
afb4869b21 | ||
|
|
c6769f9257 | ||
|
|
8657a0501f | ||
|
|
730ba9c462 | ||
|
|
1ba80606a7 | ||
|
|
3261e6ee26 | ||
|
|
de3495635b | ||
|
|
4c23f2b4a9 | ||
|
|
7b071afdfb | ||
|
|
c7fb85d281 | ||
|
|
118912dd13 | ||
|
|
0e594a5e28 | ||
|
|
a6ec72d72c | ||
|
|
e2f353d4ab | ||
|
|
e601eb04c9 | ||
|
|
6c771810f7 | ||
|
|
dbad7037d1 | ||
|
|
21362ba125 | ||
|
|
2dcdaf2b49 | ||
|
|
1fa13bbe3b | ||
|
|
a76fd8da32 | ||
|
|
634d101f2c | ||
|
|
28c9882b17 | ||
|
|
a1d8a127dc | ||
|
|
65b4af9831 | ||
|
|
8942238f9c | ||
|
|
7dc27fe882 | ||
|
|
097f241c6f | ||
|
|
1c5d6b09e2 | ||
|
|
889f8ce1cd | ||
|
|
b4e9390f16 | ||
|
|
94e2bdaaa7 | ||
|
|
d322403764 | ||
|
|
9c6ce255bd | ||
|
|
06d52a9d2c | ||
|
|
76cbd4ae22 | ||
|
|
9b04bc85c2 | ||
|
|
2ccbfc8120 | ||
|
|
1678077c53 | ||
|
|
0dbb2d13ed | ||
|
|
82e5226acc | ||
|
|
b81874f5ba | ||
|
|
797d1e0280 | ||
|
|
538ca51ba5 | ||
|
|
9339abe19c | ||
|
|
ac2d53b404 | ||
|
|
7e108a71f9 | ||
|
|
7642d120e2 | ||
|
|
6ba0639d51 | ||
|
|
3b9522fec3 | ||
|
|
aa2093d6c8 | ||
|
|
3227c92d63 | ||
|
|
b7b21d8378 | ||
|
|
fb3bd53b0a | ||
|
|
4fcdf13f66 | ||
|
|
6673e63241 | ||
|
|
62e7d4e1dd | ||
|
|
bae5d8da3c | ||
|
|
62e3dc0395 | ||
|
|
bda88d8218 | ||
|
|
b5a8e1fe7b | ||
|
|
929ab0f320 | ||
|
|
7026d86081 | ||
|
|
b2ce6f5cf1 | ||
|
|
d4a39fe234 | ||
|
|
9966fd9470 | ||
|
|
050294754c | ||
|
|
1856019a95 | ||
|
|
f68d0bc26d | ||
|
|
c59c1e28e2 | ||
|
|
bfa94830a7 |
33
.env.example
33
.env.example
@@ -20,6 +20,24 @@ DEBUG=false
|
||||
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Per-module log level overrides (optional)
|
||||
# These override LOG_LEVEL for specific modules.
|
||||
# LOG_LEVEL_GAME=DEBUG # Core game logic
|
||||
# LOG_LEVEL_AI=DEBUG # AI decisions (very verbose at DEBUG)
|
||||
# LOG_LEVEL_HANDLERS=DEBUG # WebSocket message handlers
|
||||
# LOG_LEVEL_ROOM=DEBUG # Room/lobby management
|
||||
# LOG_LEVEL_AUTH=DEBUG # Auth stack (auth, routers.auth, services.auth_service)
|
||||
# LOG_LEVEL_STORES=DEBUG # Database/Redis operations
|
||||
|
||||
# --- Preset examples ---
|
||||
# Staging (debug game logic, quiet everything else):
|
||||
# LOG_LEVEL=INFO
|
||||
# LOG_LEVEL_GAME=DEBUG
|
||||
# LOG_LEVEL_AI=DEBUG
|
||||
#
|
||||
# Production (minimal logging):
|
||||
# LOG_LEVEL=WARNING
|
||||
|
||||
# Environment name (development, staging, production)
|
||||
ENVIRONMENT=development
|
||||
|
||||
@@ -55,7 +73,12 @@ ROOM_CODE_LENGTH=4
|
||||
SECRET_KEY=
|
||||
|
||||
# Enable invite-only mode (requires invitation to register)
|
||||
INVITE_ONLY=false
|
||||
INVITE_ONLY=true
|
||||
|
||||
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
|
||||
# Remove these after first login!
|
||||
# BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
# BOOTSTRAP_ADMIN_PASSWORD=changeme12345
|
||||
|
||||
# Comma-separated list of admin email addresses
|
||||
ADMIN_EMAILS=
|
||||
@@ -104,5 +127,13 @@ CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing enabled
|
||||
# Enable rate limiting (recommended for production)
|
||||
# RATE_LIMIT_ENABLED=true
|
||||
|
||||
# Redis URL (required for matchmaking and rate limiting)
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Base URL for email links
|
||||
# BASE_URL=https://your-domain.com
|
||||
|
||||
# Matchmaking (skill-based public games)
|
||||
MATCHMAKING_ENABLED=true
|
||||
MATCHMAKING_MIN_PLAYERS=2
|
||||
MATCHMAKING_MAX_PLAYERS=4
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -201,6 +201,9 @@ pyvenv.cfg
|
||||
# Personal notes
|
||||
lookfah.md
|
||||
|
||||
# Internal docs (deployment info, credentials references, etc.)
|
||||
internal/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
|
||||
@@ -33,5 +33,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run with uvicorn
|
||||
CMD ["python", "-m", "uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# Run with uvicorn from the server directory (server uses relative imports)
|
||||
WORKDIR /app/server
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -317,7 +317,7 @@ async function loadUsers() {
|
||||
<td>${user.games_played} (${user.games_won} wins)</td>
|
||||
<td>${formatDateShort(user.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-small" onclick="viewUser('${user.id}')">View</button>
|
||||
<button class="btn btn-small" data-action="view-user" data-id="${user.id}">View</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -404,7 +404,7 @@ async function loadGames() {
|
||||
<td><span class="badge badge-${game.status === 'playing' ? 'success' : 'info'}">${game.status}</span></td>
|
||||
<td>${formatDate(game.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-small btn-danger" onclick="promptEndGame('${game.game_id}')">End</button>
|
||||
<button class="btn btn-small btn-danger" data-action="end-game" data-id="${game.game_id}">End</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -454,7 +454,8 @@ async function loadInvites() {
|
||||
<td>${status}</td>
|
||||
<td>
|
||||
${invite.is_active && !isExpired && invite.remaining_uses > 0
|
||||
? `<button class="btn btn-small btn-danger" onclick="promptRevokeInvite('${invite.code}')">Revoke</button>`
|
||||
? `<button class="btn btn-small" data-action="copy-invite" data-code="${escapeHtml(invite.code)}">Copy Link</button>
|
||||
<button class="btn btn-small btn-danger" data-action="revoke-invite" data-code="${escapeHtml(invite.code)}">Revoke</button>`
|
||||
: '-'
|
||||
}
|
||||
</td>
|
||||
@@ -619,6 +620,16 @@ async function handleCreateInvite() {
|
||||
}
|
||||
}
|
||||
|
||||
function copyInviteLink(code) {
|
||||
const link = `${window.location.origin}/?invite=${encodeURIComponent(code)}`;
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
showToast('Invite link copied!', 'success');
|
||||
}).catch(() => {
|
||||
// Fallback: select text for manual copy
|
||||
prompt('Copy this link:', link);
|
||||
});
|
||||
}
|
||||
|
||||
async function promptRevokeInvite(code) {
|
||||
if (!confirm(`Are you sure you want to revoke invite code ${code}?`)) return;
|
||||
|
||||
@@ -804,6 +815,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Delegated click handlers for dynamically-created buttons
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
if (action === 'view-user') viewUser(btn.dataset.id);
|
||||
else if (action === 'end-game') promptEndGame(btn.dataset.id);
|
||||
else if (action === 'copy-invite') copyInviteLink(btn.dataset.code);
|
||||
else if (action === 'revoke-invite') promptRevokeInvite(btn.dataset.code);
|
||||
});
|
||||
|
||||
// Check auth on load
|
||||
checkAuth();
|
||||
});
|
||||
|
||||
924
client/app.js
924
client/app.js
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,8 @@ class CardAnimations {
|
||||
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
|
||||
const cardWidth = deckRect.width;
|
||||
const cardHeight = deckRect.height;
|
||||
const overlapOffset = cardHeight * 0.35;
|
||||
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
|
||||
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
|
||||
|
||||
return {
|
||||
left: centerX - cardWidth / 2,
|
||||
@@ -75,6 +76,13 @@ class CardAnimations {
|
||||
return easings[type] || 'easeOutQuad';
|
||||
}
|
||||
|
||||
// Font size proportional to card width — consistent across all card types.
|
||||
// Mobile uses a tighter ratio since cards are smaller and closer together.
|
||||
cardFontSize(width) {
|
||||
const ratio = document.body.classList.contains('mobile-portrait') ? 0.35 : 0.5;
|
||||
return (width * ratio) + 'px';
|
||||
}
|
||||
|
||||
// Create animated card element with 3D flip structure
|
||||
createAnimCard(rect, showBack = false, deckColor = null) {
|
||||
const card = document.createElement('div');
|
||||
@@ -92,6 +100,9 @@ class CardAnimations {
|
||||
card.style.top = rect.top + 'px';
|
||||
card.style.width = rect.width + 'px';
|
||||
card.style.height = rect.height + 'px';
|
||||
// Scale font-size proportionally to card width
|
||||
const front = card.querySelector('.draw-anim-front');
|
||||
if (front) front.style.fontSize = this.cardFontSize(rect.width);
|
||||
}
|
||||
|
||||
// Apply deck color to back
|
||||
@@ -145,12 +156,20 @@ class CardAnimations {
|
||||
}
|
||||
this.activeAnimations.clear();
|
||||
|
||||
// Remove all animation card elements (including those marked as animating)
|
||||
document.querySelectorAll('.draw-anim-card').forEach(el => {
|
||||
// Remove all animation overlay elements
|
||||
document.querySelectorAll('.draw-anim-card, .traveling-card, .deal-anim-container').forEach(el => {
|
||||
delete el.dataset.animating;
|
||||
el.remove();
|
||||
});
|
||||
|
||||
// Restore visibility on any cards hidden during animations
|
||||
document.querySelectorAll('.card[style*="opacity: 0"], .card[style*="opacity:0"]').forEach(el => {
|
||||
el.style.opacity = '';
|
||||
});
|
||||
document.querySelectorAll('.card[style*="visibility: hidden"], .card[style*="visibility:hidden"]').forEach(el => {
|
||||
el.style.visibility = '';
|
||||
});
|
||||
|
||||
// Restore discard pile visibility if it was hidden during animation
|
||||
const discardPile = document.getElementById('discard');
|
||||
if (discardPile && discardPile.style.opacity === '0') {
|
||||
@@ -201,6 +220,7 @@ class CardAnimations {
|
||||
}
|
||||
|
||||
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
|
||||
console.log('[DEBUG] _animateDrawDeckCard called with cardData:', cardData ? `${cardData.rank} of ${cardData.suit}` : 'NULL');
|
||||
const deckColor = this.getDeckColor();
|
||||
const animCard = this.createAnimCard(deckRect, true, deckColor);
|
||||
animCard.dataset.animating = 'true'; // Mark as actively animating
|
||||
@@ -209,6 +229,9 @@ class CardAnimations {
|
||||
|
||||
if (cardData) {
|
||||
this.setCardContent(animCard, cardData);
|
||||
// Debug: verify what was actually set on the front face
|
||||
const front = animCard.querySelector('.draw-anim-front');
|
||||
console.log('[DEBUG] Draw anim card front content:', front?.innerHTML);
|
||||
}
|
||||
|
||||
this.playSound('draw-deck');
|
||||
@@ -397,6 +420,7 @@ class CardAnimations {
|
||||
}
|
||||
|
||||
// Animate initial flip at game start - smooth flip only, no lift
|
||||
// Uses overlay sized to match the source card exactly
|
||||
animateInitialFlip(cardElement, cardData, onComplete) {
|
||||
if (!cardElement) {
|
||||
if (onComplete) onComplete();
|
||||
@@ -410,8 +434,16 @@ class CardAnimations {
|
||||
const animCard = this.createAnimCard(rect, true, deckColor);
|
||||
this.setCardContent(animCard, cardData);
|
||||
|
||||
// Hide original card during animation
|
||||
cardElement.style.opacity = '0';
|
||||
// Match the front face styling to player hand cards (not deck/discard cards)
|
||||
const front = animCard.querySelector('.draw-anim-front');
|
||||
if (front) {
|
||||
front.style.background = 'linear-gradient(145deg, #fff 0%, #f5f5f5 100%)';
|
||||
front.style.border = '2px solid #ddd';
|
||||
front.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.3)';
|
||||
}
|
||||
|
||||
// Hide original card during animation (overlay covers it)
|
||||
cardElement.style.visibility = 'hidden';
|
||||
|
||||
const inner = animCard.querySelector('.draw-anim-inner');
|
||||
const duration = window.TIMING?.card?.flip || 320;
|
||||
@@ -426,7 +458,7 @@ class CardAnimations {
|
||||
begin: () => this.playSound('flip'),
|
||||
complete: () => {
|
||||
animCard.remove();
|
||||
cardElement.style.opacity = '1';
|
||||
cardElement.style.visibility = '';
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
});
|
||||
@@ -435,7 +467,7 @@ class CardAnimations {
|
||||
} catch (e) {
|
||||
console.error('Initial flip animation error:', e);
|
||||
animCard.remove();
|
||||
cardElement.style.opacity = '1';
|
||||
cardElement.style.visibility = '';
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
}
|
||||
@@ -448,10 +480,6 @@ class CardAnimations {
|
||||
const deckColor = this.getDeckColor();
|
||||
|
||||
const animCard = this.createAnimCard(rect, true, deckColor);
|
||||
// Match source card's font-size (opponent cards are smaller than default)
|
||||
const srcFontSize = getComputedStyle(cardElement).fontSize;
|
||||
const front = animCard.querySelector('.draw-anim-front');
|
||||
if (front) front.style.fontSize = srcFontSize;
|
||||
this.setCardContent(animCard, cardData);
|
||||
|
||||
// Apply rotation to match arch layout
|
||||
@@ -607,10 +635,6 @@ class CardAnimations {
|
||||
const deckColor = this.getDeckColor();
|
||||
|
||||
const animCard = this.createAnimCard(rect, true, deckColor);
|
||||
// Match source card's font-size (opponent cards are smaller than default)
|
||||
const srcFontSize = getComputedStyle(sourceCardElement).fontSize;
|
||||
const front = animCard.querySelector('.draw-anim-front');
|
||||
if (front) front.style.fontSize = srcFontSize;
|
||||
this.setCardContent(animCard, discardCard);
|
||||
|
||||
if (rotation) {
|
||||
@@ -748,28 +772,36 @@ class CardAnimations {
|
||||
const id = 'turnPulse';
|
||||
this.stopTurnPulse(element);
|
||||
|
||||
// Quick shake animation
|
||||
// Quick shake animation - target cards only, not labels
|
||||
const T = window.TIMING?.turnPulse || {};
|
||||
const cards = element.querySelectorAll(':scope > .pile-wrapper > .card, :scope > .pile-wrapper > .discard-stack > #discard');
|
||||
const doShake = () => {
|
||||
if (!this.activeAnimations.has(id)) return;
|
||||
|
||||
anime({
|
||||
targets: element,
|
||||
translateX: [0, -8, 8, -6, 4, 0],
|
||||
duration: 400,
|
||||
targets: cards.length ? cards : element,
|
||||
translateX: [0, -6, 6, -4, 3, 0],
|
||||
duration: T.duration || 300,
|
||||
easing: 'easeInOutQuad'
|
||||
});
|
||||
};
|
||||
|
||||
// Do initial shake, then repeat every 3 seconds
|
||||
// Delay first shake, then repeat at interval
|
||||
const timeout = setTimeout(() => {
|
||||
if (!this.activeAnimations.has(id)) return;
|
||||
doShake();
|
||||
const interval = setInterval(doShake, 3000);
|
||||
this.activeAnimations.set(id, { interval });
|
||||
const interval = setInterval(doShake, T.interval || 3000);
|
||||
const entry = this.activeAnimations.get(id);
|
||||
if (entry) entry.interval = interval;
|
||||
}, T.initialDelay || 5000);
|
||||
this.activeAnimations.set(id, { timeout });
|
||||
}
|
||||
|
||||
stopTurnPulse(element) {
|
||||
const id = 'turnPulse';
|
||||
const existing = this.activeAnimations.get(id);
|
||||
if (existing) {
|
||||
if (existing.timeout) clearTimeout(existing.timeout);
|
||||
if (existing.interval) clearInterval(existing.interval);
|
||||
if (existing.pause) existing.pause();
|
||||
this.activeAnimations.delete(id);
|
||||
@@ -1095,7 +1127,7 @@ class CardAnimations {
|
||||
});
|
||||
// Now run the swap animation
|
||||
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
|
||||
}, 100);
|
||||
}, 350);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1164,6 +1196,9 @@ class CardAnimations {
|
||||
});
|
||||
|
||||
// Hand card arcs to discard (apply counter-rotation to land flat)
|
||||
const handFront = travelingHand.querySelector('.draw-anim-front');
|
||||
const heldFront = travelingHeld.querySelector('.draw-anim-front');
|
||||
|
||||
timeline.add({
|
||||
targets: travelingHand,
|
||||
left: discardRect.left,
|
||||
@@ -1178,6 +1213,16 @@ class CardAnimations {
|
||||
easing: this.getEasing('arc'),
|
||||
}, `-=${T.lift / 2}`);
|
||||
|
||||
// Scale hand card font to match discard size
|
||||
if (handFront) {
|
||||
timeline.add({
|
||||
targets: handFront,
|
||||
fontSize: this.cardFontSize(discardRect.width),
|
||||
duration: T.arc,
|
||||
easing: this.getEasing('arc'),
|
||||
}, `-=${T.arc}`);
|
||||
}
|
||||
|
||||
// Held card arcs to hand slot (apply rotation to match hand position)
|
||||
timeline.add({
|
||||
targets: travelingHeld,
|
||||
@@ -1193,6 +1238,16 @@ class CardAnimations {
|
||||
easing: this.getEasing('arc'),
|
||||
}, `-=${T.arc + T.lift / 2}`);
|
||||
|
||||
// Scale held card font to match hand size
|
||||
if (heldFront) {
|
||||
timeline.add({
|
||||
targets: heldFront,
|
||||
fontSize: this.cardFontSize(handRect.width),
|
||||
duration: T.arc,
|
||||
easing: this.getEasing('arc'),
|
||||
}, `-=${T.arc}`);
|
||||
}
|
||||
|
||||
// Settle with gentle overshoot
|
||||
timeline.add({
|
||||
targets: [travelingHand, travelingHeld],
|
||||
@@ -1404,6 +1459,9 @@ class CardAnimations {
|
||||
card.style.top = rect.top + 'px';
|
||||
card.style.width = rect.width + 'px';
|
||||
card.style.height = rect.height + 'px';
|
||||
// Scale font-size proportionally to card width
|
||||
const front = card.querySelector('.draw-anim-front');
|
||||
if (front) front.style.fontSize = this.cardFontSize(rect.width);
|
||||
|
||||
if (rotation) {
|
||||
card.style.transform = `rotate(${rotation}deg)`;
|
||||
@@ -1444,9 +1502,8 @@ class CardAnimations {
|
||||
try {
|
||||
anime({
|
||||
targets: element,
|
||||
scale: [0.5, 1.25, 1.15],
|
||||
opacity: [0, 1, 1],
|
||||
duration: 300,
|
||||
opacity: [0, 1],
|
||||
duration: 200,
|
||||
easing: 'easeOutQuad'
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -1488,6 +1545,7 @@ class CardAnimations {
|
||||
|
||||
// Create container for animation cards
|
||||
const container = document.createElement('div');
|
||||
container.className = 'deal-anim-container';
|
||||
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
|
||||
document.body.appendChild(container);
|
||||
|
||||
|
||||
@@ -126,6 +126,13 @@ class CardManager {
|
||||
cardEl.style.width = `${rect.width}px`;
|
||||
cardEl.style.height = `${rect.height}px`;
|
||||
|
||||
// On mobile, scale font proportional to card width so rank/suit fit
|
||||
if (document.body.classList.contains('mobile-portrait')) {
|
||||
cardEl.style.fontSize = `${rect.width * 0.35}px`;
|
||||
} else {
|
||||
cardEl.style.fontSize = '';
|
||||
}
|
||||
|
||||
if (animate) {
|
||||
const moveDuration = window.TIMING?.card?.moving || 350;
|
||||
setTimeout(() => cardEl.classList.remove('moving'), moveDuration);
|
||||
|
||||
@@ -59,9 +59,9 @@
|
||||
<!-- Outer edge highlight -->
|
||||
<circle cx="50" cy="44" r="46" fill="none" stroke="#ffffff" stroke-width="0.5" opacity="0.5"/>
|
||||
|
||||
<!-- Card suits - single row, larger -->
|
||||
<text x="22" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♣</text>
|
||||
<text x="41" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">♦</text>
|
||||
<text x="59" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♠</text>
|
||||
<text x="77" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">♥</text>
|
||||
<!-- Card suits - 2x2 grid -->
|
||||
<text x="36" y="40" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♣</text>
|
||||
<text x="64" y="40" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#cc0000" text-anchor="middle">♦</text>
|
||||
<text x="36" y="64" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#cc0000" text-anchor="middle">♥</text>
|
||||
<text x="64" y="64" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♠</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Golf Card Game</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
@@ -16,36 +16,57 @@
|
||||
|
||||
<!-- Lobby Screen -->
|
||||
<div id="lobby-screen" class="screen active">
|
||||
<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><span class="logo-row"><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-container"><span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span></span></span> <span class="golf-title">GolfCards<span class="golf-title-tld">.club</span></span></h1>
|
||||
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
|
||||
|
||||
<!-- Auth buttons for guests (hidden until auth check confirms not logged in) -->
|
||||
<div id="auth-buttons" class="auth-buttons hidden">
|
||||
<button id="login-btn" class="btn btn-small">Login</button>
|
||||
<button id="signup-btn" class="btn btn-small btn-primary">Sign Up</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="player-name">Your Name</label>
|
||||
<input type="text" id="player-name" placeholder="Enter your name" maxlength="12">
|
||||
</div>
|
||||
<div class="alpha-banner">Beta Testing - Bear with us while, stuff.</div>
|
||||
|
||||
<!-- Auth prompt for unauthenticated users -->
|
||||
<div id="auth-prompt" class="auth-prompt">
|
||||
<p>Log in or sign up to play.</p>
|
||||
<div class="button-group">
|
||||
<button id="create-room-btn" class="btn btn-primary">Create Room</button>
|
||||
<button id="login-btn" class="btn btn-primary">Login</button>
|
||||
<button id="signup-btn" class="btn btn-secondary">Sign Up</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Game controls (shown only when authenticated) -->
|
||||
<div id="lobby-game-controls" class="hidden">
|
||||
<div class="button-group">
|
||||
<button id="find-game-btn" class="btn btn-primary">Find Game</button>
|
||||
</div>
|
||||
|
||||
<div class="divider">or</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="create-room-btn" class="btn btn-secondary">Create Private Room</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="room-code">Room Code</label>
|
||||
<label for="room-code">Join Private Room</label>
|
||||
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="lobby-error" class="error"></p>
|
||||
|
||||
<footer class="app-footer">v3.1.6 © Aaron D. Lee</footer>
|
||||
</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 -->
|
||||
@@ -61,16 +82,16 @@
|
||||
<div class="waiting-layout">
|
||||
<div class="waiting-left-col">
|
||||
<div class="players-list">
|
||||
<div class="players-list-header">
|
||||
<h3>Players</h3>
|
||||
<div id="cpu-controls-section" class="cpu-controls hidden">
|
||||
<span class="cpu-controls-label">CPU:</span>
|
||||
<button id="remove-cpu-btn" class="cpu-ctrl-btn btn-danger" title="Remove CPU">−</button>
|
||||
<button id="add-cpu-btn" class="cpu-ctrl-btn btn-success" title="Add CPU">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul id="players-list"></ul>
|
||||
</div>
|
||||
<div id="cpu-controls-section" class="cpu-controls-section hidden">
|
||||
<h4>Add CPU Opponents</h4>
|
||||
<div class="cpu-controls">
|
||||
<button id="remove-cpu-btn" class="btn btn-small btn-danger" title="Remove last CPU">−</button>
|
||||
<button id="add-cpu-btn" class="btn btn-small btn-success" title="Add CPU player">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
|
||||
</div>
|
||||
|
||||
@@ -265,6 +286,8 @@
|
||||
|
||||
<p id="waiting-message" class="info">Waiting for host to start the game...</p>
|
||||
</div>
|
||||
|
||||
<footer class="app-footer">v3.1.6 © Aaron D. Lee</footer>
|
||||
</div>
|
||||
|
||||
<!-- Game Screen -->
|
||||
@@ -286,7 +309,6 @@
|
||||
<div id="final-turn-badge" class="final-turn-badge hidden">
|
||||
<span class="final-turn-icon">⚡</span>
|
||||
<span class="final-turn-text">FINAL TURN</span>
|
||||
<span class="final-turn-remaining"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-col header-col-right">
|
||||
@@ -310,7 +332,12 @@
|
||||
</div>
|
||||
<span class="held-label">Holding</span>
|
||||
</div>
|
||||
<div class="pile-wrapper">
|
||||
<span class="pile-label">DRAW</span>
|
||||
<div id="deck" class="card card-back"></div>
|
||||
</div>
|
||||
<div class="pile-wrapper">
|
||||
<span class="pile-label">DISCARD</span>
|
||||
<div class="discard-stack">
|
||||
<div id="discard" class="card">
|
||||
<span id="discard-content"></span>
|
||||
@@ -325,6 +352,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="player-section">
|
||||
<div class="player-area">
|
||||
@@ -380,15 +408,32 @@
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Mobile bottom bar (hidden on desktop) -->
|
||||
<div id="mobile-bottom-bar">
|
||||
<div class="mobile-round-info">Hole <span id="mobile-current-round">1</span>/<span id="mobile-total-rounds">9</span></div>
|
||||
<button class="mobile-bar-btn mobile-rules-btn" id="mobile-rules-btn" data-drawer="rules-drawer"><span id="mobile-rules-icon">RULES</span></button>
|
||||
<button class="mobile-bar-btn" data-drawer="standings-panel">Scorecard</button>
|
||||
<button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile rules drawer -->
|
||||
<div id="rules-drawer" class="side-panel rules-drawer-panel">
|
||||
<h4>Active Rules</h4>
|
||||
<div id="mobile-rules-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Drawer backdrop for mobile -->
|
||||
<div id="drawer-backdrop" class="drawer-backdrop"></div>
|
||||
</div>
|
||||
|
||||
<!-- Rules Screen -->
|
||||
<div id="rules-screen" class="screen">
|
||||
<div class="rules-container">
|
||||
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
|
||||
|
||||
<div class="rules-header">
|
||||
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
|
||||
<h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1>
|
||||
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
|
||||
</div>
|
||||
@@ -704,9 +749,8 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
||||
<!-- Leaderboard Screen -->
|
||||
<div id="leaderboard-screen" class="screen">
|
||||
<div class="leaderboard-container">
|
||||
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">« Back</button>
|
||||
|
||||
<div class="leaderboard-header">
|
||||
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">« Back</button>
|
||||
<h1>Leaderboard</h1>
|
||||
<p class="leaderboard-subtitle">Top players ranked by performance</p>
|
||||
</div>
|
||||
@@ -717,6 +761,7 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
||||
<button class="leaderboard-tab" data-metric="avg_score">Avg Score</button>
|
||||
<button class="leaderboard-tab" data-metric="knockouts">Knockouts</button>
|
||||
<button class="leaderboard-tab" data-metric="streak">Best Streak</button>
|
||||
<button class="leaderboard-tab" data-metric="rating">Rating</button>
|
||||
</div>
|
||||
|
||||
<div id="leaderboard-content">
|
||||
@@ -810,12 +855,47 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
||||
<button type="submit" class="btn btn-primary btn-full">Login</button>
|
||||
</form>
|
||||
<p class="auth-switch">Don't have an account? <a href="#" id="show-signup">Sign up</a></p>
|
||||
<p class="auth-switch"><a href="#" id="show-forgot">Forgot password?</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<div id="forgot-form-container" class="hidden">
|
||||
<h3>Reset Password</h3>
|
||||
<p class="auth-hint">Enter your email and we'll send you a reset link.</p>
|
||||
<form id="forgot-form">
|
||||
<div class="form-group">
|
||||
<input type="email" id="forgot-email" placeholder="Email" required>
|
||||
</div>
|
||||
<p id="forgot-error" class="error"></p>
|
||||
<p id="forgot-success" class="success"></p>
|
||||
<button type="submit" class="btn btn-primary btn-full">Send Reset Link</button>
|
||||
</form>
|
||||
<p class="auth-switch"><a href="#" id="forgot-back-login">Back to login</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Reset Password Form (from email link) -->
|
||||
<div id="reset-form-container" class="hidden">
|
||||
<h3>Set New Password</h3>
|
||||
<form id="reset-form">
|
||||
<div class="form-group">
|
||||
<input type="password" id="reset-password" placeholder="New password" required minlength="8">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" id="reset-password-confirm" placeholder="Confirm password" required minlength="8">
|
||||
</div>
|
||||
<p id="reset-error" class="error"></p>
|
||||
<p id="reset-success" class="success"></p>
|
||||
<button type="submit" class="btn btn-primary btn-full">Reset Password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Signup Form -->
|
||||
<div id="signup-form-container" class="hidden">
|
||||
<h3>Sign Up</h3>
|
||||
<form id="signup-form">
|
||||
<div class="form-group">
|
||||
<input type="text" id="signup-invite-code" placeholder="Invite Code" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ class LeaderboardComponent {
|
||||
avg_score: 'Avg Score',
|
||||
knockouts: 'Knockouts',
|
||||
streak: 'Best Streak',
|
||||
rating: 'Rating',
|
||||
};
|
||||
|
||||
this.metricFormats = {
|
||||
@@ -34,6 +35,7 @@ class LeaderboardComponent {
|
||||
avg_score: (v) => v.toFixed(1),
|
||||
knockouts: (v) => v.toLocaleString(),
|
||||
streak: (v) => v.toLocaleString(),
|
||||
rating: (v) => Math.round(v).toLocaleString(),
|
||||
};
|
||||
|
||||
this.init();
|
||||
|
||||
1439
client/style.css
1439
client/style.css
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,7 @@ const TIMING = {
|
||||
|
||||
// V3_03: Round end reveal timing
|
||||
reveal: {
|
||||
lastPlayPause: 2000, // Pause after last play animation before reveals
|
||||
voluntaryWindow: 2000, // Time for players to flip their own cards
|
||||
initialPause: 250, // Pause before auto-reveals start
|
||||
cardStagger: 50, // Between cards in same hand
|
||||
@@ -128,6 +129,25 @@ const TIMING = {
|
||||
pulseDelay: 200, // Delay before card appears (pulse visible first)
|
||||
},
|
||||
|
||||
// Turn pulse (deck shake)
|
||||
turnPulse: {
|
||||
initialDelay: 5000, // Delay before first shake
|
||||
interval: 5400, // Time between shakes
|
||||
duration: 300, // Shake animation duration
|
||||
},
|
||||
|
||||
// V3_17: Knock notification
|
||||
knock: {
|
||||
statusDuration: 2500, // How long the knock status message persists
|
||||
},
|
||||
|
||||
// V3_17: Scoresheet modal
|
||||
scoresheet: {
|
||||
playerStagger: 150, // Delay between player row animations
|
||||
columnStagger: 80, // Delay between column animations within a row
|
||||
pairGlowDelay: 200, // Delay before paired columns glow
|
||||
},
|
||||
|
||||
// Player swap animation steps - smooth continuous motion
|
||||
playerSwap: {
|
||||
flipToReveal: 400, // Initial flip to show card
|
||||
|
||||
@@ -22,38 +22,60 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
||||
- DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
|
||||
- SENTRY_DSN=${SENTRY_DSN:-}
|
||||
- ENVIRONMENT=production
|
||||
- LOG_LEVEL=INFO
|
||||
- ENVIRONMENT=${ENVIRONMENT:-production}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- LOG_LEVEL_GAME=${LOG_LEVEL_GAME:-}
|
||||
- LOG_LEVEL_AI=${LOG_LEVEL_AI:-}
|
||||
- LOG_LEVEL_HANDLERS=${LOG_LEVEL_HANDLERS:-}
|
||||
- LOG_LEVEL_ROOM=${LOG_LEVEL_ROOM:-}
|
||||
- LOG_LEVEL_AUTH=${LOG_LEVEL_AUTH:-}
|
||||
- LOG_LEVEL_STORES=${LOG_LEVEL_STORES:-}
|
||||
- BASE_URL=${BASE_URL:-https://golf.example.com}
|
||||
- RATE_LIMIT_ENABLED=true
|
||||
- INVITE_ONLY=true
|
||||
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
|
||||
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||
- MATCHMAKING_ENABLED=true
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
replicas: 2
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 256M
|
||||
reservations:
|
||||
memory: 64M
|
||||
networks:
|
||||
- internal
|
||||
- web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=golfgame_web"
|
||||
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)"
|
||||
- "traefik.http.routers.golf.entrypoints=websecure"
|
||||
- "traefik.http.routers.golf.tls=true"
|
||||
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
|
||||
# www -> bare domain redirect
|
||||
- "traefik.http.routers.golf-www.rule=Host(`www.${DOMAIN:-golf.example.com}`)"
|
||||
- "traefik.http.routers.golf-www.entrypoints=websecure"
|
||||
- "traefik.http.routers.golf-www.tls=true"
|
||||
- "traefik.http.routers.golf-www.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.golf-www.middlewares=www-redirect"
|
||||
- "traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\\.(.+)"
|
||||
- "traefik.http.middlewares.www-redirect.redirectregex.replacement=https://$${1}"
|
||||
- "traefik.http.middlewares.www-redirect.redirectregex.permanent=true"
|
||||
- "traefik.http.services.golf.loadbalancer.server.port=8000"
|
||||
# WebSocket sticky sessions
|
||||
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"
|
||||
@@ -77,13 +99,13 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
memory: 192M
|
||||
reservations:
|
||||
memory: 256M
|
||||
memory: 64M
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
|
||||
command: redis-server --appendonly yes --maxmemory 32mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
@@ -96,14 +118,19 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 192M
|
||||
reservations:
|
||||
memory: 64M
|
||||
reservations:
|
||||
memory: 16M
|
||||
|
||||
traefik:
|
||||
image: traefik:v2.10
|
||||
image: traefik:v3.6
|
||||
environment:
|
||||
- DOCKER_API_VERSION=1.44
|
||||
command:
|
||||
- "--api.dashboard=true"
|
||||
- "--api.insecure=true"
|
||||
- "--accesslog=true"
|
||||
- "--log.level=WARN"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.web.address=:80"
|
||||
@@ -125,7 +152,7 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
memory: 64M
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
146
docker-compose.staging.yml
Normal file
146
docker-compose.staging.yml
Normal file
@@ -0,0 +1,146 @@
|
||||
# Staging Docker Compose for Golf Card Game
|
||||
#
|
||||
# Mirrors production but with reduced memory limits for 512MB droplet.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.staging.yml up -d --build
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
||||
- DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
|
||||
- SENTRY_DSN=${SENTRY_DSN:-}
|
||||
- ENVIRONMENT=${ENVIRONMENT:-staging}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- LOG_LEVEL_GAME=${LOG_LEVEL_GAME:-}
|
||||
- LOG_LEVEL_AI=${LOG_LEVEL_AI:-}
|
||||
- LOG_LEVEL_HANDLERS=${LOG_LEVEL_HANDLERS:-}
|
||||
- LOG_LEVEL_ROOM=${LOG_LEVEL_ROOM:-}
|
||||
- LOG_LEVEL_AUTH=${LOG_LEVEL_AUTH:-}
|
||||
- LOG_LEVEL_STORES=${LOG_LEVEL_STORES:-}
|
||||
- BASE_URL=${BASE_URL:-https://staging.golfcards.club}
|
||||
- RATE_LIMIT_ENABLED=false
|
||||
- INVITE_ONLY=true
|
||||
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
|
||||
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||
- MATCHMAKING_ENABLED=true
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
reservations:
|
||||
memory: 48M
|
||||
networks:
|
||||
- internal
|
||||
- web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=golfgame_web"
|
||||
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-staging.golfcards.club}`)"
|
||||
- "traefik.http.routers.golf.entrypoints=websecure"
|
||||
- "traefik.http.routers.golf.tls=true"
|
||||
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.golf.loadbalancer.server.port=8000"
|
||||
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"
|
||||
- "traefik.http.services.golf.loadbalancer.sticky.cookie.name=golf_server"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: golf
|
||||
POSTGRES_USER: golf
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U golf -d golf"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 96M
|
||||
reservations:
|
||||
memory: 48M
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --appendonly yes --maxmemory 16mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 32M
|
||||
reservations:
|
||||
memory: 16M
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.6
|
||||
environment:
|
||||
- DOCKER_API_VERSION=1.44
|
||||
command:
|
||||
- "--api.dashboard=true"
|
||||
- "--api.insecure=true"
|
||||
- "--accesslog=true"
|
||||
- "--log.level=WARN"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
|
||||
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
|
||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
|
||||
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
|
||||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- web
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 48M
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
letsencrypt:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
web:
|
||||
driver: bridge
|
||||
77
docs/BUG-kicked-ball-position.md
Normal file
77
docs/BUG-kicked-ball-position.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# BUG: Kicked ball animation starts from golfer's back foot
|
||||
|
||||
## Problem
|
||||
|
||||
The `⚪` kicked ball animation (`.kicked-ball`) appears to launch from the golfer's **back foot** (left side) instead of the **front foot** (right side). The golfer faces right in both landscape (two-row) and mobile (single-line) views due to `scaleX(-1)`.
|
||||
|
||||
## What we want
|
||||
|
||||
The ball should appear at the golfer's front foot (right side) and arc up and to the right — matching the "good" landscape behavior seen at wide desktop widths (~1100px+).
|
||||
|
||||
## Good reference
|
||||
|
||||
- Video: `good.mp4` (landscape wide view)
|
||||
- Extracted frames: `/tmp/golf-frames-good/`
|
||||
- Frame 025: Ball clearly appears to the RIGHT of the golfer, arcing up-right
|
||||
|
||||
## Bad behavior
|
||||
|
||||
- Videos: `Screencast_20260224_005555.mp4`, `Screencast_20260224_013326.mp4`
|
||||
- The ball appears to the LEFT of the golfer (between the golf ball logo and golfer emoji)
|
||||
- Happens at the user's phone viewport width (two-row layout, inline-grid)
|
||||
|
||||
## Root cause analysis
|
||||
|
||||
### The scaleX(-1) offset problem
|
||||
|
||||
The golfer emoji (`.golfer-swing`) has `transform: scaleX(-1)` which flips it visually. This means:
|
||||
- The golfer's **layout box** occupies the same inline flow position
|
||||
- But the **visual** left/right is flipped — the front foot (visually on the right) is at the LEFT edge of the layout box
|
||||
- The `.kicked-ball` span comes right after `.golfer-swing` in inline flow, so its natural position is at the **right edge** of the golfer's layout box
|
||||
- But due to `scaleX(-1)`, the right edge of the layout box is the golfer's **visual back** (left side)
|
||||
- So `translate(0, 0)` places the ball at the golfer's back, not front
|
||||
|
||||
### CSS translate values tested
|
||||
|
||||
| Start X | Result |
|
||||
|---------|--------|
|
||||
| `-30px` (original) | Ball appears way behind golfer (further left) |
|
||||
| `+20px` | Ball still appears to LEFT of golfer, but slightly closer |
|
||||
| `+80px` | Not confirmed (staging 404 during test) |
|
||||
|
||||
### Key finding: The kicked-ball's natural position needs ~60-80px positive X offset to reach the golfer's visual front foot
|
||||
|
||||
The golfer emoji is roughly 30-40px wide at this viewport. Since `scaleX(-1)` flips the visual, the ball needs to translate **past the entire emoji width** to reach the visual front.
|
||||
|
||||
### Media query issues encountered
|
||||
|
||||
1. First attempt: Added `ball-kicked-mobile` keyframes with `@media (max-width: 500px)` override
|
||||
2. **CSS source order bug**: The mobile override at line 144 was being overridden by the base `.kicked-ball` rule at line 216 (later = higher priority at equal specificity)
|
||||
3. Moved override after base rule — still didn't work
|
||||
4. Added `!important` — still didn't work
|
||||
5. Raised breakpoint from 500px to 768px, then 1200px — still no visible change
|
||||
6. **Breakthrough**: Added `outline: 3px solid red; background: yellow` debug styles to base `.kicked-ball` — these DID appear, confirming CSS was loading
|
||||
7. Changed base `ball-kicked` keyframes from `-30px` to `+20px` — ball DID move, confirming the base keyframes are what's being used
|
||||
8. The mobile override keyframes may never have been applied (unclear if `ball-kicked-mobile` was actually used)
|
||||
|
||||
### What the Chrome extension Claude analysis said
|
||||
|
||||
> "The breakpoint is 500px, but the viewport is above 500px. At 700px+, ball-kicked-mobile never kicks in — it still uses the desktop ball-kicked animation. But the layout at this width has already shifted to a more centered layout which changes where .kicked-ball is positioned relative to the golfer."
|
||||
|
||||
## Suggested fix approach
|
||||
|
||||
1. **Don't use separate mobile keyframes** — just fix the base `ball-kicked` to work at all viewport widths
|
||||
2. The starting X needs to be **much larger positive** (60-80px) to account for `scaleX(-1)` placing the natural position at the golfer's visual back
|
||||
3. Alternatively, restructure the HTML: move `.kicked-ball` BEFORE `.golfer-swing` in the DOM, so its natural inline position is at the golfer's visual front (since scaleX(-1) flips left/right)
|
||||
4. Or use `position: absolute` on `.kicked-ball` and position it relative to the golfer container explicitly
|
||||
|
||||
## Files involved
|
||||
|
||||
- `client/style.css` — `.kicked-ball`, `@keyframes ball-kicked`, `.golfer-swing`
|
||||
- `client/index.html` — line 19: `<span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span>`
|
||||
|
||||
## Resolution (v3.1.6)
|
||||
|
||||
**Fixed** by wrapping `.golfer-swing` + `.kicked-ball` in a `.golfer-container` span with `position: relative`, and changing `.kicked-ball` from `position: relative` to `position: absolute; right: -8px; bottom: 30%`. This anchors the ball to the golfer's front foot regardless of viewport width or inline flow layout.
|
||||
|
||||
Also fixed a **CSS source order bug** where the base `.golfer-container` rule was defined after the `@media (max-width: 500px)` override, clobbering the mobile margin-left value.
|
||||
@@ -30,6 +30,7 @@ This plan is split into independent vertical slices ordered by priority and impa
|
||||
| `V3_14_ACTIVE_RULES_CONTEXT.md` | Contextual rule highlighting | Low | Low | None |
|
||||
| `V3_15_DISCARD_PILE_HISTORY.md` | Show recent discards fanned | Low | Medium | None |
|
||||
| `V3_16_REALISTIC_CARD_SOUNDS.md` | Improved audio feedback | Nice | Medium | None |
|
||||
| `V3_17_MOBILE_PORTRAIT_LAYOUT.md` | Full mobile portrait layout + animation fixes | High | High | 02, 11 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
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.6
|
||||
**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
|
||||
57
docs/v3/V3_18_POSTGRES_STORAGE_EFFICIENCY.md
Normal file
57
docs/v3/V3_18_POSTGRES_STORAGE_EFFICIENCY.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# V3.18: PostgreSQL Game Data Storage Efficiency
|
||||
|
||||
**Status:** Planning
|
||||
**Priority:** Medium
|
||||
**Category:** Infrastructure / Performance
|
||||
|
||||
## Problem
|
||||
|
||||
Per-move game logging stores full `hand_state` and `visible_opponents` JSONB on every move. For a typical 6-player, 9-hole game this generates significant redundant data since most of each player's hand doesn't change between moves.
|
||||
|
||||
## Areas to Investigate
|
||||
|
||||
### 1. Delta Encoding for Move Data
|
||||
|
||||
Store only what changed from the previous move instead of full state snapshots.
|
||||
|
||||
- First move of each round stores full state (baseline)
|
||||
- Subsequent moves store only changed positions (e.g., `{"player_0": {"pos_2": "5H"}}`)
|
||||
- Replay reconstruction applies deltas sequentially
|
||||
- Trade-off: simpler queries vs. storage savings
|
||||
|
||||
### 2. PostgreSQL TOAST and Compression
|
||||
|
||||
- TOAST already compresses large JSONB values automatically
|
||||
- Measure actual on-disk size vs. logical size for typical game data
|
||||
- Consider whether explicit compression (e.g., storing gzipped blobs) adds meaningful savings over TOAST
|
||||
|
||||
### 3. Retention Policy
|
||||
|
||||
- Archive completed games older than N days to a separate table or cold storage
|
||||
- Configurable retention period via env var (e.g., `GAME_LOG_RETENTION_DAYS`)
|
||||
- Keep aggregate stats even after pruning raw move data
|
||||
|
||||
### 4. Move Logging Toggle
|
||||
|
||||
- Env var `GAME_LOGGING_ENABLED=true|false` to disable move-level logging entirely
|
||||
- Useful for non-analysis environments (dev, load testing)
|
||||
- Game outcomes and stats would still be recorded
|
||||
|
||||
### 5. Batch Inserts
|
||||
|
||||
- Buffer moves in memory and flush periodically instead of per-move INSERT
|
||||
- Reduces database round-trips during active games
|
||||
- Risk: data loss if server crashes mid-game (acceptable for non-critical move logs)
|
||||
|
||||
## Measurements Needed
|
||||
|
||||
Before optimizing, measure current impact:
|
||||
|
||||
- Average JSONB size per move (bytes)
|
||||
- Average moves per game
|
||||
- Total storage per game (moves + overhead)
|
||||
- Query patterns: how often is per-move data actually read?
|
||||
|
||||
## Dependencies
|
||||
|
||||
- None (independent infrastructure improvement)
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "golfgame"
|
||||
version = "2.0.1"
|
||||
version = "3.1.6"
|
||||
description = "6-Card Golf card game with AI opponents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
23
scripts/deploy-staging.sh
Executable file
23
scripts/deploy-staging.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DROPLET="root@129.212.150.189"
|
||||
REMOTE_DIR="/opt/golfgame"
|
||||
|
||||
echo "Syncing to staging ($DROPLET)..."
|
||||
rsync -az --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.env' \
|
||||
--exclude='internal/' \
|
||||
server/ "$DROPLET:$REMOTE_DIR/server/"
|
||||
rsync -az --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='node_modules' \
|
||||
client/ "$DROPLET:$REMOTE_DIR/client/"
|
||||
|
||||
echo "Rebuilding app container..."
|
||||
ssh $DROPLET "cd $REMOTE_DIR && docker compose -f docker-compose.staging.yml up -d --build app"
|
||||
echo "Staging deploy complete."
|
||||
9
scripts/deploy.sh
Executable file
9
scripts/deploy.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DROPLET="root@165.245.152.51"
|
||||
REMOTE_DIR="/opt/golfgame"
|
||||
|
||||
echo "Deploying to $DROPLET..."
|
||||
ssh $DROPLET "cd $REMOTE_DIR && git pull origin main && docker compose -f docker-compose.prod.yml up -d --build app"
|
||||
echo "Deploy complete."
|
||||
@@ -7,6 +7,24 @@ PORT=8000
|
||||
DEBUG=true
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
# Per-module log level overrides (optional)
|
||||
# These override LOG_LEVEL for specific modules.
|
||||
# LOG_LEVEL_GAME=DEBUG # Core game logic
|
||||
# LOG_LEVEL_AI=DEBUG # AI decisions (very verbose at DEBUG)
|
||||
# LOG_LEVEL_HANDLERS=DEBUG # WebSocket message handlers
|
||||
# LOG_LEVEL_ROOM=DEBUG # Room/lobby management
|
||||
# LOG_LEVEL_AUTH=DEBUG # Auth stack (auth, routers.auth, services.auth_service)
|
||||
# LOG_LEVEL_STORES=DEBUG # Database/Redis operations
|
||||
|
||||
# --- Preset examples ---
|
||||
# Staging (debug game logic, quiet everything else):
|
||||
# LOG_LEVEL=INFO
|
||||
# LOG_LEVEL_GAME=DEBUG
|
||||
# LOG_LEVEL_AI=DEBUG
|
||||
#
|
||||
# Production (minimal logging):
|
||||
# LOG_LEVEL=WARNING
|
||||
|
||||
# Environment (development, staging, production)
|
||||
# Affects logging format, security headers (HSTS), etc.
|
||||
ENVIRONMENT=development
|
||||
|
||||
63
server/ai.py
63
server/ai.py
@@ -43,8 +43,9 @@ CPU_TIMING = {
|
||||
# Delay before CPU "looks at" the discard pile
|
||||
"initial_look": (0.3, 0.5),
|
||||
# Brief pause after draw broadcast - let draw animation complete
|
||||
# Must be >= client draw animation duration (~1s for deck, ~0.4s for discard)
|
||||
"post_draw_settle": 1.1,
|
||||
# Must be >= client draw animation duration (~1.09s for deck, ~0.4s for discard)
|
||||
# Extra margin prevents swap message from arriving before draw flip completes
|
||||
"post_draw_settle": 1.3,
|
||||
# Consideration time after drawing (before swap/discard decision)
|
||||
"post_draw_consider": (0.2, 0.4),
|
||||
# Variance multiplier range for chaotic personality players
|
||||
@@ -1301,12 +1302,28 @@ class GolfAI:
|
||||
|
||||
max_acceptable_go_out = 14 + int(profile.aggression * 4)
|
||||
|
||||
# Check opponent scores - don't go out if we'd lose badly
|
||||
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
|
||||
# Aggressive players tolerate a bigger gap; conservative ones less
|
||||
opponent_margin = 4 + int(profile.aggression * 4) # 4-8 points
|
||||
opponent_cap = opponent_min + opponent_margin
|
||||
|
||||
# Use the more restrictive of the two thresholds
|
||||
effective_max = min(max_acceptable_go_out, opponent_cap)
|
||||
|
||||
ai_log(f" Go-out safety check: visible_base={visible_score}, "
|
||||
f"score_if_swap={score_if_swap}, score_if_flip={score_if_flip}, "
|
||||
f"max_acceptable={max_acceptable_go_out}")
|
||||
f"max_acceptable={max_acceptable_go_out}, opponent_min={opponent_min}, "
|
||||
f"opponent_cap={opponent_cap}, effective_max={effective_max}")
|
||||
|
||||
# High-card safety: don't swap 8+ into hidden position unless it makes a pair
|
||||
creates_pair = (last_partner.face_up and last_partner.rank == drawn_card.rank)
|
||||
if drawn_value >= HIGH_CARD_THRESHOLD and not creates_pair:
|
||||
ai_log(f" >> GO-OUT: high card ({drawn_value}) into hidden, preferring flip")
|
||||
return None # Fall through to normal scoring (will flip)
|
||||
|
||||
# If BOTH options are bad, choose the better one
|
||||
if score_if_swap > max_acceptable_go_out and score_if_flip > max_acceptable_go_out:
|
||||
if score_if_swap > effective_max and score_if_flip > effective_max:
|
||||
if score_if_swap <= score_if_flip:
|
||||
ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) "
|
||||
f"<= flip ({score_if_flip}), forcing swap")
|
||||
@@ -1322,7 +1339,7 @@ class GolfAI:
|
||||
return None
|
||||
|
||||
# If swap is good, prefer it (known outcome vs unknown flip)
|
||||
elif score_if_swap <= max_acceptable_go_out and score_if_swap <= score_if_flip:
|
||||
elif score_if_swap <= effective_max and score_if_swap <= score_if_flip:
|
||||
ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}")
|
||||
return last_pos
|
||||
|
||||
@@ -1739,9 +1756,23 @@ class GolfAI:
|
||||
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
|
||||
projected_score = visible_score + expected_hidden_total
|
||||
|
||||
# Hard cap: never knock with projected score > 10
|
||||
if projected_score > 10:
|
||||
ai_log(f" Knock rejected: projected score {projected_score:.1f} > 10 hard cap")
|
||||
return False
|
||||
|
||||
# Tighter threshold: range 5 to 9 based on aggression
|
||||
max_acceptable = 5 + int(profile.aggression * 4)
|
||||
|
||||
# Check opponent threat - don't knock if an opponent likely beats us
|
||||
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
|
||||
if opponent_min < projected_score:
|
||||
# Opponent is likely beating us - penalize threshold
|
||||
threat_margin = projected_score - opponent_min
|
||||
max_acceptable -= int(threat_margin * 0.75)
|
||||
ai_log(f" Knock threat penalty: opponent est {opponent_min}, "
|
||||
f"margin {threat_margin:.1f}, threshold now {max_acceptable}")
|
||||
|
||||
# Exception: if all opponents are showing terrible scores, relax threshold
|
||||
all_opponents_bad = all(
|
||||
sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25
|
||||
@@ -1752,12 +1783,14 @@ class GolfAI:
|
||||
|
||||
if projected_score <= max_acceptable:
|
||||
# Scale knock chance by how good the projected score is
|
||||
if projected_score <= 5:
|
||||
knock_chance = profile.aggression * 0.3 # Max 30%
|
||||
elif projected_score <= 7:
|
||||
if projected_score <= 4:
|
||||
knock_chance = profile.aggression * 0.35 # Max 35%
|
||||
elif projected_score <= 6:
|
||||
knock_chance = profile.aggression * 0.15 # Max 15%
|
||||
else:
|
||||
knock_chance = profile.aggression * 0.05 # Max 5% (very rare)
|
||||
elif projected_score <= 8:
|
||||
knock_chance = profile.aggression * 0.06 # Max 6%
|
||||
else: # 9-10
|
||||
knock_chance = profile.aggression * 0.02 # Max 2% (very rare)
|
||||
|
||||
if random.random() < knock_chance:
|
||||
ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})")
|
||||
@@ -1934,7 +1967,11 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga
|
||||
async def process_cpu_turn(
|
||||
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
|
||||
) -> None:
|
||||
"""Process a complete turn for a CPU player."""
|
||||
"""Process a complete turn for a CPU player.
|
||||
|
||||
May raise asyncio.CancelledError if the game is ended mid-turn.
|
||||
The caller (check_and_run_cpu_turn) handles cancellation.
|
||||
"""
|
||||
import asyncio
|
||||
from services.game_logger import get_logger
|
||||
|
||||
@@ -1962,10 +1999,8 @@ async def process_cpu_turn(
|
||||
await asyncio.sleep(thinking_time)
|
||||
ai_log(f"{cpu_player.name} done thinking, making decision")
|
||||
|
||||
# Check if we should try to go out early
|
||||
GolfAI.should_go_out_early(cpu_player, game, profile)
|
||||
|
||||
# Check if we should knock early (flip all remaining cards at once)
|
||||
# (Opponent threat logic consolidated into should_knock_early)
|
||||
if GolfAI.should_knock_early(game, cpu_player, profile):
|
||||
if game.knock_early(cpu_player.id):
|
||||
_log_cpu_action(logger, game_id, cpu_player, game,
|
||||
|
||||
@@ -145,9 +145,18 @@ class ServerConfig:
|
||||
|
||||
# Security (for future auth system)
|
||||
SECRET_KEY: str = ""
|
||||
INVITE_ONLY: bool = False
|
||||
INVITE_ONLY: bool = True
|
||||
|
||||
# Bootstrap admin (for first-time setup when INVITE_ONLY=true)
|
||||
BOOTSTRAP_ADMIN_USERNAME: str = ""
|
||||
BOOTSTRAP_ADMIN_PASSWORD: str = ""
|
||||
ADMIN_EMAILS: list[str] = field(default_factory=list)
|
||||
|
||||
# Matchmaking
|
||||
MATCHMAKING_ENABLED: bool = True
|
||||
MATCHMAKING_MIN_PLAYERS: int = 2
|
||||
MATCHMAKING_MAX_PLAYERS: int = 4
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_ENABLED: bool = True
|
||||
|
||||
@@ -184,7 +193,12 @@ class ServerConfig:
|
||||
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
|
||||
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
||||
SECRET_KEY=get_env("SECRET_KEY", ""),
|
||||
INVITE_ONLY=get_env_bool("INVITE_ONLY", False),
|
||||
INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
|
||||
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
|
||||
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
|
||||
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
|
||||
MATCHMAKING_MIN_PLAYERS=get_env_int("MATCHMAKING_MIN_PLAYERS", 2),
|
||||
MATCHMAKING_MAX_PLAYERS=get_env_int("MATCHMAKING_MAX_PLAYERS", 4),
|
||||
ADMIN_EMAILS=admin_emails,
|
||||
RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True),
|
||||
SENTRY_DSN=get_env("SENTRY_DSN", ""),
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Optional
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from config import config
|
||||
from game import GamePhase, GameOptions
|
||||
from ai import GolfAI, get_all_profiles
|
||||
from room import Room
|
||||
@@ -53,6 +54,10 @@ def log_human_action(room: Room, player, action: str, card=None, position=None,
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
||||
if config.INVITE_ONLY and not ctx.authenticated_user:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
|
||||
return
|
||||
|
||||
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
||||
await ctx.websocket.send_json({
|
||||
"type": "error",
|
||||
@@ -60,9 +65,8 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
|
||||
})
|
||||
return
|
||||
|
||||
player_name = data.get("player_name", "Player")
|
||||
if ctx.authenticated_user and ctx.authenticated_user.display_name:
|
||||
player_name = ctx.authenticated_user.display_name
|
||||
# Use authenticated username as player name
|
||||
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
||||
room = room_manager.create_room()
|
||||
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||
ctx.current_room = room
|
||||
@@ -81,8 +85,13 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
|
||||
|
||||
|
||||
async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
||||
if config.INVITE_ONLY and not ctx.authenticated_user:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
|
||||
return
|
||||
|
||||
room_code = data.get("room_code", "").upper()
|
||||
player_name = data.get("player_name", "Player")
|
||||
# Use authenticated username as player name
|
||||
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
||||
|
||||
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
||||
await ctx.websocket.send_json({
|
||||
@@ -104,8 +113,6 @@ async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager,
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
|
||||
return
|
||||
|
||||
if ctx.authenticated_user and ctx.authenticated_user.display_name:
|
||||
player_name = ctx.authenticated_user.display_name
|
||||
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||
ctx.current_room = room
|
||||
|
||||
@@ -222,7 +229,7 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
|
||||
"game_state": game_state,
|
||||
})
|
||||
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
@@ -233,7 +240,7 @@ async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_g
|
||||
async with ctx.current_room.game_lock:
|
||||
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -290,7 +297,7 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await asyncio.sleep(1.0)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
@@ -322,12 +329,12 @@ async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_s
|
||||
})
|
||||
else:
|
||||
await asyncio.sleep(0.5)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
else:
|
||||
logger.debug("Player discarded, waiting 0.5s before CPU turn")
|
||||
await asyncio.sleep(0.5)
|
||||
logger.debug("Post-discard delay complete, checking for CPU turn")
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
||||
@@ -357,7 +364,7 @@ async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
@@ -373,7 +380,7 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
@@ -393,7 +400,7 @@ async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
@@ -411,7 +418,7 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
@@ -436,7 +443,7 @@ async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_gam
|
||||
"game_state": game_state,
|
||||
})
|
||||
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
else:
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
|
||||
@@ -466,6 +473,15 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
|
||||
return
|
||||
|
||||
# Cancel any running CPU turn task so the game ends immediately
|
||||
if ctx.current_room.cpu_turn_task:
|
||||
ctx.current_room.cpu_turn_task.cancel()
|
||||
try:
|
||||
await ctx.current_room.cpu_turn_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
ctx.current_room.cpu_turn_task = None
|
||||
|
||||
await ctx.current_room.broadcast({
|
||||
"type": "game_ended",
|
||||
"reason": "Host ended the game",
|
||||
@@ -483,6 +499,65 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c
|
||||
# Handler dispatch table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matchmaking handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def handle_queue_join(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, rating_service=None, **kw) -> None:
|
||||
if not matchmaking_service:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Matchmaking not available"})
|
||||
return
|
||||
|
||||
if not ctx.authenticated_user:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to find a game"})
|
||||
return
|
||||
|
||||
# Get player's rating
|
||||
rating = 1500.0
|
||||
if rating_service:
|
||||
try:
|
||||
player_rating = await rating_service.get_rating(ctx.auth_user_id)
|
||||
rating = player_rating.rating
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
status = await matchmaking_service.join_queue(
|
||||
user_id=ctx.auth_user_id,
|
||||
username=ctx.authenticated_user.username,
|
||||
rating=rating,
|
||||
websocket=ctx.websocket,
|
||||
connection_id=ctx.connection_id,
|
||||
)
|
||||
|
||||
await ctx.websocket.send_json({
|
||||
"type": "queue_joined",
|
||||
**status,
|
||||
})
|
||||
|
||||
|
||||
async def handle_queue_leave(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
|
||||
if not matchmaking_service or not ctx.auth_user_id:
|
||||
return
|
||||
|
||||
removed = await matchmaking_service.leave_queue(ctx.auth_user_id)
|
||||
await ctx.websocket.send_json({
|
||||
"type": "queue_left",
|
||||
"was_queued": removed,
|
||||
})
|
||||
|
||||
|
||||
async def handle_queue_status(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
|
||||
if not matchmaking_service or not ctx.auth_user_id:
|
||||
await ctx.websocket.send_json({"type": "queue_status", "in_queue": False})
|
||||
return
|
||||
|
||||
status = await matchmaking_service.get_queue_status(ctx.auth_user_id)
|
||||
await ctx.websocket.send_json({
|
||||
"type": "queue_status",
|
||||
**status,
|
||||
})
|
||||
|
||||
|
||||
HANDLERS = {
|
||||
"create_room": handle_create_room,
|
||||
"join_room": handle_join_room,
|
||||
@@ -503,4 +578,7 @@ HANDLERS = {
|
||||
"leave_room": handle_leave_room,
|
||||
"leave_game": handle_leave_game,
|
||||
"end_game": handle_end_game,
|
||||
"queue_join": handle_queue_join,
|
||||
"queue_leave": handle_queue_leave,
|
||||
"queue_status": handle_queue_status,
|
||||
}
|
||||
|
||||
@@ -148,6 +148,39 @@ class DevelopmentFormatter(logging.Formatter):
|
||||
return output
|
||||
|
||||
|
||||
# Per-module log level overrides via env vars.
|
||||
# Key: env var suffix, Value: list of Python logger names to apply to.
|
||||
MODULE_LOGGER_MAP = {
|
||||
"GAME": ["game"],
|
||||
"AI": ["ai"],
|
||||
"HANDLERS": ["handlers"],
|
||||
"ROOM": ["room"],
|
||||
"AUTH": ["auth", "routers.auth", "services.auth_service"],
|
||||
"STORES": ["stores"],
|
||||
}
|
||||
|
||||
|
||||
def _apply_module_overrides() -> dict[str, str]:
|
||||
"""
|
||||
Apply per-module log level overrides from LOG_LEVEL_{MODULE} env vars.
|
||||
|
||||
Returns:
|
||||
Dict of module name -> level for any overrides that were applied.
|
||||
"""
|
||||
active = {}
|
||||
for module, logger_names in MODULE_LOGGER_MAP.items():
|
||||
env_val = os.environ.get(f"LOG_LEVEL_{module}", "").upper()
|
||||
if not env_val:
|
||||
continue
|
||||
level = getattr(logging, env_val, None)
|
||||
if level is None:
|
||||
continue
|
||||
active[module] = env_val
|
||||
for name in logger_names:
|
||||
logging.getLogger(name).setLevel(level)
|
||||
return active
|
||||
|
||||
|
||||
def setup_logging(
|
||||
level: str = "INFO",
|
||||
environment: str = "development",
|
||||
@@ -182,12 +215,19 @@ def setup_logging(
|
||||
logging.getLogger("websockets").setLevel(logging.WARNING)
|
||||
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||
|
||||
# Apply per-module overrides from env vars
|
||||
overrides = _apply_module_overrides()
|
||||
|
||||
# Log startup
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(
|
||||
f"Logging configured: level={level}, environment={environment}",
|
||||
extra={"level": level, "environment": environment},
|
||||
)
|
||||
if overrides:
|
||||
logger.info(
|
||||
f"Per-module log level overrides: {', '.join(f'{m}={l}' for m, l in overrides.items())}",
|
||||
)
|
||||
|
||||
|
||||
class ContextLogger(logging.LoggerAdapter):
|
||||
|
||||
153
server/main.py
153
server/main.py
@@ -59,6 +59,8 @@ _user_store = None
|
||||
_auth_service = None
|
||||
_admin_service = None
|
||||
_stats_service = None
|
||||
_rating_service = None
|
||||
_matchmaking_service = None
|
||||
_replay_service = None
|
||||
_spectator_manager = None
|
||||
_leaderboard_refresh_task = None
|
||||
@@ -101,7 +103,7 @@ async def _init_redis():
|
||||
|
||||
async def _init_database_services():
|
||||
"""Initialize all PostgreSQL-dependent services."""
|
||||
global _user_store, _auth_service, _admin_service, _stats_service
|
||||
global _user_store, _auth_service, _admin_service, _stats_service, _rating_service, _matchmaking_service
|
||||
global _replay_service, _spectator_manager, _leaderboard_refresh_task
|
||||
|
||||
from stores.user_store import get_user_store
|
||||
@@ -109,7 +111,7 @@ async def _init_database_services():
|
||||
from services.auth_service import get_auth_service
|
||||
from services.admin_service import get_admin_service
|
||||
from services.stats_service import StatsService, set_stats_service
|
||||
from routers.auth import set_auth_service
|
||||
from routers.auth import set_auth_service, set_admin_service_for_auth
|
||||
from routers.admin import set_admin_service
|
||||
from routers.stats import set_stats_service as set_stats_router_service
|
||||
from routers.stats import set_auth_service as set_stats_auth_service
|
||||
@@ -127,6 +129,7 @@ async def _init_database_services():
|
||||
state_cache=None,
|
||||
)
|
||||
set_admin_service(_admin_service)
|
||||
set_admin_service_for_auth(_admin_service)
|
||||
logger.info("Admin services initialized")
|
||||
|
||||
# Stats + event store
|
||||
@@ -137,6 +140,23 @@ async def _init_database_services():
|
||||
set_stats_auth_service(_auth_service)
|
||||
logger.info("Stats services initialized")
|
||||
|
||||
# Rating service (Glicko-2)
|
||||
from services.rating_service import RatingService
|
||||
_rating_service = RatingService(_user_store.pool)
|
||||
logger.info("Rating service initialized")
|
||||
|
||||
# Matchmaking service
|
||||
if config.MATCHMAKING_ENABLED:
|
||||
from services.matchmaking import MatchmakingService, MatchmakingConfig
|
||||
mm_config = MatchmakingConfig(
|
||||
enabled=True,
|
||||
min_players=config.MATCHMAKING_MIN_PLAYERS,
|
||||
max_players=config.MATCHMAKING_MAX_PLAYERS,
|
||||
)
|
||||
_matchmaking_service = MatchmakingService(_redis_client, mm_config)
|
||||
await _matchmaking_service.start(room_manager, broadcast_game_state)
|
||||
logger.info("Matchmaking service initialized")
|
||||
|
||||
# Game logger
|
||||
_game_logger = GameLogger(_event_store)
|
||||
set_logger(_game_logger)
|
||||
@@ -165,12 +185,56 @@ async def _init_database_services():
|
||||
logger.info("Leaderboard refresh task started")
|
||||
|
||||
|
||||
async def _bootstrap_admin():
|
||||
"""Create bootstrap admin user if no admins exist yet."""
|
||||
import bcrypt
|
||||
from models.user import UserRole
|
||||
|
||||
# Check if any admin already exists
|
||||
existing = await _user_store.get_user_by_username(config.BOOTSTRAP_ADMIN_USERNAME)
|
||||
if existing:
|
||||
return
|
||||
|
||||
# Check if any admin exists at all
|
||||
async with _user_store.pool.acquire() as conn:
|
||||
admin_count = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM users_v2 WHERE role = 'admin' AND deleted_at IS NULL"
|
||||
)
|
||||
if admin_count > 0:
|
||||
return
|
||||
|
||||
# Create the bootstrap admin
|
||||
password_hash = bcrypt.hashpw(
|
||||
config.BOOTSTRAP_ADMIN_PASSWORD.encode("utf-8"),
|
||||
bcrypt.gensalt(),
|
||||
).decode("utf-8")
|
||||
|
||||
user = await _user_store.create_user(
|
||||
username=config.BOOTSTRAP_ADMIN_USERNAME,
|
||||
password_hash=password_hash,
|
||||
role=UserRole.ADMIN,
|
||||
)
|
||||
|
||||
if user:
|
||||
logger.warning(
|
||||
f"Bootstrap admin '{config.BOOTSTRAP_ADMIN_USERNAME}' created. "
|
||||
"Change the password and remove BOOTSTRAP_ADMIN_* env vars."
|
||||
)
|
||||
else:
|
||||
logger.error("Failed to create bootstrap admin user")
|
||||
|
||||
|
||||
async def _shutdown_services():
|
||||
"""Gracefully shut down all services."""
|
||||
_shutdown_event.set()
|
||||
|
||||
await _close_all_websockets()
|
||||
|
||||
# Stop matchmaking
|
||||
if _matchmaking_service:
|
||||
await _matchmaking_service.stop()
|
||||
await _matchmaking_service.cleanup()
|
||||
|
||||
# Clean up rooms and CPU profiles
|
||||
for room in list(room_manager.rooms.values()):
|
||||
for cpu in list(room.get_cpu_players()):
|
||||
@@ -225,6 +289,10 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work")
|
||||
|
||||
# Bootstrap admin user if needed (for first-time setup with INVITE_ONLY)
|
||||
if config.POSTGRES_URL and config.BOOTSTRAP_ADMIN_USERNAME and config.BOOTSTRAP_ADMIN_PASSWORD:
|
||||
await _bootstrap_admin()
|
||||
|
||||
# Set up health check dependencies
|
||||
from routers.health import set_health_dependencies
|
||||
set_health_dependencies(
|
||||
@@ -257,7 +325,7 @@ async def _close_all_websockets():
|
||||
app = FastAPI(
|
||||
title="Golf Card Game",
|
||||
debug=config.DEBUG,
|
||||
version="2.0.1",
|
||||
version="3.1.6",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -458,7 +526,7 @@ def count_user_games(user_id: str) -> int:
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
|
||||
# Extract token from query param for optional authentication
|
||||
# Extract token from query param for authentication
|
||||
token = websocket.query_params.get("token")
|
||||
authenticated_user = None
|
||||
if token and _auth_service:
|
||||
@@ -467,6 +535,12 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket auth failed: {e}")
|
||||
|
||||
# Reject unauthenticated connections when invite-only
|
||||
if config.INVITE_ONLY and not authenticated_user:
|
||||
await websocket.send_json({"type": "error", "message": "Authentication required. Please log in."})
|
||||
await websocket.close(code=4001, reason="Authentication required")
|
||||
return
|
||||
|
||||
connection_id = str(uuid.uuid4())
|
||||
auth_user_id = str(authenticated_user.id) if authenticated_user else None
|
||||
|
||||
@@ -492,6 +566,8 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
check_and_run_cpu_turn=check_and_run_cpu_turn,
|
||||
handle_player_leave=handle_player_leave,
|
||||
cleanup_room_profiles=cleanup_room_profiles,
|
||||
matchmaking_service=_matchmaking_service,
|
||||
rating_service=_rating_service,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -534,6 +610,23 @@ async def _process_stats_safe(room: Room):
|
||||
game_options=room.game.options,
|
||||
)
|
||||
logger.debug(f"Stats processed for room {room.code}")
|
||||
|
||||
# Update Glicko-2 ratings for human players
|
||||
if _rating_service:
|
||||
player_results = []
|
||||
for game_player in room.game.players:
|
||||
if game_player.id in player_user_ids:
|
||||
player_results.append((
|
||||
player_user_ids[game_player.id],
|
||||
game_player.total_score,
|
||||
))
|
||||
|
||||
if len(player_results) >= 2:
|
||||
await _rating_service.update_ratings(
|
||||
player_results=player_results,
|
||||
is_standard_rules=room.game.options.is_standard_rules(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process game stats: {e}")
|
||||
|
||||
@@ -612,8 +705,40 @@ async def broadcast_game_state(room: Room):
|
||||
})
|
||||
|
||||
|
||||
async def check_and_run_cpu_turn(room: Room):
|
||||
"""Check if current player is CPU and run their turn."""
|
||||
def check_and_run_cpu_turn(room: Room):
|
||||
"""Check if current player is CPU and start their turn as a background task.
|
||||
|
||||
The CPU turn chain runs as a fire-and-forget asyncio.Task stored on
|
||||
room.cpu_turn_task. This allows the WebSocket message loop to remain
|
||||
responsive so that end_game/leave messages can cancel the task immediately.
|
||||
"""
|
||||
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
||||
return
|
||||
|
||||
current = room.game.current_player()
|
||||
if not current:
|
||||
return
|
||||
|
||||
room_player = room.get_player(current.id)
|
||||
if not room_player or not room_player.is_cpu:
|
||||
return
|
||||
|
||||
task = asyncio.create_task(_run_cpu_chain(room))
|
||||
room.cpu_turn_task = task
|
||||
|
||||
def _on_done(t: asyncio.Task):
|
||||
# Clear the reference when the task finishes (success, cancel, or error)
|
||||
if room.cpu_turn_task is t:
|
||||
room.cpu_turn_task = None
|
||||
if not t.cancelled() and t.exception():
|
||||
logger.error(f"CPU turn task error in room {room.code}: {t.exception()}")
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
|
||||
|
||||
async def _run_cpu_chain(room: Room):
|
||||
"""Run consecutive CPU turns until a human player's turn or game ends."""
|
||||
while True:
|
||||
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
||||
return
|
||||
|
||||
@@ -634,12 +759,18 @@ async def check_and_run_cpu_turn(room: Room):
|
||||
|
||||
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
|
||||
|
||||
# Check if next player is also CPU (chain CPU turns)
|
||||
await check_and_run_cpu_turn(room)
|
||||
|
||||
|
||||
async def handle_player_leave(room: Room, player_id: str):
|
||||
"""Handle a player leaving a room."""
|
||||
# Cancel any running CPU turn task before cleanup
|
||||
if room.cpu_turn_task:
|
||||
room.cpu_turn_task.cancel()
|
||||
try:
|
||||
await room.cpu_turn_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
room.cpu_turn_task = None
|
||||
|
||||
room_code = room.code
|
||||
room_player = room.remove_player(player_id)
|
||||
|
||||
@@ -675,6 +806,10 @@ if os.path.exists(client_path):
|
||||
async def serve_replay_page(share_code: str):
|
||||
return FileResponse(os.path.join(client_path, "index.html"))
|
||||
|
||||
@app.get("/reset-password")
|
||||
async def serve_reset_password_page():
|
||||
return FileResponse(os.path.join(client_path, "index.html"))
|
||||
|
||||
# Mount static files for everything else (JS, CSS, SVG, etc.)
|
||||
app.mount("/", StaticFiles(directory=client_path), name="static")
|
||||
|
||||
|
||||
@@ -110,8 +110,10 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# Add WebSocket URLs
|
||||
if self.environment == "production":
|
||||
connect_sources.append(f"ws://{host}")
|
||||
connect_sources.append(f"wss://{host}")
|
||||
for allowed_host in self.allowed_hosts:
|
||||
connect_sources.append(f"ws://{allowed_host}")
|
||||
connect_sources.append(f"wss://{allowed_host}")
|
||||
else:
|
||||
# Development - allow ws:// and wss://
|
||||
|
||||
@@ -69,6 +69,7 @@ class Room:
|
||||
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
|
||||
game_log_id: Optional[str] = None
|
||||
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
cpu_turn_task: Optional[asyncio.Task] = None
|
||||
|
||||
def add_player(
|
||||
self,
|
||||
|
||||
@@ -11,8 +11,10 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from config import config
|
||||
from models.user import User
|
||||
from services.auth_service import AuthService
|
||||
from services.admin_service import AdminService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,6 +31,7 @@ class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: Optional[str] = None
|
||||
invite_code: Optional[str] = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -111,6 +114,7 @@ class SessionResponse(BaseModel):
|
||||
|
||||
# These will be set by main.py during startup
|
||||
_auth_service: Optional[AuthService] = None
|
||||
_admin_service: Optional[AdminService] = None
|
||||
|
||||
|
||||
def set_auth_service(service: AuthService) -> None:
|
||||
@@ -119,6 +123,12 @@ def set_auth_service(service: AuthService) -> None:
|
||||
_auth_service = service
|
||||
|
||||
|
||||
def set_admin_service_for_auth(service: AdminService) -> None:
|
||||
"""Set the admin service instance for invite code validation (called from main.py)."""
|
||||
global _admin_service
|
||||
_admin_service = service
|
||||
|
||||
|
||||
def get_auth_service_dep() -> AuthService:
|
||||
"""Dependency to get auth service."""
|
||||
if _auth_service is None:
|
||||
@@ -201,6 +211,15 @@ async def register(
|
||||
auth_service: AuthService = Depends(get_auth_service_dep),
|
||||
):
|
||||
"""Register a new user account."""
|
||||
# Validate invite code when invite-only mode is enabled
|
||||
if config.INVITE_ONLY:
|
||||
if not request_body.invite_code:
|
||||
raise HTTPException(status_code=400, detail="Invite code required")
|
||||
if not _admin_service:
|
||||
raise HTTPException(status_code=503, detail="Admin service not initialized")
|
||||
if not await _admin_service.validate_invite_code(request_body.invite_code):
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
|
||||
|
||||
result = await auth_service.register(
|
||||
username=request_body.username,
|
||||
password=request_body.password,
|
||||
@@ -210,6 +229,10 @@ async def register(
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
|
||||
# Consume the invite code after successful registration
|
||||
if config.INVITE_ONLY and request_body.invite_code:
|
||||
await _admin_service.use_invite_code(request_body.invite_code)
|
||||
|
||||
if result.requires_verification:
|
||||
# Return user info but note they need to verify
|
||||
return {
|
||||
|
||||
@@ -155,7 +155,7 @@ async def require_user(
|
||||
|
||||
@router.get("/leaderboard", response_model=LeaderboardResponse)
|
||||
async def get_leaderboard(
|
||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
service: StatsService = Depends(get_stats_service_dep),
|
||||
@@ -226,7 +226,7 @@ async def get_player_stats(
|
||||
@router.get("/players/{user_id}/rank", response_model=PlayerRankResponse)
|
||||
async def get_player_rank(
|
||||
user_id: str,
|
||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
|
||||
service: StatsService = Depends(get_stats_service_dep),
|
||||
):
|
||||
"""Get player's rank on a leaderboard."""
|
||||
@@ -346,7 +346,7 @@ async def get_my_stats(
|
||||
|
||||
@router.get("/me/rank", response_model=PlayerRankResponse)
|
||||
async def get_my_rank(
|
||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
|
||||
user: User = Depends(require_user),
|
||||
service: StatsService = Depends(get_stats_service_dep),
|
||||
):
|
||||
|
||||
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
|
||||
@@ -37,6 +37,8 @@ class PlayerStats:
|
||||
wolfpacks: int = 0
|
||||
current_win_streak: int = 0
|
||||
best_win_streak: int = 0
|
||||
rating: float = 1500.0
|
||||
rating_deviation: float = 350.0
|
||||
first_game_at: Optional[datetime] = None
|
||||
last_game_at: Optional[datetime] = None
|
||||
achievements: List[str] = field(default_factory=list)
|
||||
@@ -156,6 +158,8 @@ class StatsService:
|
||||
wolfpacks=row["wolfpacks"] or 0,
|
||||
current_win_streak=row["current_win_streak"] or 0,
|
||||
best_win_streak=row["best_win_streak"] or 0,
|
||||
rating=float(row["rating"]) if row.get("rating") else 1500.0,
|
||||
rating_deviation=float(row["rating_deviation"]) if row.get("rating_deviation") else 350.0,
|
||||
first_game_at=row["first_game_at"].replace(tzinfo=timezone.utc) if row["first_game_at"] else None,
|
||||
last_game_at=row["last_game_at"].replace(tzinfo=timezone.utc) if row["last_game_at"] else None,
|
||||
achievements=[a["achievement_id"] for a in achievements],
|
||||
@@ -184,6 +188,7 @@ class StatsService:
|
||||
"avg_score": ("avg_score", "ASC"), # Lower is better
|
||||
"knockouts": ("knockouts", "DESC"),
|
||||
"streak": ("best_win_streak", "DESC"),
|
||||
"rating": ("rating", "DESC"),
|
||||
}
|
||||
|
||||
if metric not in order_map:
|
||||
@@ -203,6 +208,7 @@ class StatsService:
|
||||
SELECT
|
||||
user_id, username, games_played, games_won,
|
||||
win_rate, avg_score, knockouts, best_win_streak,
|
||||
COALESCE(rating, 1500) as rating,
|
||||
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
||||
FROM leaderboard_overall
|
||||
ORDER BY {column} {direction}
|
||||
@@ -216,6 +222,7 @@ class StatsService:
|
||||
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
|
||||
ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score,
|
||||
s.knockouts, s.best_win_streak,
|
||||
COALESCE(s.rating, 1500) as rating,
|
||||
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
||||
FROM player_stats s
|
||||
JOIN users_v2 u ON s.user_id = u.id
|
||||
|
||||
@@ -204,6 +204,22 @@ BEGIN
|
||||
WHERE table_name = 'player_stats' AND column_name = 'games_won_vs_humans') THEN
|
||||
ALTER TABLE player_stats ADD COLUMN games_won_vs_humans INT DEFAULT 0;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'player_stats' AND column_name = 'rating') THEN
|
||||
ALTER TABLE player_stats ADD COLUMN rating DECIMAL(7,2) DEFAULT 1500.0;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'player_stats' AND column_name = 'rating_deviation') THEN
|
||||
ALTER TABLE player_stats ADD COLUMN rating_deviation DECIMAL(7,2) DEFAULT 350.0;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'player_stats' AND column_name = 'rating_volatility') THEN
|
||||
ALTER TABLE player_stats ADD COLUMN rating_volatility DECIMAL(8,6) DEFAULT 0.06;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'player_stats' AND column_name = 'rating_updated_at') THEN
|
||||
ALTER TABLE player_stats ADD COLUMN rating_updated_at TIMESTAMPTZ;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Stats processing queue (for async stats processing)
|
||||
@@ -265,9 +281,19 @@ CREATE TABLE IF NOT EXISTS system_metrics (
|
||||
);
|
||||
|
||||
-- Leaderboard materialized view (refreshed periodically)
|
||||
-- Note: Using DO block to handle case where view already exists
|
||||
-- Drop and recreate if missing rating column (v3.1.0 migration)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
|
||||
-- Check if rating column exists in the view
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'leaderboard_overall' AND column_name = 'rating'
|
||||
) THEN
|
||||
DROP MATERIALIZED VIEW leaderboard_overall;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
|
||||
EXECUTE '
|
||||
CREATE MATERIALIZED VIEW leaderboard_overall AS
|
||||
@@ -282,6 +308,7 @@ BEGIN
|
||||
s.best_score as best_round_score,
|
||||
s.knockouts,
|
||||
s.best_win_streak,
|
||||
COALESCE(s.rating, 1500) as rating,
|
||||
s.last_game_at
|
||||
FROM player_stats s
|
||||
JOIN users_v2 u ON s.user_id = u.id
|
||||
@@ -349,6 +376,9 @@ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_score') THEN
|
||||
CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_rating') THEN
|
||||
CREATE INDEX idx_leaderboard_overall_rating ON leaderboard_overall(rating DESC);
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user