Compare commits
No commits in common. "main" and "v3.1.2" have entirely different histories.
27
.env.example
27
.env.example
@ -20,24 +20,6 @@ DEBUG=false
|
|||||||
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||||
LOG_LEVEL=INFO
|
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 name (development, staging, production)
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
|
|
||||||
@ -75,15 +57,6 @@ SECRET_KEY=
|
|||||||
# Enable invite-only mode (requires invitation to register)
|
# Enable invite-only mode (requires invitation to register)
|
||||||
INVITE_ONLY=true
|
INVITE_ONLY=true
|
||||||
|
|
||||||
# Metered open signups (public beta)
|
|
||||||
# 0 = disabled (invite-only enforced), -1 = unlimited, N = max open signups per day
|
|
||||||
# When set > 0, users can register without an invite code up to the daily limit.
|
|
||||||
# Invite codes always work regardless of this limit.
|
|
||||||
DAILY_OPEN_SIGNUPS=0
|
|
||||||
|
|
||||||
# Max signups per IP address per day (0 = unlimited)
|
|
||||||
DAILY_SIGNUPS_PER_IP=3
|
|
||||||
|
|
||||||
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
|
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
|
||||||
# Remove these after first login!
|
# Remove these after first login!
|
||||||
# BOOTSTRAP_ADMIN_USERNAME=admin
|
# BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
|||||||
27
.gitignore
vendored
27
.gitignore
vendored
@ -136,31 +136,7 @@ celerybeat.pid
|
|||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
.envrc
|
.envrc
|
||||||
|
|
||||||
# Private keys and certificates
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
*.p12
|
|
||||||
*.pfx
|
|
||||||
*.jks
|
|
||||||
*.keystore
|
|
||||||
|
|
||||||
# Service credentials
|
|
||||||
credentials.json
|
|
||||||
service-account.json
|
|
||||||
*-credentials.json
|
|
||||||
|
|
||||||
# SSH keys
|
|
||||||
id_rsa
|
|
||||||
id_ecdsa
|
|
||||||
id_ed25519
|
|
||||||
|
|
||||||
# Other sensitive files
|
|
||||||
*.secrets
|
|
||||||
.htpasswd
|
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
@ -225,9 +201,6 @@ pyvenv.cfg
|
|||||||
# Personal notes
|
# Personal notes
|
||||||
lookfah.md
|
lookfah.md
|
||||||
|
|
||||||
# Internal docs (deployment info, credentials references, etc.)
|
|
||||||
internal/
|
|
||||||
|
|
||||||
# Ruff stuff:
|
# Ruff stuff:
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
|
|||||||
@ -1,300 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "1.5.0",
|
|
||||||
"plugins_used": [
|
|
||||||
{
|
|
||||||
"name": "ArtifactoryDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AWSKeyDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AzureStorageKeyDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Base64HighEntropyString",
|
|
||||||
"limit": 4.5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "BasicAuthDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CloudantDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DiscordBotTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GitHubTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GitLabTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "HexHighEntropyString",
|
|
||||||
"limit": 3.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IbmCloudIamDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IbmCosHmacDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IPPublicDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "JwtTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "KeywordDetector",
|
|
||||||
"keyword_exclude": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "MailchimpDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "NpmDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "OpenAIDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "PrivateKeyDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "PypiTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SendGridDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SlackDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SoftlayerDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SquareOAuthDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "StripeDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TelegramBotTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TwilioKeyDetector"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"filters_used": [
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
|
||||||
"min_level": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.regex.should_exclude_file",
|
|
||||||
"pattern": [
|
|
||||||
"\\.env\\.example$",
|
|
||||||
"server/\\.env\\.example$"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"results": {
|
|
||||||
"INSTALL.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "INSTALL.md",
|
|
||||||
"hashed_secret": "365e24291fd19bba10a0d8504c0ed90d5c8bef7f",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 75
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Basic Auth Credentials",
|
|
||||||
"filename": "INSTALL.md",
|
|
||||||
"hashed_secret": "4f4944a7117fd2e95169da2b40af33b68a65a161",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 114
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "INSTALL.md",
|
|
||||||
"hashed_secret": "c35bdb821a941808a150db95d0f934f449bbff17",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 182
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Basic Auth Credentials",
|
|
||||||
"filename": "INSTALL.md",
|
|
||||||
"hashed_secret": "c35bdb821a941808a150db95d0f934f449bbff17",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 225
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "INSTALL.md",
|
|
||||||
"hashed_secret": "001c1654cb8dff7c4ddb1ae6d2203d0dd15a6096",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 391
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "INSTALL.md",
|
|
||||||
"hashed_secret": "53fe8c55272f9c3ceebb5e6058788e8981a359cb",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 397
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docker-compose.dev.yml": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docker-compose.dev.yml",
|
|
||||||
"hashed_secret": "4f4944a7117fd2e95169da2b40af33b68a65a161",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 44
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/v2/V2_BUILD_PLAN.md": [
|
|
||||||
{
|
|
||||||
"type": "Basic Auth Credentials",
|
|
||||||
"filename": "docs/v2/V2_BUILD_PLAN.md",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 301
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"scripts/docker-build.sh": [
|
|
||||||
{
|
|
||||||
"type": "Basic Auth Credentials",
|
|
||||||
"filename": "scripts/docker-build.sh",
|
|
||||||
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 40
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"scripts/install.sh": [
|
|
||||||
{
|
|
||||||
"type": "Basic Auth Credentials",
|
|
||||||
"filename": "scripts/install.sh",
|
|
||||||
"hashed_secret": "4f4944a7117fd2e95169da2b40af33b68a65a161",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 156
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Basic Auth Credentials",
|
|
||||||
"filename": "scripts/install.sh",
|
|
||||||
"hashed_secret": "7205a0abf00d1daec13c63ece029057c974795a9",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 267
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"server/RULES.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "server/RULES.md",
|
|
||||||
"hashed_secret": "a6778f1880744bd1a342a8e3789135412d8f9da2",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 904
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "server/RULES.md",
|
|
||||||
"hashed_secret": "aafdc23870ecbcd3d557b6423a8982134e17927e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 949
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"server/config.py": [
|
|
||||||
{
|
|
||||||
"type": "Basic Auth Credentials",
|
|
||||||
"filename": "server/config.py",
|
|
||||||
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 123
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"server/game_analyzer.py": [
|
|
||||||
{
|
|
||||||
"type": "Basic Auth Credentials",
|
|
||||||
"filename": "server/game_analyzer.py",
|
|
||||||
"hashed_secret": "4f4944a7117fd2e95169da2b40af33b68a65a161",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 616
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"server/test_auth.py": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "server/test_auth.py",
|
|
||||||
"hashed_secret": "cbfdac6008f9cab4083784cbd1874f76618d2a97",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 38
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "server/test_auth.py",
|
|
||||||
"hashed_secret": "f0578f1e7174b1a41c4ea8c6e17f7a8a3b88c92a",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 50
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "server/test_auth.py",
|
|
||||||
"hashed_secret": "8be52126a6fde450a7162a3651d589bb51e9579d",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 64
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "server/test_auth.py",
|
|
||||||
"hashed_secret": "74913f5cd5f61ec0bcfdb775414c2fb3d161b620",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 74
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "server/test_auth.py",
|
|
||||||
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 91
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "server/test_auth.py",
|
|
||||||
"hashed_secret": "1e99b09f6eb835305555cc43c3e0768b1a39226b",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 103
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"generated_at": "2026-03-06T03:45:28Z"
|
|
||||||
}
|
|
||||||
@ -31,17 +31,14 @@ class AnimationQueue {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add movements to the queue and start processing.
|
// Add movements to the queue and start processing
|
||||||
// The onComplete callback only fires after the LAST movement in this batch —
|
|
||||||
// intermediate movements don't trigger it. This is intentional: callers want
|
|
||||||
// to know when the whole sequence is done, not each individual step.
|
|
||||||
async enqueue(movements, onComplete) {
|
async enqueue(movements, onComplete) {
|
||||||
if (!movements || movements.length === 0) {
|
if (!movements || movements.length === 0) {
|
||||||
if (onComplete) onComplete();
|
if (onComplete) onComplete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach callback to last movement only
|
// Add completion callback to last movement
|
||||||
const movementsWithCallback = movements.map((m, i) => ({
|
const movementsWithCallback = movements.map((m, i) => ({
|
||||||
...m,
|
...m,
|
||||||
onComplete: i === movements.length - 1 ? onComplete : null
|
onComplete: i === movements.length - 1 ? onComplete : null
|
||||||
@ -188,9 +185,7 @@ class AnimationQueue {
|
|||||||
await this.delay(this.timing.flipDuration);
|
await this.delay(this.timing.flipDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Quick crossfade swap.
|
// Step 2: Quick crossfade swap
|
||||||
// 150ms is short enough to feel instant but long enough for the eye to
|
|
||||||
// register the transition. Shorter looks like a glitch, longer looks laggy.
|
|
||||||
handCard.classList.add('fade-out');
|
handCard.classList.add('fade-out');
|
||||||
heldCard.classList.add('fade-out');
|
heldCard.classList.add('fade-out');
|
||||||
await this.delay(150);
|
await this.delay(150);
|
||||||
|
|||||||
445
client/app.js
445
client/app.js
@ -30,14 +30,7 @@ class GolfGame {
|
|||||||
this.soundEnabled = true;
|
this.soundEnabled = true;
|
||||||
this.audioCtx = null;
|
this.audioCtx = null;
|
||||||
|
|
||||||
// --- Animation coordination flags ---
|
// Swap animation state
|
||||||
// These flags form a system: they block renderGame() from touching the discard pile
|
|
||||||
// while an animation is in flight. If any flag gets stuck true, the discard pile
|
|
||||||
// freezes and the UI looks broken. Every flag MUST be cleared in every code path:
|
|
||||||
// animation callbacks, error handlers, fallbacks, and the `your_turn` safety net.
|
|
||||||
// If you're debugging a frozen discard pile, check these first.
|
|
||||||
|
|
||||||
// Swap animation state — local player's swap defers state updates until animation completes
|
|
||||||
this.swapAnimationInProgress = false;
|
this.swapAnimationInProgress = false;
|
||||||
this.swapAnimationCardEl = null;
|
this.swapAnimationCardEl = null;
|
||||||
this.swapAnimationFront = null;
|
this.swapAnimationFront = null;
|
||||||
@ -51,19 +44,19 @@ class GolfGame {
|
|||||||
// Animation lock - prevent overlapping animations on same elements
|
// Animation lock - prevent overlapping animations on same elements
|
||||||
this.animatingPositions = new Set();
|
this.animatingPositions = new Set();
|
||||||
|
|
||||||
// Blocks discard update: opponent swap animation in progress
|
// Track opponent swap animation in progress (to apply swap-out class after render)
|
||||||
this.opponentSwapAnimation = null; // { playerId, position }
|
this.opponentSwapAnimation = null; // { playerId, position }
|
||||||
|
|
||||||
// Blocks held card display: draw pulse animation hasn't finished yet
|
// Track draw pulse animation in progress (defer held card display until pulse completes)
|
||||||
this.drawPulseAnimation = false;
|
this.drawPulseAnimation = false;
|
||||||
|
|
||||||
// Blocks discard update: local player discarding drawn card to pile
|
// Track local discard animation in progress (prevent renderGame from updating discard)
|
||||||
this.localDiscardAnimating = false;
|
this.localDiscardAnimating = false;
|
||||||
|
|
||||||
// Blocks discard update: opponent discarding without swap
|
// Track opponent discard animation in progress (prevent renderGame from updating discard)
|
||||||
this.opponentDiscardAnimating = false;
|
this.opponentDiscardAnimating = false;
|
||||||
|
|
||||||
// Blocks discard update + suppresses flip prompts: deal animation in progress
|
// Track deal animation in progress (suppress flip prompts until dealing complete)
|
||||||
this.dealAnimationInProgress = false;
|
this.dealAnimationInProgress = false;
|
||||||
|
|
||||||
// Track round winners for visual highlight
|
// Track round winners for visual highlight
|
||||||
@ -81,7 +74,6 @@ class GolfGame {
|
|||||||
this.initCardTooltips();
|
this.initCardTooltips();
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.initMobileDetection();
|
this.initMobileDetection();
|
||||||
this.initDesktopScorecard();
|
|
||||||
this.checkUrlParams();
|
this.checkUrlParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,11 +104,9 @@ class GolfGame {
|
|||||||
this.isMobile = e.matches;
|
this.isMobile = e.matches;
|
||||||
document.body.classList.toggle('mobile-portrait', e.matches);
|
document.body.classList.toggle('mobile-portrait', e.matches);
|
||||||
setAppHeight();
|
setAppHeight();
|
||||||
// Close any open drawers/overlays on layout change
|
// Close any open drawers on layout change
|
||||||
if (!e.matches) {
|
if (!e.matches) {
|
||||||
this.closeDrawers();
|
this.closeDrawers();
|
||||||
} else {
|
|
||||||
this.closeDesktopScorecard();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
mql.addEventListener('change', update);
|
mql.addEventListener('change', update);
|
||||||
@ -157,31 +147,6 @@ class GolfGame {
|
|||||||
if (bottomBar) bottomBar.classList.remove('hidden');
|
if (bottomBar) bottomBar.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
initDesktopScorecard() {
|
|
||||||
if (!this.desktopScorecardBtn) return;
|
|
||||||
|
|
||||||
this.desktopScorecardBtn.addEventListener('click', () => {
|
|
||||||
const isOpen = this.desktopScorecardOverlay.classList.contains('open');
|
|
||||||
if (isOpen) {
|
|
||||||
this.closeDesktopScorecard();
|
|
||||||
} else {
|
|
||||||
this.desktopScorecardOverlay.classList.add('open');
|
|
||||||
this.desktopScorecardBtn.classList.add('active');
|
|
||||||
this.desktopScorecardBackdrop.classList.add('visible');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.desktopScorecardBackdrop.addEventListener('click', () => {
|
|
||||||
this.closeDesktopScorecard();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
closeDesktopScorecard() {
|
|
||||||
if (this.desktopScorecardOverlay) this.desktopScorecardOverlay.classList.remove('open');
|
|
||||||
if (this.desktopScorecardBtn) this.desktopScorecardBtn.classList.remove('active');
|
|
||||||
if (this.desktopScorecardBackdrop) this.desktopScorecardBackdrop.classList.remove('visible');
|
|
||||||
}
|
|
||||||
|
|
||||||
initAudio() {
|
initAudio() {
|
||||||
// Initialize audio context on first user interaction
|
// Initialize audio context on first user interaction
|
||||||
const initCtx = () => {
|
const initCtx = () => {
|
||||||
@ -414,7 +379,7 @@ class GolfGame {
|
|||||||
// Only show tooltips on your turn
|
// Only show tooltips on your turn
|
||||||
if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return;
|
if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return;
|
||||||
|
|
||||||
const value = this.getCardPointValueForTooltip(cardData);
|
const value = this.getCardPointValue(cardData);
|
||||||
const special = this.getCardSpecialNote(cardData);
|
const special = this.getCardSpecialNote(cardData);
|
||||||
|
|
||||||
let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`;
|
let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`;
|
||||||
@ -444,15 +409,14 @@ class GolfGame {
|
|||||||
if (this.tooltip) this.tooltip.classList.add('hidden');
|
if (this.tooltip) this.tooltip.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
getCardPointValueForTooltip(cardData) {
|
getCardPointValue(cardData) {
|
||||||
const values = this.gameState?.card_values || this.getDefaultCardValues();
|
const values = this.gameState?.card_values || this.getDefaultCardValues();
|
||||||
const rules = this.gameState?.scoring_rules || {};
|
return values[cardData.rank] ?? 0;
|
||||||
return this.getCardPointValue(cardData, values, rules);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCardSpecialNote(cardData) {
|
getCardSpecialNote(cardData) {
|
||||||
const rank = cardData.rank;
|
const rank = cardData.rank;
|
||||||
const value = this.getCardPointValueForTooltip(cardData);
|
const value = this.getCardPointValue(cardData);
|
||||||
if (value < 0) return 'Negative - keep it!';
|
if (value < 0) return 'Negative - keep it!';
|
||||||
if (rank === 'K' && value === 0) return 'Safe card';
|
if (rank === 'K' && value === 0) return 'Safe card';
|
||||||
if (rank === 'K' && value === -2) return 'Super King!';
|
if (rank === 'K' && value === -2) return 'Super King!';
|
||||||
@ -572,13 +536,6 @@ class GolfGame {
|
|||||||
this.gameUsername = document.getElementById('game-username');
|
this.gameUsername = document.getElementById('game-username');
|
||||||
this.gameLogoutBtn = document.getElementById('game-logout-btn');
|
this.gameLogoutBtn = document.getElementById('game-logout-btn');
|
||||||
this.authBar = document.getElementById('auth-bar');
|
this.authBar = document.getElementById('auth-bar');
|
||||||
|
|
||||||
// Desktop scorecard overlay elements
|
|
||||||
this.desktopScorecardBtn = document.getElementById('desktop-scorecard-btn');
|
|
||||||
this.desktopScorecardOverlay = document.getElementById('desktop-scorecard-overlay');
|
|
||||||
this.desktopScorecardBackdrop = document.getElementById('desktop-scorecard-backdrop');
|
|
||||||
this.desktopStandingsList = document.getElementById('desktop-standings-list');
|
|
||||||
this.desktopScoreTable = document.getElementById('desktop-score-table')?.querySelector('tbody');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bindEvents() {
|
bindEvents() {
|
||||||
@ -860,39 +817,16 @@ class GolfGame {
|
|||||||
hasDrawn: newState.has_drawn_card
|
hasDrawn: newState.has_drawn_card
|
||||||
});
|
});
|
||||||
|
|
||||||
// V3_03: Intercept round_over transition to defer card reveals.
|
// V3_03: Intercept round_over transition to defer card reveals
|
||||||
// The problem: the last turn's swap animation flips a card, and then
|
|
||||||
// the round-end reveal animation would flip it again. We snapshot the
|
|
||||||
// old state, patch it to mark the swap position as already face-up,
|
|
||||||
// and use that as the "before" for the reveal animation.
|
|
||||||
const roundJustEnded = oldState?.phase !== 'round_over' &&
|
const roundJustEnded = oldState?.phase !== 'round_over' &&
|
||||||
newState.phase === 'round_over';
|
newState.phase === 'round_over';
|
||||||
|
|
||||||
if (roundJustEnded && oldState) {
|
if (roundJustEnded && oldState) {
|
||||||
// Update state first so animations can read new card data
|
// Save pre-reveal state for the reveal animation
|
||||||
this.gameState = newState;
|
this.preRevealState = JSON.parse(JSON.stringify(oldState));
|
||||||
|
|
||||||
// Fire animations for the last turn (swap/discard) before deferring
|
|
||||||
try {
|
|
||||||
this.triggerAnimationsForStateChange(oldState, newState);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Animation error on round end:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build preRevealState from oldState, but mark swap position as
|
|
||||||
// already handled so reveal animation doesn't double-flip it.
|
|
||||||
// Without this patch, the card visually flips twice in a row.
|
|
||||||
const preReveal = JSON.parse(JSON.stringify(oldState));
|
|
||||||
if (this.opponentSwapAnimation) {
|
|
||||||
const { playerId, position } = this.opponentSwapAnimation;
|
|
||||||
const player = preReveal.players.find(p => p.id === playerId);
|
|
||||||
if (player?.cards[position]) {
|
|
||||||
player.cards[position].face_up = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.preRevealState = preReveal;
|
|
||||||
this.postRevealState = newState;
|
this.postRevealState = newState;
|
||||||
|
// Update state but DON'T render yet - reveal animation will handle it
|
||||||
|
this.gameState = newState;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -989,16 +923,16 @@ class GolfGame {
|
|||||||
this.displayHeldCard(data.card, true);
|
this.displayHeldCard(data.card, true);
|
||||||
this.renderGame();
|
this.renderGame();
|
||||||
}
|
}
|
||||||
this.showToast('Swap with a card or discard', 'your-turn', 3000);
|
this.showToast('Swap with a card or discard', '', 3000);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'can_flip':
|
case 'can_flip':
|
||||||
this.waitingForFlip = true;
|
this.waitingForFlip = true;
|
||||||
this.flipIsOptional = data.optional || false;
|
this.flipIsOptional = data.optional || false;
|
||||||
if (this.flipIsOptional) {
|
if (this.flipIsOptional) {
|
||||||
this.showToast('Flip a card or skip', 'your-turn', 3000);
|
this.showToast('Flip a card or skip', '', 3000);
|
||||||
} else {
|
} else {
|
||||||
this.showToast('Flip a face-down card', 'your-turn', 3000);
|
this.showToast('Flip a face-down card', '', 3000);
|
||||||
}
|
}
|
||||||
this.renderGame();
|
this.renderGame();
|
||||||
break;
|
break;
|
||||||
@ -1016,7 +950,7 @@ class GolfGame {
|
|||||||
// Host ended the game or player was kicked
|
// Host ended the game or player was kicked
|
||||||
this._intentionalClose = true;
|
this._intentionalClose = true;
|
||||||
if (this.ws) this.ws.close();
|
if (this.ws) this.ws.close();
|
||||||
this.showLobby();
|
this.showScreen('lobby');
|
||||||
if (data.reason) {
|
if (data.reason) {
|
||||||
this.showError(data.reason);
|
this.showError(data.reason);
|
||||||
}
|
}
|
||||||
@ -1041,7 +975,7 @@ class GolfGame {
|
|||||||
|
|
||||||
case 'queue_left':
|
case 'queue_left':
|
||||||
this.stopMatchmakingTimer();
|
this.stopMatchmakingTimer();
|
||||||
this.showLobby();
|
this.showScreen('lobby');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
@ -1061,7 +995,7 @@ class GolfGame {
|
|||||||
cancelMatchmaking() {
|
cancelMatchmaking() {
|
||||||
this.send({ type: 'queue_leave' });
|
this.send({ type: 'queue_leave' });
|
||||||
this.stopMatchmakingTimer();
|
this.stopMatchmakingTimer();
|
||||||
this.showLobby();
|
this.showScreen('lobby');
|
||||||
}
|
}
|
||||||
|
|
||||||
startMatchmakingTimer() {
|
startMatchmakingTimer() {
|
||||||
@ -1379,16 +1313,14 @@ class GolfGame {
|
|||||||
this.heldCardFloating.classList.add('hidden');
|
this.heldCardFloating.classList.add('hidden');
|
||||||
this.heldCardFloating.style.cssText = '';
|
this.heldCardFloating.style.cssText = '';
|
||||||
|
|
||||||
// Three-part race guard. All three are needed, and they protect different things:
|
// Pre-emptively skip the flip animation - the server may broadcast the new state
|
||||||
// 1. skipNextDiscardFlip: prevents the CSS flip-in animation from firing
|
// before our animation completes, and we don't want renderGame() to trigger
|
||||||
// (it starts at opacity:0, which causes a visible flash)
|
// the flip-in animation (which starts with opacity: 0, causing a flash)
|
||||||
// 2. lastDiscardKey: prevents renderGame() from detecting a "change" to the
|
|
||||||
// discard pile and re-rendering it mid-animation
|
|
||||||
// 3. localDiscardAnimating: blocks renderGame() from touching the discard DOM
|
|
||||||
// entirely until our animation callback fires
|
|
||||||
// Remove any one of these and you get a different flavor of visual glitch.
|
|
||||||
this.skipNextDiscardFlip = true;
|
this.skipNextDiscardFlip = true;
|
||||||
|
// Also update lastDiscardKey so renderGame() won't see a "change"
|
||||||
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
|
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
|
||||||
|
|
||||||
|
// Block renderGame from updating discard during animation (prevents race condition)
|
||||||
this.localDiscardAnimating = true;
|
this.localDiscardAnimating = true;
|
||||||
|
|
||||||
// Animate held card to discard using anime.js
|
// Animate held card to discard using anime.js
|
||||||
@ -1499,8 +1431,11 @@ class GolfGame {
|
|||||||
this.swapAnimationCardEl = handCardEl;
|
this.swapAnimationCardEl = handCardEl;
|
||||||
this.swapAnimationHandCardEl = handCardEl;
|
this.swapAnimationHandCardEl = handCardEl;
|
||||||
|
|
||||||
// Hide discard button during animation (held card hidden later by onStart)
|
// Hide originals during animation
|
||||||
this.discardBtn.classList.add('hidden');
|
handCardEl.classList.add('swap-out');
|
||||||
|
if (this.heldCardFloating) {
|
||||||
|
this.heldCardFloating.style.visibility = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
// Store drawn card data before clearing
|
// Store drawn card data before clearing
|
||||||
const drawnCardData = this.drawnCard;
|
const drawnCardData = this.drawnCard;
|
||||||
@ -1523,12 +1458,6 @@ class GolfGame {
|
|||||||
{
|
{
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
wasHandFaceDown: false,
|
wasHandFaceDown: false,
|
||||||
onStart: () => {
|
|
||||||
handCardEl.classList.add('swap-out');
|
|
||||||
if (this.heldCardFloating) {
|
|
||||||
this.heldCardFloating.style.visibility = 'hidden';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
handCardEl.classList.remove('swap-out');
|
handCardEl.classList.remove('swap-out');
|
||||||
if (this.heldCardFloating) {
|
if (this.heldCardFloating) {
|
||||||
@ -1592,12 +1521,6 @@ class GolfGame {
|
|||||||
{
|
{
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
wasHandFaceDown: true,
|
wasHandFaceDown: true,
|
||||||
onStart: () => {
|
|
||||||
if (handCardEl) handCardEl.classList.add('swap-out');
|
|
||||||
if (this.heldCardFloating) {
|
|
||||||
this.heldCardFloating.style.visibility = 'hidden';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
if (handCardEl) handCardEl.classList.remove('swap-out');
|
if (handCardEl) handCardEl.classList.remove('swap-out');
|
||||||
if (this.heldCardFloating) {
|
if (this.heldCardFloating) {
|
||||||
@ -1650,27 +1573,9 @@ class GolfGame {
|
|||||||
this.heldCardFloating.classList.add('hidden');
|
this.heldCardFloating.classList.add('hidden');
|
||||||
|
|
||||||
if (this.pendingGameState) {
|
if (this.pendingGameState) {
|
||||||
const oldState = this.gameState;
|
this.gameState = this.pendingGameState;
|
||||||
const newState = this.pendingGameState;
|
|
||||||
this.pendingGameState = null;
|
this.pendingGameState = null;
|
||||||
|
this.renderGame();
|
||||||
// Check if the deferred state is a round_over transition
|
|
||||||
const roundJustEnded = oldState?.phase !== 'round_over' &&
|
|
||||||
newState.phase === 'round_over';
|
|
||||||
|
|
||||||
if (roundJustEnded && oldState) {
|
|
||||||
// Same intercept as the game_state handler: store pre/post
|
|
||||||
// reveal states so runRoundEndReveal can animate the reveal
|
|
||||||
this.gameState = newState;
|
|
||||||
const preReveal = JSON.parse(JSON.stringify(oldState));
|
|
||||||
this.preRevealState = preReveal;
|
|
||||||
this.postRevealState = newState;
|
|
||||||
// Don't renderGame - let the reveal sequence handle it
|
|
||||||
} else {
|
|
||||||
this.gameState = newState;
|
|
||||||
this.checkForNewPairs(oldState, newState);
|
|
||||||
this.renderGame();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1884,10 +1789,6 @@ class GolfGame {
|
|||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
this.setStatus('Hole complete');
|
this.setStatus('Hole complete');
|
||||||
|
|
||||||
// Hide bottom bar so it doesn't overlay the modal
|
|
||||||
const bottomBar = document.getElementById('mobile-bottom-bar');
|
|
||||||
if (bottomBar) bottomBar.classList.add('hidden');
|
|
||||||
|
|
||||||
// Bind next button
|
// Bind next button
|
||||||
const nextBtn = document.getElementById('ss-next-btn');
|
const nextBtn = document.getElementById('ss-next-btn');
|
||||||
nextBtn.addEventListener('click', () => {
|
nextBtn.addEventListener('click', () => {
|
||||||
@ -2017,10 +1918,6 @@ class GolfGame {
|
|||||||
this.clearScoresheetCountdown();
|
this.clearScoresheetCountdown();
|
||||||
const modal = document.getElementById('scoresheet-modal');
|
const modal = document.getElementById('scoresheet-modal');
|
||||||
if (modal) modal.remove();
|
if (modal) modal.remove();
|
||||||
|
|
||||||
// Restore bottom bar
|
|
||||||
const bottomBar = document.getElementById('mobile-bottom-bar');
|
|
||||||
if (bottomBar) bottomBar.classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- V3_02: Dealing Animation ---
|
// --- V3_02: Dealing Animation ---
|
||||||
@ -2156,16 +2053,6 @@ class GolfGame {
|
|||||||
|
|
||||||
async runRoundEndReveal(scores, rankings) {
|
async runRoundEndReveal(scores, rankings) {
|
||||||
const T = window.TIMING?.reveal || {};
|
const T = window.TIMING?.reveal || {};
|
||||||
|
|
||||||
// preRevealState may not be set yet if the game_state was deferred
|
|
||||||
// (e.g., local swap animation was in progress). Wait briefly for it.
|
|
||||||
if (!this.preRevealState) {
|
|
||||||
const waitStart = Date.now();
|
|
||||||
while (!this.preRevealState && Date.now() - waitStart < 3000) {
|
|
||||||
await this.delay(100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldState = this.preRevealState;
|
const oldState = this.preRevealState;
|
||||||
const newState = this.postRevealState || this.gameState;
|
const newState = this.postRevealState || this.gameState;
|
||||||
|
|
||||||
@ -2175,35 +2062,22 @@ class GolfGame {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute what needs revealing (before renderGame changes the DOM)
|
// First, render the game with the OLD state (pre-reveal) so cards show face-down
|
||||||
|
this.gameState = newState;
|
||||||
|
// But render with pre-reveal card visuals
|
||||||
|
this.revealAnimationInProgress = true;
|
||||||
|
|
||||||
|
// Render game to show current layout (opponents, etc)
|
||||||
|
this.renderGame();
|
||||||
|
|
||||||
|
// Compute what needs revealing
|
||||||
const revealsByPlayer = this.getCardsToReveal(oldState, newState);
|
const revealsByPlayer = this.getCardsToReveal(oldState, newState);
|
||||||
|
|
||||||
// Get reveal order: knocker first, then clockwise
|
// Get reveal order: knocker first, then clockwise
|
||||||
const knockerId = newState.finisher_id;
|
const knockerId = newState.finisher_id;
|
||||||
const revealOrder = this.getRevealOrder(newState.players, knockerId);
|
const revealOrder = this.getRevealOrder(newState.players, knockerId);
|
||||||
|
|
||||||
// Wait for the last player's animation (swap/discard/draw) to finish
|
// Initial pause
|
||||||
// so the final play is visible before the reveal sequence starts
|
|
||||||
const maxWait = 3000;
|
|
||||||
const start = Date.now();
|
|
||||||
while (Date.now() - start < maxWait) {
|
|
||||||
if (!this.isDrawAnimating && !this.opponentSwapAnimation &&
|
|
||||||
!this.opponentDiscardAnimating && !this.localDiscardAnimating &&
|
|
||||||
!this.swapAnimationInProgress) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await this.delay(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extra pause so the final play registers visually before we
|
|
||||||
// re-render the board (renderGame below resets card positions)
|
|
||||||
await this.delay(T.lastPlayPause || 2500);
|
|
||||||
|
|
||||||
// Now render with pre-reveal state (face-down cards) for the reveal sequence
|
|
||||||
this.gameState = newState;
|
|
||||||
this.revealAnimationInProgress = true;
|
|
||||||
this.renderGame();
|
|
||||||
|
|
||||||
this.setStatus('Revealing cards...', 'reveal');
|
this.setStatus('Revealing cards...', 'reveal');
|
||||||
await this.delay(T.initialPause || 500);
|
await this.delay(T.initialPause || 500);
|
||||||
|
|
||||||
@ -2471,13 +2345,7 @@ class GolfGame {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget animation triggers based on state diffs.
|
// Fire-and-forget animation triggers based on state changes
|
||||||
// Two-step detection:
|
|
||||||
// STEP 1: Did someone draw? (drawn_card goes null -> something)
|
|
||||||
// STEP 2: Did someone finish their turn? (discard pile changed + turn advanced)
|
|
||||||
// Critical: if STEP 1 detects a draw-from-discard, STEP 2 must be skipped.
|
|
||||||
// The discard pile changed because a card was REMOVED, not ADDED. Without this
|
|
||||||
// suppression, we'd fire a phantom discard animation for a card nobody discarded.
|
|
||||||
triggerAnimationsForStateChange(oldState, newState) {
|
triggerAnimationsForStateChange(oldState, newState) {
|
||||||
if (!oldState) return;
|
if (!oldState) return;
|
||||||
|
|
||||||
@ -2536,13 +2404,8 @@ class GolfGame {
|
|||||||
this.opponentDiscardAnimating = false;
|
this.opponentDiscardAnimating = false;
|
||||||
// Set isDrawAnimating to block renderGame from updating discard pile
|
// Set isDrawAnimating to block renderGame from updating discard pile
|
||||||
this.isDrawAnimating = true;
|
this.isDrawAnimating = true;
|
||||||
// Force discard DOM to show the card being drawn before animation starts
|
|
||||||
// (previous animation may have blocked renderGame from updating it)
|
|
||||||
if (oldDiscard) {
|
|
||||||
this.updateDiscardPileDisplay(oldDiscard);
|
|
||||||
}
|
|
||||||
console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true');
|
console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true');
|
||||||
window.drawAnimations.animateDrawDiscard(oldDiscard || drawnCard, () => {
|
window.drawAnimations.animateDrawDiscard(drawnCard, () => {
|
||||||
console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating');
|
console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating');
|
||||||
this.isDrawAnimating = false;
|
this.isDrawAnimating = false;
|
||||||
onAnimComplete();
|
onAnimComplete();
|
||||||
@ -2552,7 +2415,7 @@ class GolfGame {
|
|||||||
this.opponentSwapAnimation = null;
|
this.opponentSwapAnimation = null;
|
||||||
this.opponentDiscardAnimating = false;
|
this.opponentDiscardAnimating = false;
|
||||||
this.isDrawAnimating = true;
|
this.isDrawAnimating = true;
|
||||||
console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true, drawnCard:', drawnCard ? `${drawnCard.rank} of ${drawnCard.suit}` : 'NULL', 'discardTop:', newDiscard ? `${newDiscard.rank} of ${newDiscard.suit}` : 'EMPTY');
|
console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true');
|
||||||
window.drawAnimations.animateDrawDeck(drawnCard, () => {
|
window.drawAnimations.animateDrawDeck(drawnCard, () => {
|
||||||
console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating');
|
console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating');
|
||||||
this.isDrawAnimating = false;
|
this.isDrawAnimating = false;
|
||||||
@ -2585,18 +2448,18 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances)
|
// STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances)
|
||||||
// Skip if we just detected a draw — see comment at top of function.
|
// Skip if we just detected a draw - the discard change was from REMOVING a card, not adding one
|
||||||
if (discardChanged && wasOtherPlayer && !justDetectedDraw) {
|
if (discardChanged && wasOtherPlayer && !justDetectedDraw) {
|
||||||
// Figure out if the previous player SWAPPED (a card in their hand changed)
|
// Check if the previous player actually SWAPPED (has a new face-up card)
|
||||||
// or just discarded their drawn card (hand is identical).
|
// vs just discarding the drawn card (no hand change)
|
||||||
// Three cases to detect a swap:
|
|
||||||
// Case 1: face-down -> face-up (normal swap into hidden position)
|
|
||||||
// Case 2: both face-up but different card (swap into already-revealed position)
|
|
||||||
// Case 3: card identity null -> known (race condition: face_up flag lagging behind)
|
|
||||||
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
|
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
|
||||||
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
|
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
|
||||||
|
|
||||||
if (oldPlayer && newPlayer) {
|
if (oldPlayer && newPlayer) {
|
||||||
|
// Find the position that changed
|
||||||
|
// Could be: face-down -> face-up (new reveal)
|
||||||
|
// Or: different card at same position (replaced visible card)
|
||||||
|
// Or: card identity became known (null -> value, indicates swap)
|
||||||
let swappedPosition = -1;
|
let swappedPosition = -1;
|
||||||
let wasFaceUp = false; // Track if old card was already face-up
|
let wasFaceUp = false; // Track if old card was already face-up
|
||||||
|
|
||||||
@ -2633,7 +2496,6 @@ class GolfGame {
|
|||||||
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
|
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
|
||||||
|
|
||||||
if (swappedPosition >= 0 && wasOtherPlayer) {
|
if (swappedPosition >= 0 && wasOtherPlayer) {
|
||||||
console.log('[DEBUG] Swap detected:', { playerId: previousPlayerId, position: swappedPosition, wasFaceUp, newDiscard: newDiscard?.rank });
|
|
||||||
// Opponent swapped - animate from the actual position that changed
|
// Opponent swapped - animate from the actual position that changed
|
||||||
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
|
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
|
||||||
// Show CPU swap announcement
|
// Show CPU swap announcement
|
||||||
@ -2737,7 +2599,6 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
firePairCelebration(playerId, pos1, pos2) {
|
firePairCelebration(playerId, pos1, pos2) {
|
||||||
this.playSound('pair');
|
|
||||||
const elements = this.getCardElements(playerId, pos1, pos2);
|
const elements = this.getCardElements(playerId, pos1, pos2);
|
||||||
if (elements.length < 2) return;
|
if (elements.length < 2) return;
|
||||||
|
|
||||||
@ -2930,9 +2791,21 @@ class GolfGame {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide the source card during animation
|
||||||
|
sourceCardEl.classList.add('swap-out');
|
||||||
|
|
||||||
// Use unified swap animation
|
// Use unified swap animation
|
||||||
if (window.cardAnimations) {
|
if (window.cardAnimations) {
|
||||||
const heldRect = window.cardAnimations.getHoldingRect();
|
// For opponent swaps, size the held card to match the opponent card
|
||||||
|
// rather than the deck size (default holding rect uses deck dimensions,
|
||||||
|
// which looks oversized next to small opponent cards on mobile)
|
||||||
|
const holdingRect = window.cardAnimations.getHoldingRect();
|
||||||
|
const heldRect = holdingRect ? {
|
||||||
|
left: holdingRect.left,
|
||||||
|
top: holdingRect.top,
|
||||||
|
width: sourceRect.width,
|
||||||
|
height: sourceRect.height
|
||||||
|
} : null;
|
||||||
|
|
||||||
window.cardAnimations.animateUnifiedSwap(
|
window.cardAnimations.animateUnifiedSwap(
|
||||||
discardCard, // handCardData - card going to discard
|
discardCard, // handCardData - card going to discard
|
||||||
@ -2942,32 +2815,23 @@ class GolfGame {
|
|||||||
{
|
{
|
||||||
rotation: sourceRotation,
|
rotation: sourceRotation,
|
||||||
wasHandFaceDown: !wasFaceUp,
|
wasHandFaceDown: !wasFaceUp,
|
||||||
onStart: () => {
|
|
||||||
sourceCardEl.classList.add('swap-out');
|
|
||||||
},
|
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
|
sourceCardEl.classList.remove('swap-out');
|
||||||
this.opponentSwapAnimation = null;
|
this.opponentSwapAnimation = null;
|
||||||
this.opponentDiscardAnimating = false;
|
this.opponentDiscardAnimating = false;
|
||||||
console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating');
|
console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating');
|
||||||
// Don't re-render during reveal animation - it handles its own rendering
|
this.renderGame();
|
||||||
if (!this.revealAnimationInProgress) {
|
|
||||||
this.renderGame();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Fallback
|
// Fallback
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
|
sourceCardEl.classList.remove('swap-out');
|
||||||
this.opponentSwapAnimation = null;
|
this.opponentSwapAnimation = null;
|
||||||
this.opponentDiscardAnimating = false;
|
this.opponentDiscardAnimating = false;
|
||||||
console.log('[DEBUG] Swap animation fallback complete - clearing flags');
|
console.log('[DEBUG] Swap animation fallback complete - clearing flags');
|
||||||
// Don't re-render during reveal animation - it handles its own rendering
|
this.renderGame();
|
||||||
if (!this.revealAnimationInProgress) {
|
|
||||||
this.renderGame();
|
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2989,11 +2853,6 @@ class GolfGame {
|
|||||||
if (window.cardAnimations) {
|
if (window.cardAnimations) {
|
||||||
window.cardAnimations.animateInitialFlip(cardEl, cardData, () => {
|
window.cardAnimations.animateInitialFlip(cardEl, cardData, () => {
|
||||||
this.animatingPositions.delete(key);
|
this.animatingPositions.delete(key);
|
||||||
// Unhide the current card element (may have been rebuilt by renderGame)
|
|
||||||
const currentCards = this.playerCards.querySelectorAll('.card');
|
|
||||||
if (currentCards[position]) {
|
|
||||||
currentCards[position].style.visibility = '';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Fallback if card animations not available
|
// Fallback if card animations not available
|
||||||
@ -3098,7 +2957,7 @@ class GolfGame {
|
|||||||
this.hideToast();
|
this.hideToast();
|
||||||
} else {
|
} else {
|
||||||
const remaining = requiredFlips - uniquePositions.length;
|
const remaining = requiredFlips - uniquePositions.length;
|
||||||
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, 'your-turn', 5000);
|
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -3196,14 +3055,6 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showLobby() {
|
showLobby() {
|
||||||
if (window.cardAnimations) {
|
|
||||||
window.cardAnimations.cancelAll();
|
|
||||||
}
|
|
||||||
this.dealAnimationInProgress = false;
|
|
||||||
this.isDrawAnimating = false;
|
|
||||||
this.localDiscardAnimating = false;
|
|
||||||
this.opponentDiscardAnimating = false;
|
|
||||||
this.opponentSwapAnimation = false;
|
|
||||||
this.showScreen(this.lobbyScreen);
|
this.showScreen(this.lobbyScreen);
|
||||||
this.lobbyError.textContent = '';
|
this.lobbyError.textContent = '';
|
||||||
this.roomCode = null;
|
this.roomCode = null;
|
||||||
@ -3276,24 +3127,6 @@ class GolfGame {
|
|||||||
`<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`;
|
`<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`;
|
||||||
}
|
}
|
||||||
this.activeRulesBar.classList.remove('hidden');
|
this.activeRulesBar.classList.remove('hidden');
|
||||||
|
|
||||||
// Update mobile rules indicator
|
|
||||||
const mobileRulesBtn = document.getElementById('mobile-rules-btn');
|
|
||||||
const mobileRulesIcon = document.getElementById('mobile-rules-icon');
|
|
||||||
const mobileRulesContent = document.getElementById('mobile-rules-content');
|
|
||||||
if (mobileRulesBtn && mobileRulesIcon && mobileRulesContent) {
|
|
||||||
const isHouseRules = rules.length > 0;
|
|
||||||
mobileRulesIcon.textContent = isHouseRules ? '!' : 'RULES';
|
|
||||||
mobileRulesBtn.classList.toggle('house-rules', isHouseRules);
|
|
||||||
|
|
||||||
if (!isHouseRules) {
|
|
||||||
mobileRulesContent.innerHTML = '<div class="mobile-rules-content-list"><span class="rule-tag standard">Standard Rules</span></div>';
|
|
||||||
} else {
|
|
||||||
const tagHtml = (unrankedTag ? '<span class="rule-tag unranked">Unranked</span>' : '') +
|
|
||||||
rules.map(renderTag).join('');
|
|
||||||
mobileRulesContent.innerHTML = `<div class="mobile-rules-content-list">${tagHtml}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// V3_14: Map display names to rule keys
|
// V3_14: Map display names to rule keys
|
||||||
@ -3605,6 +3438,15 @@ class GolfGame {
|
|||||||
// Toggle game area class for border pulse
|
// Toggle game area class for border pulse
|
||||||
this.gameScreen.classList.add('final-turn-active');
|
this.gameScreen.classList.add('final-turn-active');
|
||||||
|
|
||||||
|
// Calculate remaining turns
|
||||||
|
const remaining = this.countRemainingTurns();
|
||||||
|
|
||||||
|
// Update badge content
|
||||||
|
const remainingEl = this.finalTurnBadge.querySelector('.final-turn-remaining');
|
||||||
|
if (remainingEl) {
|
||||||
|
remainingEl.textContent = remaining === 1 ? '1 turn left' : `${remaining} turns left`;
|
||||||
|
}
|
||||||
|
|
||||||
// Show badge
|
// Show badge
|
||||||
this.finalTurnBadge.classList.remove('hidden');
|
this.finalTurnBadge.classList.remove('hidden');
|
||||||
|
|
||||||
@ -3700,9 +3542,7 @@ class GolfGame {
|
|||||||
const cardHeight = deckRect.height;
|
const cardHeight = deckRect.height;
|
||||||
|
|
||||||
// Position card centered, overlapping both piles (lower than before)
|
// Position card centered, overlapping both piles (lower than before)
|
||||||
// On mobile portrait, place held card fully above the deck/discard area
|
const overlapOffset = cardHeight * 0.35; // More overlap = lower position
|
||||||
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
|
|
||||||
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
|
|
||||||
const cardLeft = centerX - cardWidth / 2;
|
const cardLeft = centerX - cardWidth / 2;
|
||||||
const cardTop = deckRect.top - overlapOffset;
|
const cardTop = deckRect.top - overlapOffset;
|
||||||
this.heldCardFloating.style.left = `${cardLeft}px`;
|
this.heldCardFloating.style.left = `${cardLeft}px`;
|
||||||
@ -3714,21 +3554,11 @@ class GolfGame {
|
|||||||
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
|
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position discard button
|
// Position discard button attached to right side of held card
|
||||||
if (isMobilePortrait) {
|
const buttonLeft = cardLeft + cardWidth; // Right edge of card (no gap)
|
||||||
// Below the held card, centered
|
const buttonTop = cardTop + cardHeight * 0.3; // Vertically centered on card
|
||||||
const btnRect = this.discardBtn.getBoundingClientRect();
|
this.discardBtn.style.left = `${buttonLeft}px`;
|
||||||
const buttonLeft = cardLeft + (cardWidth - (btnRect.width || 70)) / 2;
|
this.discardBtn.style.top = `${buttonTop}px`;
|
||||||
const buttonTop = cardTop + cardHeight + 4;
|
|
||||||
this.discardBtn.style.left = `${buttonLeft}px`;
|
|
||||||
this.discardBtn.style.top = `${buttonTop}px`;
|
|
||||||
} else {
|
|
||||||
// Right side of held card (desktop)
|
|
||||||
const buttonLeft = cardLeft + cardWidth;
|
|
||||||
const buttonTop = cardTop + cardHeight * 0.3;
|
|
||||||
this.discardBtn.style.left = `${buttonLeft}px`;
|
|
||||||
this.discardBtn.style.top = `${buttonTop}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (card.rank === '★') {
|
if (card.rank === '★') {
|
||||||
this.heldCardFloating.classList.add('joker');
|
this.heldCardFloating.classList.add('joker');
|
||||||
@ -3774,8 +3604,7 @@ class GolfGame {
|
|||||||
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
|
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
|
||||||
const cardWidth = deckRect.width;
|
const cardWidth = deckRect.width;
|
||||||
const cardHeight = deckRect.height;
|
const cardHeight = deckRect.height;
|
||||||
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
|
const overlapOffset = cardHeight * 0.35;
|
||||||
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
|
|
||||||
const cardLeft = centerX - cardWidth / 2;
|
const cardLeft = centerX - cardWidth / 2;
|
||||||
const cardTop = deckRect.top - overlapOffset;
|
const cardTop = deckRect.top - overlapOffset;
|
||||||
|
|
||||||
@ -3947,19 +3776,14 @@ class GolfGame {
|
|||||||
if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds;
|
if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds;
|
||||||
|
|
||||||
// Show/hide final turn badge with enhanced urgency
|
// Show/hide final turn badge with enhanced urgency
|
||||||
// Note: markKnocker() is deferred until after opponent areas are rebuilt below
|
|
||||||
const isFinalTurn = this.gameState.phase === 'final_turn';
|
const isFinalTurn = this.gameState.phase === 'final_turn';
|
||||||
if (isFinalTurn) {
|
if (isFinalTurn) {
|
||||||
this.gameScreen.classList.add('final-turn-active');
|
this.updateFinalTurnDisplay();
|
||||||
this.finalTurnBadge.classList.remove('hidden');
|
|
||||||
if (!this.finalTurnAnnounced) {
|
|
||||||
this.playSound('alert');
|
|
||||||
this.finalTurnAnnounced = true;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.finalTurnBadge.classList.add('hidden');
|
this.finalTurnBadge.classList.add('hidden');
|
||||||
this.gameScreen.classList.remove('final-turn-active');
|
this.gameScreen.classList.remove('final-turn-active');
|
||||||
this.finalTurnAnnounced = false;
|
this.finalTurnAnnounced = false;
|
||||||
|
this.clearKnockerMark();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle not-my-turn class to disable hover effects when it's not player's turn
|
// Toggle not-my-turn class to disable hover effects when it's not player's turn
|
||||||
@ -3981,7 +3805,7 @@ class GolfGame {
|
|||||||
: this.gameState.current_player_id;
|
: this.gameState.current_player_id;
|
||||||
const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId);
|
const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId);
|
||||||
if (displayedPlayer && displayedPlayerId !== this.playerId) {
|
if (displayedPlayer && displayedPlayerId !== this.playerId) {
|
||||||
this.setStatus(`${displayedPlayer.name}'s turn`, 'opponent-turn');
|
this.setStatus(`${displayedPlayer.name}'s turn`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update player header (name + score like opponents)
|
// Update player header (name + score like opponents)
|
||||||
@ -4056,11 +3880,7 @@ class GolfGame {
|
|||||||
// Not holding - show normal discard pile
|
// Not holding - show normal discard pile
|
||||||
this.discard.classList.remove('picked-up');
|
this.discard.classList.remove('picked-up');
|
||||||
|
|
||||||
// The discard pile is touched by four different animation paths.
|
// Skip discard update during any discard-related animation - animation handles the visual
|
||||||
// Each flag represents a different in-flight animation that "owns" the discard DOM.
|
|
||||||
// renderGame() must not update the discard while any of these are active, or you'll
|
|
||||||
// see the card content flash/change underneath the animation overlay.
|
|
||||||
// Priority order doesn't matter — any one of them is reason enough to skip.
|
|
||||||
const skipReason = this.localDiscardAnimating ? 'localDiscardAnimating' :
|
const skipReason = this.localDiscardAnimating ? 'localDiscardAnimating' :
|
||||||
this.opponentSwapAnimation ? 'opponentSwapAnimation' :
|
this.opponentSwapAnimation ? 'opponentSwapAnimation' :
|
||||||
this.opponentDiscardAnimating ? 'opponentDiscardAnimating' :
|
this.opponentDiscardAnimating ? 'opponentDiscardAnimating' :
|
||||||
@ -4084,9 +3904,7 @@ class GolfGame {
|
|||||||
const discardCard = this.gameState.discard_top;
|
const discardCard = this.gameState.discard_top;
|
||||||
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
|
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
|
||||||
|
|
||||||
// Only animate discard flip during active gameplay, not at round/game end.
|
// Only animate discard flip during active gameplay, not at round/game end
|
||||||
// lastDiscardKey is pre-set by discardDrawn() to prevent a false "change"
|
|
||||||
// detection when the server confirms what we already animated locally.
|
|
||||||
const isActivePlay = this.gameState.phase !== 'round_over' &&
|
const isActivePlay = this.gameState.phase !== 'round_over' &&
|
||||||
this.gameState.phase !== 'game_over';
|
this.gameState.phase !== 'game_over';
|
||||||
const shouldAnimate = isActivePlay && this.lastDiscardKey &&
|
const shouldAnimate = isActivePlay && this.lastDiscardKey &&
|
||||||
@ -4274,13 +4092,7 @@ class GolfGame {
|
|||||||
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
|
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
|
||||||
// V3_13: Bind tooltip events for face-up cards
|
// V3_13: Bind tooltip events for face-up cards
|
||||||
this.bindCardTooltipEvents(cardEl.firstChild, displayCard);
|
this.bindCardTooltipEvents(cardEl.firstChild, displayCard);
|
||||||
const appendedCard = cardEl.firstChild;
|
this.playerCards.appendChild(cardEl.firstChild);
|
||||||
this.playerCards.appendChild(appendedCard);
|
|
||||||
|
|
||||||
// Hide card if flip animation overlay is active on this position
|
|
||||||
if (this.animatingPositions.has(`local-${index}`)) {
|
|
||||||
appendedCard.style.visibility = 'hidden';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4337,13 +4149,6 @@ class GolfGame {
|
|||||||
// Update scoreboard panel
|
// Update scoreboard panel
|
||||||
this.updateScorePanel();
|
this.updateScorePanel();
|
||||||
|
|
||||||
// Mark knocker AFTER opponent areas are rebuilt (otherwise innerHTML='' wipes it)
|
|
||||||
if (this.gameState.phase === 'final_turn') {
|
|
||||||
this.markKnocker(this.gameState.finisher_id);
|
|
||||||
} else {
|
|
||||||
this.clearKnockerMark();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize anime.js hover listeners on newly created cards
|
// Initialize anime.js hover listeners on newly created cards
|
||||||
if (window.cardAnimations) {
|
if (window.cardAnimations) {
|
||||||
window.cardAnimations.initHoverListeners(this.playerCards);
|
window.cardAnimations.initHoverListeners(this.playerCards);
|
||||||
@ -4390,11 +4195,6 @@ class GolfGame {
|
|||||||
`;
|
`;
|
||||||
this.scoreTable.appendChild(tr);
|
this.scoreTable.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mirror to desktop overlay
|
|
||||||
if (this.desktopScoreTable) {
|
|
||||||
this.desktopScoreTable.innerHTML = this.scoreTable.innerHTML;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStandings() {
|
updateStandings() {
|
||||||
@ -4432,7 +4232,7 @@ class GolfGame {
|
|||||||
return `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won} wins</span></div>`;
|
return `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won} wins</span></div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
const standingsContent = `
|
this.standingsList.innerHTML = `
|
||||||
<div class="standings-section">
|
<div class="standings-section">
|
||||||
<div class="standings-title">By Score</div>
|
<div class="standings-title">By Score</div>
|
||||||
${pointsHtml}
|
${pointsHtml}
|
||||||
@ -4442,10 +4242,6 @@ class GolfGame {
|
|||||||
${holesHtml}
|
${holesHtml}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
this.standingsList.innerHTML = standingsContent;
|
|
||||||
if (this.desktopStandingsList) {
|
|
||||||
this.desktopStandingsList.innerHTML = standingsContent;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCard(card, clickable, selected) {
|
renderCard(card, clickable, selected) {
|
||||||
@ -4525,11 +4321,6 @@ class GolfGame {
|
|||||||
this.scoreTable.appendChild(tr);
|
this.scoreTable.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mirror to desktop overlay
|
|
||||||
if (this.desktopScoreTable) {
|
|
||||||
this.desktopScoreTable.innerHTML = this.scoreTable.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show rankings announcement only for final results
|
// Show rankings announcement only for final results
|
||||||
const existingAnnouncement = document.getElementById('rankings-announcement');
|
const existingAnnouncement = document.getElementById('rankings-announcement');
|
||||||
if (existingAnnouncement) existingAnnouncement.remove();
|
if (existingAnnouncement) existingAnnouncement.remove();
|
||||||
@ -4845,14 +4636,11 @@ class AuthManager {
|
|||||||
this.signupFormContainer = document.getElementById('signup-form-container');
|
this.signupFormContainer = document.getElementById('signup-form-container');
|
||||||
this.signupForm = document.getElementById('signup-form');
|
this.signupForm = document.getElementById('signup-form');
|
||||||
this.signupInviteCode = document.getElementById('signup-invite-code');
|
this.signupInviteCode = document.getElementById('signup-invite-code');
|
||||||
this.inviteCodeGroup = document.getElementById('invite-code-group');
|
|
||||||
this.inviteCodeHint = document.getElementById('invite-code-hint');
|
|
||||||
this.signupUsername = document.getElementById('signup-username');
|
this.signupUsername = document.getElementById('signup-username');
|
||||||
this.signupEmail = document.getElementById('signup-email');
|
this.signupEmail = document.getElementById('signup-email');
|
||||||
this.signupPassword = document.getElementById('signup-password');
|
this.signupPassword = document.getElementById('signup-password');
|
||||||
this.signupError = document.getElementById('signup-error');
|
this.signupError = document.getElementById('signup-error');
|
||||||
this.showSignupLink = document.getElementById('show-signup');
|
this.showSignupLink = document.getElementById('show-signup');
|
||||||
this.signupInfo = null; // populated by fetchSignupInfo()
|
|
||||||
this.showLoginLink = document.getElementById('show-login');
|
this.showLoginLink = document.getElementById('show-login');
|
||||||
this.showForgotLink = document.getElementById('show-forgot');
|
this.showForgotLink = document.getElementById('show-forgot');
|
||||||
this.forgotFormContainer = document.getElementById('forgot-form-container');
|
this.forgotFormContainer = document.getElementById('forgot-form-container');
|
||||||
@ -4901,9 +4689,6 @@ class AuthManager {
|
|||||||
// Check URL for reset token or invite code on page load
|
// Check URL for reset token or invite code on page load
|
||||||
this.checkResetToken();
|
this.checkResetToken();
|
||||||
this.checkInviteCode();
|
this.checkInviteCode();
|
||||||
|
|
||||||
// Fetch signup availability info (metered open signups)
|
|
||||||
this.fetchSignupInfo();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showModal(form = 'login') {
|
showModal(form = 'login') {
|
||||||
@ -5068,44 +4853,6 @@ class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchSignupInfo() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/auth/signup-info');
|
|
||||||
if (resp.ok) {
|
|
||||||
this.signupInfo = await resp.json();
|
|
||||||
this.updateInviteCodeField();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Fail silently — invite field stays required by default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInviteCodeField() {
|
|
||||||
if (!this.signupInfo || !this.signupInviteCode) return;
|
|
||||||
|
|
||||||
const { invite_required, open_signups_enabled, remaining_today, unlimited } = this.signupInfo;
|
|
||||||
|
|
||||||
if (invite_required) {
|
|
||||||
this.signupInviteCode.required = true;
|
|
||||||
this.signupInviteCode.placeholder = 'Invite Code (required)';
|
|
||||||
if (this.inviteCodeHint) this.inviteCodeHint.textContent = '';
|
|
||||||
} else if (open_signups_enabled) {
|
|
||||||
this.signupInviteCode.required = false;
|
|
||||||
this.signupInviteCode.placeholder = 'Invite Code (optional)';
|
|
||||||
if (this.inviteCodeHint) {
|
|
||||||
if (unlimited) {
|
|
||||||
this.inviteCodeHint.textContent = 'Open registration — no invite needed';
|
|
||||||
} else if (remaining_today !== null && remaining_today > 0) {
|
|
||||||
this.inviteCodeHint.textContent = `${remaining_today} open signup${remaining_today !== 1 ? 's' : ''} left today`;
|
|
||||||
} else if (remaining_today === 0) {
|
|
||||||
this.signupInviteCode.required = true;
|
|
||||||
this.signupInviteCode.placeholder = 'Invite Code (required)';
|
|
||||||
this.inviteCodeHint.textContent = 'Daily signups full — invite code required';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleForgotPassword(e) {
|
async handleForgotPassword(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.clearErrors();
|
this.clearErrors();
|
||||||
|
|||||||
@ -43,15 +43,10 @@ class CardAnimations {
|
|||||||
const discardRect = this.getDiscardRect();
|
const discardRect = this.getDiscardRect();
|
||||||
if (!deckRect || !discardRect) return null;
|
if (!deckRect || !discardRect) return null;
|
||||||
|
|
||||||
// Center the held card between deck and discard pile
|
|
||||||
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
|
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
|
||||||
const cardWidth = deckRect.width;
|
const cardWidth = deckRect.width;
|
||||||
const cardHeight = deckRect.height;
|
const cardHeight = deckRect.height;
|
||||||
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
|
const overlapOffset = cardHeight * 0.35;
|
||||||
// Overlap percentages: how much the held card peeks above the deck/discard row.
|
|
||||||
// 48% on mobile (tighter vertical space, needs more overlap to fit),
|
|
||||||
// 35% on desktop (more breathing room). Tuned by eye, not by math.
|
|
||||||
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: centerX - cardWidth / 2,
|
left: centerX - cardWidth / 2,
|
||||||
@ -160,20 +155,12 @@ class CardAnimations {
|
|||||||
}
|
}
|
||||||
this.activeAnimations.clear();
|
this.activeAnimations.clear();
|
||||||
|
|
||||||
// Remove all animation overlay elements
|
// Remove all animation card elements (including those marked as animating)
|
||||||
document.querySelectorAll('.draw-anim-card, .traveling-card, .deal-anim-container').forEach(el => {
|
document.querySelectorAll('.draw-anim-card').forEach(el => {
|
||||||
delete el.dataset.animating;
|
delete el.dataset.animating;
|
||||||
el.remove();
|
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
|
// Restore discard pile visibility if it was hidden during animation
|
||||||
const discardPile = document.getElementById('discard');
|
const discardPile = document.getElementById('discard');
|
||||||
if (discardPile && discardPile.style.opacity === '0') {
|
if (discardPile && discardPile.style.opacity === '0') {
|
||||||
@ -224,7 +211,6 @@ class CardAnimations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
|
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
|
||||||
console.log('[DEBUG] _animateDrawDeckCard called with cardData:', cardData ? `${cardData.rank} of ${cardData.suit}` : 'NULL');
|
|
||||||
const deckColor = this.getDeckColor();
|
const deckColor = this.getDeckColor();
|
||||||
const animCard = this.createAnimCard(deckRect, true, deckColor);
|
const animCard = this.createAnimCard(deckRect, true, deckColor);
|
||||||
animCard.dataset.animating = 'true'; // Mark as actively animating
|
animCard.dataset.animating = 'true'; // Mark as actively animating
|
||||||
@ -233,9 +219,6 @@ class CardAnimations {
|
|||||||
|
|
||||||
if (cardData) {
|
if (cardData) {
|
||||||
this.setCardContent(animCard, 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');
|
this.playSound('draw-deck');
|
||||||
@ -424,7 +407,6 @@ class CardAnimations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Animate initial flip at game start - smooth flip only, no lift
|
// Animate initial flip at game start - smooth flip only, no lift
|
||||||
// Uses overlay sized to match the source card exactly
|
|
||||||
animateInitialFlip(cardElement, cardData, onComplete) {
|
animateInitialFlip(cardElement, cardData, onComplete) {
|
||||||
if (!cardElement) {
|
if (!cardElement) {
|
||||||
if (onComplete) onComplete();
|
if (onComplete) onComplete();
|
||||||
@ -438,16 +420,8 @@ class CardAnimations {
|
|||||||
const animCard = this.createAnimCard(rect, true, deckColor);
|
const animCard = this.createAnimCard(rect, true, deckColor);
|
||||||
this.setCardContent(animCard, cardData);
|
this.setCardContent(animCard, cardData);
|
||||||
|
|
||||||
// Match the front face styling to player hand cards (not deck/discard cards)
|
// Hide original card during animation
|
||||||
const front = animCard.querySelector('.draw-anim-front');
|
cardElement.style.opacity = '0';
|
||||||
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 inner = animCard.querySelector('.draw-anim-inner');
|
||||||
const duration = window.TIMING?.card?.flip || 320;
|
const duration = window.TIMING?.card?.flip || 320;
|
||||||
@ -462,19 +436,16 @@ class CardAnimations {
|
|||||||
begin: () => this.playSound('flip'),
|
begin: () => this.playSound('flip'),
|
||||||
complete: () => {
|
complete: () => {
|
||||||
animCard.remove();
|
animCard.remove();
|
||||||
cardElement.style.visibility = '';
|
cardElement.style.opacity = '1';
|
||||||
if (onComplete) onComplete();
|
if (onComplete) onComplete();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register a no-op entry so cancelAll() can find and stop this animation.
|
|
||||||
// The actual anime.js instance doesn't need to be tracked (fire-and-forget),
|
|
||||||
// but we need SOMETHING in the map or cleanup won't know we're animating.
|
|
||||||
this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} });
|
this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Initial flip animation error:', e);
|
console.error('Initial flip animation error:', e);
|
||||||
animCard.remove();
|
animCard.remove();
|
||||||
cardElement.style.visibility = '';
|
cardElement.style.opacity = '1';
|
||||||
if (onComplete) onComplete();
|
if (onComplete) onComplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -779,40 +750,28 @@ class CardAnimations {
|
|||||||
const id = 'turnPulse';
|
const id = 'turnPulse';
|
||||||
this.stopTurnPulse(element);
|
this.stopTurnPulse(element);
|
||||||
|
|
||||||
// Quick shake animation - target cards only, not labels
|
// Quick shake animation
|
||||||
const T = window.TIMING?.turnPulse || {};
|
|
||||||
const cards = element.querySelectorAll(':scope > .pile-wrapper > .card, :scope > .pile-wrapper > .discard-stack > #discard');
|
|
||||||
const doShake = () => {
|
const doShake = () => {
|
||||||
if (!this.activeAnimations.has(id)) return;
|
if (!this.activeAnimations.has(id)) return;
|
||||||
|
|
||||||
anime({
|
anime({
|
||||||
targets: cards.length ? cards : element,
|
targets: element,
|
||||||
translateX: [0, -6, 6, -4, 3, 0],
|
translateX: [0, -8, 8, -6, 4, 0],
|
||||||
duration: T.duration || 300,
|
duration: 400,
|
||||||
easing: 'easeInOutQuad'
|
easing: 'easeInOutQuad'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Two-phase timing: wait initialDelay, then shake on an interval.
|
// Do initial shake, then repeat every 3 seconds
|
||||||
// Edge case: if stopTurnPulse() is called between the timeout firing and
|
doShake();
|
||||||
// the interval being stored on the entry, the interval would leak. That's
|
const interval = setInterval(doShake, 3000);
|
||||||
// why we re-check activeAnimations.has(id) after the timeout fires — if
|
this.activeAnimations.set(id, { interval });
|
||||||
// stop was called during the delay, we bail before creating the interval.
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (!this.activeAnimations.has(id)) return;
|
|
||||||
doShake();
|
|
||||||
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) {
|
stopTurnPulse(element) {
|
||||||
const id = 'turnPulse';
|
const id = 'turnPulse';
|
||||||
const existing = this.activeAnimations.get(id);
|
const existing = this.activeAnimations.get(id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.timeout) clearTimeout(existing.timeout);
|
|
||||||
if (existing.interval) clearInterval(existing.interval);
|
if (existing.interval) clearInterval(existing.interval);
|
||||||
if (existing.pause) existing.pause();
|
if (existing.pause) existing.pause();
|
||||||
this.activeAnimations.delete(id);
|
this.activeAnimations.delete(id);
|
||||||
@ -1105,7 +1064,7 @@ class CardAnimations {
|
|||||||
// heldRect: position of the held card (or null to use default holding position)
|
// heldRect: position of the held card (or null to use default holding position)
|
||||||
// options: { rotation, wasHandFaceDown, onComplete }
|
// options: { rotation, wasHandFaceDown, onComplete }
|
||||||
animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) {
|
animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) {
|
||||||
const { rotation = 0, wasHandFaceDown = false, onComplete, onStart } = options;
|
const { rotation = 0, wasHandFaceDown = false, onComplete } = options;
|
||||||
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
|
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
|
||||||
const discardRect = this.getDiscardRect();
|
const discardRect = this.getDiscardRect();
|
||||||
|
|
||||||
@ -1125,27 +1084,27 @@ class CardAnimations {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collision detection: if a draw animation is still in flight (its overlay cards
|
// Wait for any in-progress draw animation to complete
|
||||||
// are still in the DOM), we can't start the swap yet — both animations touch the
|
// Check if there's an active draw animation by looking for overlay cards
|
||||||
// same visual space. 350ms is enough for the draw to finish its arc and land.
|
|
||||||
// This happens when the server sends the swap state update before the draw
|
|
||||||
// animation's callback fires (network is faster than anime.js, sometimes).
|
|
||||||
const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]');
|
const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]');
|
||||||
if (existingDrawCards.length > 0) {
|
if (existingDrawCards.length > 0) {
|
||||||
|
// Draw animation still in progress - wait a bit and retry
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
// Clean up the draw animation overlay
|
||||||
existingDrawCards.forEach(el => {
|
existingDrawCards.forEach(el => {
|
||||||
delete el.dataset.animating;
|
delete el.dataset.animating;
|
||||||
el.remove();
|
el.remove();
|
||||||
});
|
});
|
||||||
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart);
|
// Now run the swap animation
|
||||||
}, 350);
|
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
|
||||||
|
}, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart);
|
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
_runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart) {
|
_runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete) {
|
||||||
// Create the two traveling cards
|
// Create the two traveling cards
|
||||||
const travelingHand = this.createCardFromData(handCardData, handRect, rotation);
|
const travelingHand = this.createCardFromData(handCardData, handRect, rotation);
|
||||||
const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0);
|
const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0);
|
||||||
@ -1154,9 +1113,6 @@ class CardAnimations {
|
|||||||
document.body.appendChild(travelingHand);
|
document.body.appendChild(travelingHand);
|
||||||
document.body.appendChild(travelingHeld);
|
document.body.appendChild(travelingHeld);
|
||||||
|
|
||||||
// Now that overlays cover the originals, hide them
|
|
||||||
if (onStart) onStart();
|
|
||||||
|
|
||||||
this.playSound('card');
|
this.playSound('card');
|
||||||
|
|
||||||
// If hand card was face-down, flip it first
|
// If hand card was face-down, flip it first
|
||||||
@ -1222,9 +1178,6 @@ class CardAnimations {
|
|||||||
],
|
],
|
||||||
width: discardRect.width,
|
width: discardRect.width,
|
||||||
height: discardRect.height,
|
height: discardRect.height,
|
||||||
// Counter-rotate from the card's grid tilt back to 0. The -3 intermediate
|
|
||||||
// value adds a slight overshoot that makes the arc feel physical.
|
|
||||||
// Do not "simplify" this to [rotation, 0]. It will look robotic.
|
|
||||||
rotate: [rotation, rotation - 3, 0],
|
rotate: [rotation, rotation - 3, 0],
|
||||||
duration: T.arc,
|
duration: T.arc,
|
||||||
easing: this.getEasing('arc'),
|
easing: this.getEasing('arc'),
|
||||||
@ -1562,7 +1515,6 @@ class CardAnimations {
|
|||||||
|
|
||||||
// Create container for animation cards
|
// Create container for animation cards
|
||||||
const container = document.createElement('div');
|
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;';
|
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
|||||||
@ -100,14 +100,12 @@ class CardManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the deck color class for a card based on its deck_id.
|
// Get the deck color class for a card based on its deck_id
|
||||||
// Reads from window.currentDeckColors, which app.js sets from game state.
|
|
||||||
// This global coupling is intentional — card-manager shouldn't know about
|
|
||||||
// game state directly, and passing it through every call site isn't worth it.
|
|
||||||
getDeckColorClass(cardData) {
|
getDeckColorClass(cardData) {
|
||||||
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
|
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Get deck colors from game state (set by app.js)
|
||||||
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
|
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
|
||||||
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
|
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
|
||||||
return `deck-${colorName}`;
|
return `deck-${colorName}`;
|
||||||
@ -128,10 +126,7 @@ class CardManager {
|
|||||||
cardEl.style.width = `${rect.width}px`;
|
cardEl.style.width = `${rect.width}px`;
|
||||||
cardEl.style.height = `${rect.height}px`;
|
cardEl.style.height = `${rect.height}px`;
|
||||||
|
|
||||||
// On mobile, scale font proportional to card width so rank/suit fit.
|
// On mobile, scale font proportional to card width so rank/suit fit
|
||||||
// This must stay in sync with the CSS .card font-size on desktop — if CSS
|
|
||||||
// sets a fixed size and we set an inline style, the inline wins. Clearing
|
|
||||||
// fontSize on desktop lets the CSS rule take over.
|
|
||||||
if (document.body.classList.contains('mobile-portrait')) {
|
if (document.body.classList.contains('mobile-portrait')) {
|
||||||
cardEl.style.fontSize = `${rect.width * 0.35}px`;
|
cardEl.style.fontSize = `${rect.width * 0.35}px`;
|
||||||
} else {
|
} else {
|
||||||
@ -240,9 +235,7 @@ class CardManager {
|
|||||||
await this.delay(flipDuration);
|
await this.delay(flipDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Move card to discard.
|
// Step 2: Move card to discard
|
||||||
// The +50ms buffer accounts for CSS transition timing jitter — without it,
|
|
||||||
// we occasionally remove the 'moving' class before the transition finishes.
|
|
||||||
cardEl.classList.add('moving');
|
cardEl.classList.add('moving');
|
||||||
this.positionCard(cardEl, discardRect);
|
this.positionCard(cardEl, discardRect);
|
||||||
await this.delay(duration + 50);
|
await this.delay(duration + 50);
|
||||||
|
|||||||
@ -59,9 +59,9 @@
|
|||||||
<!-- Outer edge highlight -->
|
<!-- Outer edge highlight -->
|
||||||
<circle cx="50" cy="44" r="46" fill="none" stroke="#ffffff" stroke-width="0.5" opacity="0.5"/>
|
<circle cx="50" cy="44" r="46" fill="none" stroke="#ffffff" stroke-width="0.5" opacity="0.5"/>
|
||||||
|
|
||||||
<!-- Card suits - 2x2 grid -->
|
<!-- Card suits - single row, larger -->
|
||||||
<text x="36" y="40" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♣</text>
|
<text x="22" y="51" font-family="Arial, sans-serif" font-size="32" 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="41" y="51" font-family="Arial, sans-serif" font-size="32" 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="59" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" 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>
|
<text x="77" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">♥</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
@ -16,11 +16,9 @@
|
|||||||
|
|
||||||
<!-- Lobby Screen -->
|
<!-- Lobby Screen -->
|
||||||
<div id="lobby-screen" class="screen active">
|
<div id="lobby-screen" class="screen active">
|
||||||
<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>
|
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span> <span class="golf-title">Golf</span></h1>
|
||||||
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
|
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
|
||||||
|
|
||||||
<div class="alpha-banner">Beta Testing - Bear with us while, stuff.</div>
|
|
||||||
|
|
||||||
<!-- Auth prompt for unauthenticated users -->
|
<!-- Auth prompt for unauthenticated users -->
|
||||||
<div id="auth-prompt" class="auth-prompt">
|
<div id="auth-prompt" class="auth-prompt">
|
||||||
<p>Log in or sign up to play.</p>
|
<p>Log in or sign up to play.</p>
|
||||||
@ -53,8 +51,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="lobby-error" class="error"></p>
|
<p id="lobby-error" class="error"></p>
|
||||||
|
|
||||||
<footer class="app-footer">v3.1.6 © Aaron D. Lee</footer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Matchmaking Screen -->
|
<!-- Matchmaking Screen -->
|
||||||
@ -82,16 +78,16 @@
|
|||||||
<div class="waiting-layout">
|
<div class="waiting-layout">
|
||||||
<div class="waiting-left-col">
|
<div class="waiting-left-col">
|
||||||
<div class="players-list">
|
<div class="players-list">
|
||||||
<div class="players-list-header">
|
<h3>Players</h3>
|
||||||
<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>
|
<ul id="players-list"></ul>
|
||||||
</div>
|
</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>
|
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -286,8 +282,6 @@
|
|||||||
|
|
||||||
<p id="waiting-message" class="info">Waiting for host to start the game...</p>
|
<p id="waiting-message" class="info">Waiting for host to start the game...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="app-footer">v3.1.6 © Aaron D. Lee</footer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Game Screen -->
|
<!-- Game Screen -->
|
||||||
@ -309,6 +303,7 @@
|
|||||||
<div id="final-turn-badge" class="final-turn-badge hidden">
|
<div id="final-turn-badge" class="final-turn-badge hidden">
|
||||||
<span class="final-turn-icon">⚡</span>
|
<span class="final-turn-icon">⚡</span>
|
||||||
<span class="final-turn-text">FINAL TURN</span>
|
<span class="final-turn-text">FINAL TURN</span>
|
||||||
|
<span class="final-turn-remaining"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-col header-col-right">
|
<div class="header-col header-col-right">
|
||||||
@ -332,24 +327,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="held-label">Holding</span>
|
<span class="held-label">Holding</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pile-wrapper">
|
<div id="deck" class="card card-back"></div>
|
||||||
<span class="pile-label">DRAW</span>
|
<div class="discard-stack">
|
||||||
<div id="deck" class="card card-back"></div>
|
<div id="discard" class="card">
|
||||||
</div>
|
<span id="discard-content"></span>
|
||||||
<div class="pile-wrapper">
|
|
||||||
<span class="pile-label">DISCARD</span>
|
|
||||||
<div class="discard-stack">
|
|
||||||
<div id="discard" class="card">
|
|
||||||
<span id="discard-content"></span>
|
|
||||||
</div>
|
|
||||||
<!-- Floating held card (appears larger over discard when holding) -->
|
|
||||||
<div id="held-card-floating" class="card card-front held-card-floating hidden">
|
|
||||||
<span id="held-card-floating-content"></span>
|
|
||||||
</div>
|
|
||||||
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
|
|
||||||
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
|
|
||||||
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Floating held card (appears larger over discard when holding) -->
|
||||||
|
<div id="held-card-floating" class="card card-front held-card-floating hidden">
|
||||||
|
<span id="held-card-floating-content"></span>
|
||||||
|
</div>
|
||||||
|
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
|
||||||
|
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
|
||||||
|
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -408,23 +397,16 @@
|
|||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile bottom bar (hidden on desktop) -->
|
<!-- Mobile bottom bar (hidden on desktop) -->
|
||||||
<div id="mobile-bottom-bar">
|
<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>
|
<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">Standings</button>
|
||||||
<button class="mobile-bar-btn" data-drawer="standings-panel">Scorecard</button>
|
<button class="mobile-bar-btn" data-drawer="scoreboard">Scores</button>
|
||||||
<button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button>
|
<button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button>
|
||||||
</div>
|
</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 -->
|
<!-- Drawer backdrop for mobile -->
|
||||||
<div id="drawer-backdrop" class="drawer-backdrop"></div>
|
<div id="drawer-backdrop" class="drawer-backdrop"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -432,8 +414,9 @@
|
|||||||
<!-- Rules Screen -->
|
<!-- Rules Screen -->
|
||||||
<div id="rules-screen" class="screen">
|
<div id="rules-screen" class="screen">
|
||||||
<div class="rules-container">
|
<div class="rules-container">
|
||||||
|
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
|
||||||
|
|
||||||
<div class="rules-header">
|
<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>
|
<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>
|
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
|
||||||
</div>
|
</div>
|
||||||
@ -749,8 +732,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
|||||||
<!-- Leaderboard Screen -->
|
<!-- Leaderboard Screen -->
|
||||||
<div id="leaderboard-screen" class="screen">
|
<div id="leaderboard-screen" class="screen">
|
||||||
<div class="leaderboard-container">
|
<div class="leaderboard-container">
|
||||||
|
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">« Back</button>
|
||||||
|
|
||||||
<div class="leaderboard-header">
|
<div class="leaderboard-header">
|
||||||
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">« Back</button>
|
|
||||||
<h1>Leaderboard</h1>
|
<h1>Leaderboard</h1>
|
||||||
<p class="leaderboard-subtitle">Top players ranked by performance</p>
|
<p class="leaderboard-subtitle">Top players ranked by performance</p>
|
||||||
</div>
|
</div>
|
||||||
@ -893,9 +877,8 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
|||||||
<div id="signup-form-container" class="hidden">
|
<div id="signup-form-container" class="hidden">
|
||||||
<h3>Sign Up</h3>
|
<h3>Sign Up</h3>
|
||||||
<form id="signup-form">
|
<form id="signup-form">
|
||||||
<div class="form-group" id="invite-code-group">
|
<div class="form-group">
|
||||||
<input type="text" id="signup-invite-code" placeholder="Invite Code">
|
<input type="text" id="signup-invite-code" placeholder="Invite Code" required>
|
||||||
<small id="invite-code-hint" class="form-hint"></small>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
|
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
|
||||||
|
|||||||
601
client/style.css
601
client/style.css
File diff suppressed because it is too large
Load Diff
@ -77,7 +77,6 @@ const TIMING = {
|
|||||||
|
|
||||||
// V3_03: Round end reveal timing
|
// V3_03: Round end reveal timing
|
||||||
reveal: {
|
reveal: {
|
||||||
lastPlayPause: 2000, // Pause after last play animation before reveals
|
|
||||||
voluntaryWindow: 2000, // Time for players to flip their own cards
|
voluntaryWindow: 2000, // Time for players to flip their own cards
|
||||||
initialPause: 250, // Pause before auto-reveals start
|
initialPause: 250, // Pause before auto-reveals start
|
||||||
cardStagger: 50, // Between cards in same hand
|
cardStagger: 50, // Between cards in same hand
|
||||||
@ -129,13 +128,6 @@ const TIMING = {
|
|||||||
pulseDelay: 200, // Delay before card appears (pulse visible first)
|
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
|
// V3_17: Knock notification
|
||||||
knock: {
|
knock: {
|
||||||
statusDuration: 2500, // How long the knock status message persists
|
statusDuration: 2500, // How long the knock status message persists
|
||||||
|
|||||||
@ -28,19 +28,11 @@ services:
|
|||||||
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||||
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
|
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
|
||||||
- SENTRY_DSN=${SENTRY_DSN:-}
|
- SENTRY_DSN=${SENTRY_DSN:-}
|
||||||
- ENVIRONMENT=${ENVIRONMENT:-production}
|
- ENVIRONMENT=production
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-WARNING}
|
- 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}
|
- BASE_URL=${BASE_URL:-https://golf.example.com}
|
||||||
- RATE_LIMIT_ENABLED=true
|
- RATE_LIMIT_ENABLED=true
|
||||||
- INVITE_ONLY=true
|
- INVITE_ONLY=true
|
||||||
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
|
|
||||||
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
|
|
||||||
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
|
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
|
||||||
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
|
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
- MATCHMAKING_ENABLED=true
|
- MATCHMAKING_ENABLED=true
|
||||||
@ -69,15 +61,6 @@ services:
|
|||||||
- "traefik.http.routers.golf.entrypoints=websecure"
|
- "traefik.http.routers.golf.entrypoints=websecure"
|
||||||
- "traefik.http.routers.golf.tls=true"
|
- "traefik.http.routers.golf.tls=true"
|
||||||
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
|
- "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"
|
- "traefik.http.services.golf.loadbalancer.server.port=8000"
|
||||||
# WebSocket sticky sessions
|
# WebSocket sticky sessions
|
||||||
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"
|
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"
|
||||||
|
|||||||
@ -1,148 +0,0 @@
|
|||||||
# 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
|
|
||||||
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
|
|
||||||
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
|
|
||||||
- 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
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# V3.17: Mobile Portrait Layout
|
# V3.17: Mobile Portrait Layout
|
||||||
|
|
||||||
**Version:** 3.1.6
|
**Version:** 3.1.1
|
||||||
**Commits:** `4fcdf13`, `fb3bd53`
|
**Commits:** `4fcdf13`, `fb3bd53`
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
# 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]
|
[project]
|
||||||
name = "golfgame"
|
name = "golfgame"
|
||||||
version = "3.1.6"
|
version = "3.1.1"
|
||||||
description = "6-Card Golf card game with AI opponents"
|
description = "6-Card Golf card game with AI opponents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
@ -7,24 +7,6 @@ PORT=8000
|
|||||||
DEBUG=true
|
DEBUG=true
|
||||||
LOG_LEVEL=DEBUG
|
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)
|
# Environment (development, staging, production)
|
||||||
# Affects logging format, security headers (HSTS), etc.
|
# Affects logging format, security headers (HSTS), etc.
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
|
|||||||
101
server/ai.py
101
server/ai.py
@ -43,9 +43,8 @@ CPU_TIMING = {
|
|||||||
# Delay before CPU "looks at" the discard pile
|
# Delay before CPU "looks at" the discard pile
|
||||||
"initial_look": (0.3, 0.5),
|
"initial_look": (0.3, 0.5),
|
||||||
# Brief pause after draw broadcast - let draw animation complete
|
# Brief pause after draw broadcast - let draw animation complete
|
||||||
# Must be >= client draw animation duration (~1.09s for deck, ~0.4s for discard)
|
# Must be >= client draw animation duration (~1s for deck, ~0.4s for discard)
|
||||||
# Extra margin prevents swap message from arriving before draw flip completes
|
"post_draw_settle": 1.1,
|
||||||
"post_draw_settle": 1.3,
|
|
||||||
# Consideration time after drawing (before swap/discard decision)
|
# Consideration time after drawing (before swap/discard decision)
|
||||||
"post_draw_consider": (0.2, 0.4),
|
"post_draw_consider": (0.2, 0.4),
|
||||||
# Variance multiplier range for chaotic personality players
|
# Variance multiplier range for chaotic personality players
|
||||||
@ -55,15 +54,17 @@ CPU_TIMING = {
|
|||||||
"post_action_pause": (0.5, 0.7),
|
"post_action_pause": (0.5, 0.7),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Thinking time ranges by card difficulty (seconds).
|
# Thinking time ranges by card difficulty (seconds)
|
||||||
# Yes, these are all identical. That's intentional — the categories exist so we
|
|
||||||
# CAN tune them independently later, but right now a uniform 0.15-0.3s feels
|
|
||||||
# natural enough. The structure is the point, not the current values.
|
|
||||||
THINKING_TIME = {
|
THINKING_TIME = {
|
||||||
|
# Obviously good cards (Jokers, Kings, 2s, Aces) - easy take
|
||||||
"easy_good": (0.15, 0.3),
|
"easy_good": (0.15, 0.3),
|
||||||
|
# Obviously bad cards (10s, Jacks, Queens) - easy pass
|
||||||
"easy_bad": (0.15, 0.3),
|
"easy_bad": (0.15, 0.3),
|
||||||
|
# Medium difficulty (3, 4, 8, 9)
|
||||||
"medium": (0.15, 0.3),
|
"medium": (0.15, 0.3),
|
||||||
|
# Hardest decisions (5, 6, 7 - middle of range)
|
||||||
"hard": (0.15, 0.3),
|
"hard": (0.15, 0.3),
|
||||||
|
# No discard available - quick decision
|
||||||
"no_card": (0.15, 0.3),
|
"no_card": (0.15, 0.3),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -798,9 +799,7 @@ class GolfAI:
|
|||||||
ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)")
|
ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Take card if it could make a column pair (but NOT for negative value cards).
|
# Take card if it could make a column pair (but NOT for negative value cards)
|
||||||
# Why exclude negatives: a Joker (-2) paired in a column scores 0, which is
|
|
||||||
# worse than keeping it unpaired at -2. Same logic for 2s with default values.
|
|
||||||
if discard_value > 0:
|
if discard_value > 0:
|
||||||
for i, card in enumerate(player.cards):
|
for i, card in enumerate(player.cards):
|
||||||
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
||||||
@ -1031,11 +1030,7 @@ class GolfAI:
|
|||||||
if not creates_negative_pair:
|
if not creates_negative_pair:
|
||||||
expected_hidden = EXPECTED_HIDDEN_VALUE
|
expected_hidden = EXPECTED_HIDDEN_VALUE
|
||||||
point_gain = expected_hidden - drawn_value
|
point_gain = expected_hidden - drawn_value
|
||||||
# Personality discount: swap_threshold ranges 0-8, so this maps to 0.5-1.0.
|
discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0
|
||||||
# Conservative players (low threshold) discount heavily — they need a bigger
|
|
||||||
# point gain to justify swapping into the unknown. Aggressive players take
|
|
||||||
# the swap at closer to face value.
|
|
||||||
discount = 0.5 + (profile.swap_threshold / 16)
|
|
||||||
return point_gain * discount
|
return point_gain * discount
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
@ -1256,6 +1251,8 @@ class GolfAI:
|
|||||||
"""If player has exactly 1 face-down card, decide the best go-out swap.
|
"""If player has exactly 1 face-down card, decide the best go-out swap.
|
||||||
|
|
||||||
Returns position to swap into, or None to fall through to normal scoring.
|
Returns position to swap into, or None to fall through to normal scoring.
|
||||||
|
Uses a sentinel value of -1 (converted to None by caller) is not needed -
|
||||||
|
we return None to indicate "no early decision, continue normal flow".
|
||||||
"""
|
"""
|
||||||
options = game.options
|
options = game.options
|
||||||
face_down_positions = hidden_positions(player)
|
face_down_positions = hidden_positions(player)
|
||||||
@ -1304,28 +1301,12 @@ class GolfAI:
|
|||||||
|
|
||||||
max_acceptable_go_out = 14 + int(profile.aggression * 4)
|
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}, "
|
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"score_if_swap={score_if_swap}, score_if_flip={score_if_flip}, "
|
||||||
f"max_acceptable={max_acceptable_go_out}, opponent_min={opponent_min}, "
|
f"max_acceptable={max_acceptable_go_out}")
|
||||||
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 BOTH options are bad, choose the better one
|
||||||
if score_if_swap > effective_max and score_if_flip > effective_max:
|
if score_if_swap > max_acceptable_go_out and score_if_flip > max_acceptable_go_out:
|
||||||
if score_if_swap <= score_if_flip:
|
if score_if_swap <= score_if_flip:
|
||||||
ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) "
|
ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) "
|
||||||
f"<= flip ({score_if_flip}), forcing swap")
|
f"<= flip ({score_if_flip}), forcing swap")
|
||||||
@ -1341,7 +1322,7 @@ class GolfAI:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# If swap is good, prefer it (known outcome vs unknown flip)
|
# If swap is good, prefer it (known outcome vs unknown flip)
|
||||||
elif score_if_swap <= effective_max and score_if_swap <= score_if_flip:
|
elif score_if_swap <= max_acceptable_go_out and score_if_swap <= score_if_flip:
|
||||||
ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}")
|
ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}")
|
||||||
return last_pos
|
return last_pos
|
||||||
|
|
||||||
@ -1363,11 +1344,7 @@ class GolfAI:
|
|||||||
if not face_down or random.random() >= 0.5:
|
if not face_down or random.random() >= 0.5:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# SAFETY: Don't randomly go out with a bad score.
|
# SAFETY: Don't randomly go out with a bad score
|
||||||
# This duplicates some logic from project_score() on purpose — project_score()
|
|
||||||
# is designed for strategic decisions with weighted estimates, but here we need
|
|
||||||
# a hard pass/fail check with exact pair math. Close enough isn't good enough
|
|
||||||
# when the downside is accidentally ending the round at 30 points.
|
|
||||||
if len(face_down) == 1:
|
if len(face_down) == 1:
|
||||||
last_pos = face_down[0]
|
last_pos = face_down[0]
|
||||||
projected = drawn_value
|
projected = drawn_value
|
||||||
@ -1762,23 +1739,9 @@ class GolfAI:
|
|||||||
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
|
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
|
||||||
projected_score = visible_score + expected_hidden_total
|
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
|
# Tighter threshold: range 5 to 9 based on aggression
|
||||||
max_acceptable = 5 + int(profile.aggression * 4)
|
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
|
# Exception: if all opponents are showing terrible scores, relax threshold
|
||||||
all_opponents_bad = all(
|
all_opponents_bad = all(
|
||||||
sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25
|
sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25
|
||||||
@ -1789,14 +1752,12 @@ class GolfAI:
|
|||||||
|
|
||||||
if projected_score <= max_acceptable:
|
if projected_score <= max_acceptable:
|
||||||
# Scale knock chance by how good the projected score is
|
# Scale knock chance by how good the projected score is
|
||||||
if projected_score <= 4:
|
if projected_score <= 5:
|
||||||
knock_chance = profile.aggression * 0.35 # Max 35%
|
knock_chance = profile.aggression * 0.3 # Max 30%
|
||||||
elif projected_score <= 6:
|
elif projected_score <= 7:
|
||||||
knock_chance = profile.aggression * 0.15 # Max 15%
|
knock_chance = profile.aggression * 0.15 # Max 15%
|
||||||
elif projected_score <= 8:
|
else:
|
||||||
knock_chance = profile.aggression * 0.06 # Max 6%
|
knock_chance = profile.aggression * 0.05 # Max 5% (very rare)
|
||||||
else: # 9-10
|
|
||||||
knock_chance = profile.aggression * 0.02 # Max 2% (very rare)
|
|
||||||
|
|
||||||
if random.random() < knock_chance:
|
if random.random() < knock_chance:
|
||||||
ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})")
|
ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})")
|
||||||
@ -1971,14 +1932,9 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga
|
|||||||
|
|
||||||
|
|
||||||
async def process_cpu_turn(
|
async def process_cpu_turn(
|
||||||
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None,
|
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
|
||||||
reveal_callback=None,
|
|
||||||
) -> 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
|
import asyncio
|
||||||
from services.game_logger import get_logger
|
from services.game_logger import get_logger
|
||||||
|
|
||||||
@ -2006,8 +1962,10 @@ async def process_cpu_turn(
|
|||||||
await asyncio.sleep(thinking_time)
|
await asyncio.sleep(thinking_time)
|
||||||
ai_log(f"{cpu_player.name} done thinking, making decision")
|
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)
|
# 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 GolfAI.should_knock_early(game, cpu_player, profile):
|
||||||
if game.knock_early(cpu_player.id):
|
if game.knock_early(cpu_player.id):
|
||||||
_log_cpu_action(logger, game_id, cpu_player, game,
|
_log_cpu_action(logger, game_id, cpu_player, game,
|
||||||
@ -2090,13 +2048,6 @@ async def process_cpu_turn(
|
|||||||
|
|
||||||
if swap_pos is not None:
|
if swap_pos is not None:
|
||||||
old_card = cpu_player.cards[swap_pos]
|
old_card = cpu_player.cards[swap_pos]
|
||||||
# Reveal the face-down card before swapping
|
|
||||||
if not old_card.face_up and reveal_callback:
|
|
||||||
await reveal_callback(
|
|
||||||
cpu_player.id, swap_pos,
|
|
||||||
{"rank": old_card.rank.value, "suit": old_card.suit.value},
|
|
||||||
)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
game.swap_card(cpu_player.id, swap_pos)
|
game.swap_card(cpu_player.id, swap_pos)
|
||||||
_log_cpu_action(logger, game_id, cpu_player, game,
|
_log_cpu_action(logger, game_id, cpu_player, game,
|
||||||
action="swap", card=drawn, position=swap_pos,
|
action="swap", card=drawn, position=swap_pos,
|
||||||
|
|||||||
@ -142,18 +142,11 @@ class ServerConfig:
|
|||||||
MAX_PLAYERS_PER_ROOM: int = 6
|
MAX_PLAYERS_PER_ROOM: int = 6
|
||||||
ROOM_TIMEOUT_MINUTES: int = 60
|
ROOM_TIMEOUT_MINUTES: int = 60
|
||||||
ROOM_CODE_LENGTH: int = 4
|
ROOM_CODE_LENGTH: int = 4
|
||||||
ROOM_IDLE_TIMEOUT_SECONDS: int = 300 # 5 minutes of inactivity
|
|
||||||
|
|
||||||
# Security (for future auth system)
|
# Security (for future auth system)
|
||||||
SECRET_KEY: str = ""
|
SECRET_KEY: str = ""
|
||||||
INVITE_ONLY: bool = True
|
INVITE_ONLY: bool = True
|
||||||
|
|
||||||
# Metered open signups (public beta)
|
|
||||||
# 0 = disabled (invite-only), -1 = unlimited, N = max per day
|
|
||||||
DAILY_OPEN_SIGNUPS: int = 0
|
|
||||||
# Max signups per IP per day (0 = unlimited)
|
|
||||||
DAILY_SIGNUPS_PER_IP: int = 3
|
|
||||||
|
|
||||||
# Bootstrap admin (for first-time setup when INVITE_ONLY=true)
|
# Bootstrap admin (for first-time setup when INVITE_ONLY=true)
|
||||||
BOOTSTRAP_ADMIN_USERNAME: str = ""
|
BOOTSTRAP_ADMIN_USERNAME: str = ""
|
||||||
BOOTSTRAP_ADMIN_PASSWORD: str = ""
|
BOOTSTRAP_ADMIN_PASSWORD: str = ""
|
||||||
@ -199,11 +192,8 @@ class ServerConfig:
|
|||||||
MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6),
|
MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6),
|
||||||
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
|
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
|
||||||
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
||||||
ROOM_IDLE_TIMEOUT_SECONDS=get_env_int("ROOM_IDLE_TIMEOUT_SECONDS", 300),
|
|
||||||
SECRET_KEY=get_env("SECRET_KEY", ""),
|
SECRET_KEY=get_env("SECRET_KEY", ""),
|
||||||
INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
|
INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
|
||||||
DAILY_OPEN_SIGNUPS=get_env_int("DAILY_OPEN_SIGNUPS", 0),
|
|
||||||
DAILY_SIGNUPS_PER_IP=get_env_int("DAILY_SIGNUPS_PER_IP", 3),
|
|
||||||
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
|
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
|
||||||
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
|
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
|
||||||
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
|
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
|
||||||
|
|||||||
@ -358,13 +358,6 @@ class Player:
|
|||||||
jack_pairs = 0 # Track paired Jacks for Wolfpack bonus
|
jack_pairs = 0 # Track paired Jacks for Wolfpack bonus
|
||||||
paired_ranks: list[Rank] = [] # Track all paired ranks for four-of-a-kind
|
paired_ranks: list[Rank] = [] # Track all paired ranks for four-of-a-kind
|
||||||
|
|
||||||
# Evaluation order matters here. We check special-case pairs BEFORE the
|
|
||||||
# default "pairs cancel to 0" rule, because house rules can override that:
|
|
||||||
# 1. Eagle Eye joker pairs -> -4 (better than 0, exit early)
|
|
||||||
# 2. Negative pairs keep value -> sum of negatives (worse than 0, exit early)
|
|
||||||
# 3. Normal pairs -> 0 (skip both cards)
|
|
||||||
# 4. Non-matching -> sum both values
|
|
||||||
# Bonuses (wolfpack, four-of-a-kind) are applied after all columns are scored.
|
|
||||||
for col in range(3):
|
for col in range(3):
|
||||||
top_idx = col
|
top_idx = col
|
||||||
bottom_idx = col + 3
|
bottom_idx = col + 3
|
||||||
@ -782,17 +775,9 @@ class Game:
|
|||||||
for i, player in enumerate(self.players):
|
for i, player in enumerate(self.players):
|
||||||
if player.id == player_id:
|
if player.id == player_id:
|
||||||
removed = self.players.pop(i)
|
removed = self.players.pop(i)
|
||||||
if self.players:
|
# Adjust dealer_idx if needed after removal
|
||||||
# Adjust dealer_idx if needed after removal
|
if self.players and self.dealer_idx >= len(self.players):
|
||||||
if self.dealer_idx >= len(self.players):
|
self.dealer_idx = 0
|
||||||
self.dealer_idx = 0
|
|
||||||
# Adjust current_player_index after removal
|
|
||||||
if i < self.current_player_index:
|
|
||||||
# Removed player was before current: shift back
|
|
||||||
self.current_player_index -= 1
|
|
||||||
elif self.current_player_index >= len(self.players):
|
|
||||||
# Removed player was at/after current and index is now OOB
|
|
||||||
self.current_player_index = 0
|
|
||||||
self._emit("player_left", player_id=player_id, reason=reason)
|
self._emit("player_left", player_id=player_id, reason=reason)
|
||||||
return removed
|
return removed
|
||||||
return None
|
return None
|
||||||
@ -815,8 +800,6 @@ class Game:
|
|||||||
def current_player(self) -> Optional[Player]:
|
def current_player(self) -> Optional[Player]:
|
||||||
"""Get the player whose turn it currently is."""
|
"""Get the player whose turn it currently is."""
|
||||||
if self.players:
|
if self.players:
|
||||||
if self.current_player_index >= len(self.players):
|
|
||||||
self.current_player_index = self.current_player_index % len(self.players)
|
|
||||||
return self.players[self.current_player_index]
|
return self.players[self.current_player_index]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -949,8 +932,7 @@ class Game:
|
|||||||
if self.current_round > 1:
|
if self.current_round > 1:
|
||||||
self.dealer_idx = (self.dealer_idx + 1) % len(self.players)
|
self.dealer_idx = (self.dealer_idx + 1) % len(self.players)
|
||||||
|
|
||||||
# "Left of dealer goes first" — standard card game convention.
|
# First player is to the left of dealer (next in order)
|
||||||
# In our circular list, "left" is the next index.
|
|
||||||
self.current_player_index = (self.dealer_idx + 1) % len(self.players)
|
self.current_player_index = (self.dealer_idx + 1) % len(self.players)
|
||||||
|
|
||||||
# Emit round_started event with deck seed and all dealt cards
|
# Emit round_started event with deck seed and all dealt cards
|
||||||
@ -1433,9 +1415,6 @@ class Game:
|
|||||||
Args:
|
Args:
|
||||||
player: The player whose turn just ended.
|
player: The player whose turn just ended.
|
||||||
"""
|
"""
|
||||||
# This method and _next_turn() are tightly coupled. _check_end_turn populates
|
|
||||||
# players_with_final_turn BEFORE calling _next_turn(), which reads it to decide
|
|
||||||
# whether the round is over. Reordering these calls will break end-of-round logic.
|
|
||||||
if player.all_face_up() and self.finisher_id is None:
|
if player.all_face_up() and self.finisher_id is None:
|
||||||
self.finisher_id = player.id
|
self.finisher_id = player.id
|
||||||
self.phase = GamePhase.FINAL_TURN
|
self.phase = GamePhase.FINAL_TURN
|
||||||
@ -1452,8 +1431,7 @@ class Game:
|
|||||||
Advance to the next player's turn.
|
Advance to the next player's turn.
|
||||||
|
|
||||||
In FINAL_TURN phase, tracks which players have had their final turn
|
In FINAL_TURN phase, tracks which players have had their final turn
|
||||||
and ends the round when everyone has played. Depends on _check_end_turn()
|
and ends the round when everyone has played.
|
||||||
having already added the current player to players_with_final_turn.
|
|
||||||
"""
|
"""
|
||||||
if self.phase == GamePhase.FINAL_TURN:
|
if self.phase == GamePhase.FINAL_TURN:
|
||||||
next_index = (self.current_player_index + 1) % len(self.players)
|
next_index = (self.current_player_index + 1) % len(self.players)
|
||||||
@ -1496,10 +1474,6 @@ class Game:
|
|||||||
player.calculate_score(self.options)
|
player.calculate_score(self.options)
|
||||||
|
|
||||||
# --- Apply House Rule Bonuses/Penalties ---
|
# --- Apply House Rule Bonuses/Penalties ---
|
||||||
# Order matters. Blackjack converts 21->0 first, so knock penalty checks
|
|
||||||
# against the post-blackjack score. Knock penalty before knock bonus so they
|
|
||||||
# can stack (you get penalized AND rewarded, net +5). Underdog before tied shame
|
|
||||||
# so the -3 bonus can create new ties that then get punished. It's mean by design.
|
|
||||||
|
|
||||||
# Blackjack: exact score of 21 becomes 0
|
# Blackjack: exact score of 21 becomes 0
|
||||||
if self.options.blackjack:
|
if self.options.blackjack:
|
||||||
@ -1623,10 +1597,6 @@ class Game:
|
|||||||
"""
|
"""
|
||||||
current = self.current_player()
|
current = self.current_player()
|
||||||
|
|
||||||
# Card visibility has three cases:
|
|
||||||
# 1. Round/game over: all cards revealed to everyone (reveal=True)
|
|
||||||
# 2. Your own cards: always revealed to you (is_self=True)
|
|
||||||
# 3. Opponent cards mid-game: only face-up cards shown, hidden cards are redacted
|
|
||||||
players_data = []
|
players_data = []
|
||||||
for player in self.players:
|
for player in self.players:
|
||||||
reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER)
|
reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER)
|
||||||
|
|||||||
@ -69,7 +69,6 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
|
|||||||
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
||||||
room = room_manager.create_room()
|
room = room_manager.create_room()
|
||||||
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||||
room.touch()
|
|
||||||
ctx.current_room = room
|
ctx.current_room = room
|
||||||
|
|
||||||
await ctx.websocket.send_json({
|
await ctx.websocket.send_json({
|
||||||
@ -115,7 +114,6 @@ async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager,
|
|||||||
return
|
return
|
||||||
|
|
||||||
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||||
room.touch()
|
|
||||||
ctx.current_room = room
|
ctx.current_room = room
|
||||||
|
|
||||||
await ctx.websocket.send_json({
|
await ctx.websocket.send_json({
|
||||||
@ -191,7 +189,6 @@ async def handle_remove_cpu(data: dict, ctx: ConnectionContext, **kw) -> None:
|
|||||||
async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||||
if not room_player or not room_player.is_host:
|
if not room_player or not room_player.is_host:
|
||||||
@ -232,19 +229,18 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
|
|||||||
"game_state": game_state,
|
"game_state": game_state,
|
||||||
})
|
})
|
||||||
|
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await 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:
|
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
positions = data.get("positions", [])
|
positions = data.get("positions", [])
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
|
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -254,7 +250,6 @@ async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_g
|
|||||||
async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
source = data.get("source", "deck")
|
source = data.get("source", "deck")
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
@ -282,7 +277,6 @@ async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
|
|||||||
async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
position = data.get("position", 0)
|
position = data.get("position", 0)
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
@ -290,18 +284,6 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
|
|||||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||||
old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None
|
old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None
|
||||||
|
|
||||||
# Capture old card info BEFORE the swap mutates the player's hand.
|
|
||||||
# game.swap_card() overwrites player.cards[position] in place, so if we
|
|
||||||
# read it after, we'd get the new card. The client needs the old card data
|
|
||||||
# to animate the outgoing card correctly.
|
|
||||||
old_was_face_down = old_card and not old_card.face_up if old_card else False
|
|
||||||
old_card_data = None
|
|
||||||
if old_card and old_was_face_down:
|
|
||||||
old_card_data = {
|
|
||||||
"rank": old_card.rank.value if old_card.rank else None,
|
|
||||||
"suit": old_card.suit.value if old_card.suit else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
discarded = ctx.current_room.game.swap_card(ctx.player_id, position)
|
discarded = ctx.current_room.game.swap_card(ctx.player_id, position)
|
||||||
|
|
||||||
if discarded:
|
if discarded:
|
||||||
@ -315,13 +297,12 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
|
|||||||
|
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await 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:
|
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
drawn_card = ctx.current_room.game.drawn_card
|
drawn_card = ctx.current_room.game.drawn_card
|
||||||
@ -348,12 +329,12 @@ async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_s
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
else:
|
else:
|
||||||
logger.debug("Player discarded, waiting 0.5s before CPU turn")
|
logger.debug("Player discarded, waiting 0.5s before CPU turn")
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
logger.debug("Post-discard delay complete, checking for CPU turn")
|
logger.debug("Post-discard delay complete, checking for CPU turn")
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
|
|
||||||
|
|
||||||
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
||||||
@ -368,7 +349,6 @@ async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_ga
|
|||||||
async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
position = data.get("position", 0)
|
position = data.get("position", 0)
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
@ -384,13 +364,12 @@ async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game
|
|||||||
)
|
)
|
||||||
|
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await 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:
|
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||||
@ -401,13 +380,12 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game
|
|||||||
)
|
)
|
||||||
|
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await 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:
|
async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
position = data.get("position", 0)
|
position = data.get("position", 0)
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
@ -422,13 +400,12 @@ async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast
|
|||||||
)
|
)
|
||||||
|
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await 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:
|
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||||
@ -441,13 +418,12 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga
|
|||||||
)
|
)
|
||||||
|
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await 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:
|
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||||
if not room_player or not room_player.is_host:
|
if not room_player or not room_player.is_host:
|
||||||
@ -467,7 +443,7 @@ async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_gam
|
|||||||
"game_state": game_state,
|
"game_state": game_state,
|
||||||
})
|
})
|
||||||
|
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
else:
|
else:
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
|
|
||||||
@ -491,22 +467,12 @@ async def handle_leave_game(data: dict, ctx: ConnectionContext, *, handle_player
|
|||||||
async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None:
|
async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||||
if not room_player or not room_player.is_host:
|
if not room_player or not room_player.is_host:
|
||||||
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
|
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
|
||||||
return
|
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({
|
await ctx.current_room.broadcast({
|
||||||
"type": "game_ended",
|
"type": "game_ended",
|
||||||
"reason": "Host ended the game",
|
"reason": "Host ended the game",
|
||||||
|
|||||||
@ -148,39 +148,6 @@ class DevelopmentFormatter(logging.Formatter):
|
|||||||
return output
|
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(
|
def setup_logging(
|
||||||
level: str = "INFO",
|
level: str = "INFO",
|
||||||
environment: str = "development",
|
environment: str = "development",
|
||||||
@ -215,19 +182,12 @@ def setup_logging(
|
|||||||
logging.getLogger("websockets").setLevel(logging.WARNING)
|
logging.getLogger("websockets").setLevel(logging.WARNING)
|
||||||
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
|
||||||
# Apply per-module overrides from env vars
|
|
||||||
overrides = _apply_module_overrides()
|
|
||||||
|
|
||||||
# Log startup
|
# Log startup
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Logging configured: level={level}, environment={environment}",
|
f"Logging configured: level={level}, environment={environment}",
|
||||||
extra={"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):
|
class ContextLogger(logging.LoggerAdapter):
|
||||||
|
|||||||
211
server/main.py
211
server/main.py
@ -64,7 +64,6 @@ _matchmaking_service = None
|
|||||||
_replay_service = None
|
_replay_service = None
|
||||||
_spectator_manager = None
|
_spectator_manager = None
|
||||||
_leaderboard_refresh_task = None
|
_leaderboard_refresh_task = None
|
||||||
_room_cleanup_task = None
|
|
||||||
_redis_client = None
|
_redis_client = None
|
||||||
_rate_limiter = None
|
_rate_limiter = None
|
||||||
_shutdown_event = asyncio.Event()
|
_shutdown_event = asyncio.Event()
|
||||||
@ -84,74 +83,8 @@ async def _periodic_leaderboard_refresh():
|
|||||||
logger.error(f"Leaderboard refresh failed: {e}")
|
logger.error(f"Leaderboard refresh failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def _periodic_room_cleanup():
|
|
||||||
"""Periodic task to clean up rooms idle for longer than ROOM_IDLE_TIMEOUT_SECONDS."""
|
|
||||||
import time
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
now = time.time()
|
|
||||||
timeout = config.ROOM_IDLE_TIMEOUT_SECONDS
|
|
||||||
stale_rooms = [
|
|
||||||
room for room in room_manager.rooms.values()
|
|
||||||
if now - room.last_activity > timeout
|
|
||||||
]
|
|
||||||
for room in stale_rooms:
|
|
||||||
logger.info(
|
|
||||||
f"Cleaning up stale room {room.code} "
|
|
||||||
f"(idle {int(now - room.last_activity)}s, "
|
|
||||||
f"{len(room.players)} players)"
|
|
||||||
)
|
|
||||||
# Cancel CPU turn task
|
|
||||||
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
|
|
||||||
|
|
||||||
# Notify and close human WebSocket connections
|
|
||||||
for player in list(room.players.values()):
|
|
||||||
if player.websocket and not player.is_cpu:
|
|
||||||
try:
|
|
||||||
await player.websocket.send_json({
|
|
||||||
"type": "room_expired",
|
|
||||||
"message": "Room closed due to inactivity",
|
|
||||||
})
|
|
||||||
await player.websocket.close(code=4002, reason="Room expired")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Mark game as abandoned in DB
|
|
||||||
if room.game_log_id:
|
|
||||||
try:
|
|
||||||
async with _user_store.pool.acquire() as conn:
|
|
||||||
await conn.execute(
|
|
||||||
"UPDATE games_v2 SET status = 'abandoned', completed_at = NOW() WHERE id = $1 AND status = 'active'",
|
|
||||||
room.game_log_id,
|
|
||||||
)
|
|
||||||
logger.info(f"Marked game {room.game_log_id} as abandoned in DB")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to mark game {room.game_log_id} as abandoned: {e}")
|
|
||||||
|
|
||||||
# Clean up players and profiles
|
|
||||||
room_code = room.code
|
|
||||||
for cpu in list(room.get_cpu_players()):
|
|
||||||
room.remove_player(cpu.id)
|
|
||||||
cleanup_room_profiles(room_code)
|
|
||||||
room_manager.remove_room(room_code)
|
|
||||||
|
|
||||||
if stale_rooms:
|
|
||||||
logger.info(f"Cleaned up {len(stale_rooms)} stale room(s)")
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Room cleanup failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _init_redis():
|
async def _init_redis():
|
||||||
"""Initialize Redis client, rate limiter, and signup limiter."""
|
"""Initialize Redis client and rate limiter."""
|
||||||
global _redis_client, _rate_limiter
|
global _redis_client, _rate_limiter
|
||||||
try:
|
try:
|
||||||
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
|
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
|
||||||
@ -162,17 +95,6 @@ async def _init_redis():
|
|||||||
from services.ratelimit import get_rate_limiter
|
from services.ratelimit import get_rate_limiter
|
||||||
_rate_limiter = await get_rate_limiter(_redis_client)
|
_rate_limiter = await get_rate_limiter(_redis_client)
|
||||||
logger.info("Rate limiter initialized")
|
logger.info("Rate limiter initialized")
|
||||||
|
|
||||||
# Initialize signup limiter for metered open signups
|
|
||||||
if config.DAILY_OPEN_SIGNUPS != 0 or config.DAILY_SIGNUPS_PER_IP > 0:
|
|
||||||
from services.ratelimit import get_signup_limiter
|
|
||||||
signup_limiter = await get_signup_limiter(_redis_client)
|
|
||||||
from routers.auth import set_signup_limiter
|
|
||||||
set_signup_limiter(signup_limiter)
|
|
||||||
logger.info(
|
|
||||||
f"Signup limiter initialized "
|
|
||||||
f"(daily={config.DAILY_OPEN_SIGNUPS}, per_ip={config.DAILY_SIGNUPS_PER_IP})"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
|
logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
|
||||||
_redis_client = None
|
_redis_client = None
|
||||||
@ -321,14 +243,6 @@ async def _shutdown_services():
|
|||||||
reset_all_profiles()
|
reset_all_profiles()
|
||||||
logger.info("All rooms and CPU profiles cleaned up")
|
logger.info("All rooms and CPU profiles cleaned up")
|
||||||
|
|
||||||
if _room_cleanup_task:
|
|
||||||
_room_cleanup_task.cancel()
|
|
||||||
try:
|
|
||||||
await _room_cleanup_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
logger.info("Room cleanup task stopped")
|
|
||||||
|
|
||||||
if _leaderboard_refresh_task:
|
if _leaderboard_refresh_task:
|
||||||
_leaderboard_refresh_task.cancel()
|
_leaderboard_refresh_task.cancel()
|
||||||
try:
|
try:
|
||||||
@ -387,26 +301,6 @@ async def lifespan(app: FastAPI):
|
|||||||
room_manager=room_manager,
|
room_manager=room_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark any orphaned active games as abandoned (in-memory state lost on restart)
|
|
||||||
if _user_store:
|
|
||||||
try:
|
|
||||||
async with _user_store.pool.acquire() as conn:
|
|
||||||
result = await conn.execute(
|
|
||||||
"UPDATE games_v2 SET status = 'abandoned', completed_at = NOW() WHERE status = 'active'"
|
|
||||||
)
|
|
||||||
# PostgreSQL returns command tags like "UPDATE 3" — the last word is
|
|
||||||
# the affected row count. This is a documented protocol behavior.
|
|
||||||
count = int(result.split()[-1]) if result else 0
|
|
||||||
if count > 0:
|
|
||||||
logger.info(f"Marked {count} orphaned active game(s) as abandoned on startup")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to clean up orphaned games on startup: {e}")
|
|
||||||
|
|
||||||
# Start periodic room cleanup
|
|
||||||
global _room_cleanup_task
|
|
||||||
_room_cleanup_task = asyncio.create_task(_periodic_room_cleanup())
|
|
||||||
logger.info(f"Room cleanup task started (timeout={config.ROOM_IDLE_TIMEOUT_SECONDS}s)")
|
|
||||||
|
|
||||||
logger.info(f"Golf server started (environment={config.ENVIRONMENT})")
|
logger.info(f"Golf server started (environment={config.ENVIRONMENT})")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
@ -431,7 +325,7 @@ async def _close_all_websockets():
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Golf Card Game",
|
title="Golf Card Game",
|
||||||
debug=config.DEBUG,
|
debug=config.DEBUG,
|
||||||
version="3.2.0",
|
version="3.1.1",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -615,8 +509,6 @@ async def reset_cpu_profiles():
|
|||||||
return {"status": "ok", "message": "All CPU profiles reset"}
|
return {"status": "ok", "message": "All CPU profiles reset"}
|
||||||
|
|
||||||
|
|
||||||
# Per-user game limit. Prevents a single account from creating dozens of rooms
|
|
||||||
# and exhausting server memory. 4 is generous — most people play 1 at a time.
|
|
||||||
MAX_CONCURRENT_GAMES = 4
|
MAX_CONCURRENT_GAMES = 4
|
||||||
|
|
||||||
|
|
||||||
@ -657,10 +549,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"WebSocket connected anonymously as {connection_id}")
|
logger.debug(f"WebSocket connected anonymously as {connection_id}")
|
||||||
|
|
||||||
# player_id = connection_id by design. Originally these were separate concepts
|
|
||||||
# (connection vs game identity), but in practice a player IS their connection.
|
|
||||||
# Reconnection creates a new connection_id, and the room layer handles the
|
|
||||||
# identity mapping. Keeping both fields lets handlers be explicit about intent.
|
|
||||||
ctx = ConnectionContext(
|
ctx = ConnectionContext(
|
||||||
websocket=websocket,
|
websocket=websocket,
|
||||||
connection_id=connection_id,
|
connection_id=connection_id,
|
||||||
@ -764,7 +652,7 @@ async def broadcast_game_state(room: Room):
|
|||||||
# Check for round over
|
# Check for round over
|
||||||
if room.game.phase == GamePhase.ROUND_OVER:
|
if room.game.phase == GamePhase.ROUND_OVER:
|
||||||
scores = [
|
scores = [
|
||||||
{"id": p.id, "name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
|
{"name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
|
||||||
for p in room.game.players
|
for p in room.game.players
|
||||||
]
|
]
|
||||||
# Build rankings
|
# Build rankings
|
||||||
@ -773,7 +661,6 @@ async def broadcast_game_state(room: Room):
|
|||||||
await player.websocket.send_json({
|
await player.websocket.send_json({
|
||||||
"type": "round_over",
|
"type": "round_over",
|
||||||
"scores": scores,
|
"scores": scores,
|
||||||
"finisher_id": room.game.finisher_id,
|
|
||||||
"round": room.game.current_round,
|
"round": room.game.current_round,
|
||||||
"total_rounds": room.game.num_rounds,
|
"total_rounds": room.game.num_rounds,
|
||||||
"rankings": {
|
"rankings": {
|
||||||
@ -818,13 +705,8 @@ async def broadcast_game_state(room: Room):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def check_and_run_cpu_turn(room: Room):
|
async def check_and_run_cpu_turn(room: Room):
|
||||||
"""Check if current player is CPU and start their turn as a background task.
|
"""Check if current player is CPU and run their turn."""
|
||||||
|
|
||||||
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):
|
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -836,77 +718,25 @@ def check_and_run_cpu_turn(room: Room):
|
|||||||
if not room_player or not room_player.is_cpu:
|
if not room_player or not room_player.is_cpu:
|
||||||
return
|
return
|
||||||
|
|
||||||
task = asyncio.create_task(_run_cpu_chain(room))
|
# Brief pause before CPU starts - animations are faster now
|
||||||
room.cpu_turn_task = task
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
def _on_done(t: asyncio.Task):
|
# Run CPU turn
|
||||||
# Clear the reference when the task finishes (success, cancel, or error)
|
async def broadcast_cb():
|
||||||
if room.cpu_turn_task is t:
|
await broadcast_game_state(room)
|
||||||
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)
|
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)
|
||||||
async def _run_cpu_chain(room: Room):
|
await check_and_run_cpu_turn(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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
room.touch()
|
|
||||||
|
|
||||||
# Brief pause before CPU starts. Without this, the CPU's draw message arrives
|
|
||||||
# before the client has finished processing the previous turn's state update,
|
|
||||||
# and animations overlap. 0.25s is enough for the client to settle.
|
|
||||||
await asyncio.sleep(0.25)
|
|
||||||
|
|
||||||
# Run CPU turn
|
|
||||||
async def broadcast_cb():
|
|
||||||
await broadcast_game_state(room)
|
|
||||||
|
|
||||||
async def reveal_cb(player_id, position, card_data):
|
|
||||||
reveal_msg = {
|
|
||||||
"type": "card_revealed",
|
|
||||||
"player_id": player_id,
|
|
||||||
"position": position,
|
|
||||||
"card": card_data,
|
|
||||||
}
|
|
||||||
for pid, p in room.players.items():
|
|
||||||
if not p.is_cpu and p.websocket:
|
|
||||||
try:
|
|
||||||
await p.websocket.send_json(reveal_msg)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id, reveal_callback=reveal_cb)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_player_leave(room: Room, player_id: str):
|
async def handle_player_leave(room: Room, player_id: str):
|
||||||
"""Handle a player leaving a room."""
|
"""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_code = room.code
|
||||||
room_player = room.remove_player(player_id)
|
room_player = room.remove_player(player_id)
|
||||||
|
|
||||||
# Check both is_empty() AND human_player_count() — CPU players keep rooms
|
# If no human players left, clean up the room entirely
|
||||||
# technically non-empty, but a room with only CPUs is an abandoned room.
|
|
||||||
if room.is_empty() or room.human_player_count() == 0:
|
if room.is_empty() or room.human_player_count() == 0:
|
||||||
# Remove all remaining CPU players to release their profiles
|
# Remove all remaining CPU players to release their profiles
|
||||||
for cpu in list(room.get_cpu_players()):
|
for cpu in list(room.get_cpu_players()):
|
||||||
@ -943,18 +773,7 @@ if os.path.exists(client_path):
|
|||||||
return FileResponse(os.path.join(client_path, "index.html"))
|
return FileResponse(os.path.join(client_path, "index.html"))
|
||||||
|
|
||||||
# Mount static files for everything else (JS, CSS, SVG, etc.)
|
# Mount static files for everything else (JS, CSS, SVG, etc.)
|
||||||
# Wrap StaticFiles to reject WebSocket requests gracefully instead of
|
app.mount("/", StaticFiles(directory=client_path), name="static")
|
||||||
# crashing with AssertionError (starlette asserts scope["type"] == "http").
|
|
||||||
static_files = StaticFiles(directory=client_path)
|
|
||||||
|
|
||||||
async def safe_static_files(scope, receive, send):
|
|
||||||
if scope["type"] != "http":
|
|
||||||
if scope["type"] == "websocket":
|
|
||||||
await send({"type": "websocket.close", "code": 1000})
|
|
||||||
return
|
|
||||||
await static_files(scope, receive, send)
|
|
||||||
|
|
||||||
app.mount("/", safe_static_files, name="static")
|
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
|
|||||||
@ -81,15 +81,11 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
# Generate client key
|
# Generate client key
|
||||||
client_key = self.limiter.get_client_key(request, user_id)
|
client_key = self.limiter.get_client_key(request, user_id)
|
||||||
|
|
||||||
# Check rate limit (fail closed for auth endpoints)
|
# Check rate limit
|
||||||
endpoint_key = self._get_endpoint_key(path)
|
endpoint_key = self._get_endpoint_key(path)
|
||||||
full_key = f"{endpoint_key}:{client_key}"
|
full_key = f"{endpoint_key}:{client_key}"
|
||||||
|
|
||||||
is_auth_endpoint = path.startswith("/api/auth")
|
allowed, info = await self.limiter.is_allowed(full_key, limit, window)
|
||||||
if is_auth_endpoint:
|
|
||||||
allowed, info = await self.limiter.is_allowed_strict(full_key, limit, window)
|
|
||||||
else:
|
|
||||||
allowed, info = await self.limiter.is_allowed(full_key, limit, window)
|
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
if allowed:
|
if allowed:
|
||||||
|
|||||||
@ -14,7 +14,6 @@ A Room contains:
|
|||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -70,12 +69,6 @@ class Room:
|
|||||||
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
|
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
|
||||||
game_log_id: Optional[str] = None
|
game_log_id: Optional[str] = None
|
||||||
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||||
cpu_turn_task: Optional[asyncio.Task] = None
|
|
||||||
last_activity: float = field(default_factory=time.time)
|
|
||||||
|
|
||||||
def touch(self) -> None:
|
|
||||||
"""Update last_activity timestamp to mark room as active."""
|
|
||||||
self.last_activity = time.time()
|
|
||||||
|
|
||||||
def add_player(
|
def add_player(
|
||||||
self,
|
self,
|
||||||
@ -98,9 +91,6 @@ class Room:
|
|||||||
Returns:
|
Returns:
|
||||||
The created RoomPlayer object.
|
The created RoomPlayer object.
|
||||||
"""
|
"""
|
||||||
# First player in becomes host. On reconnection, the player gets a new
|
|
||||||
# connection_id, so they rejoin as a "new" player — host status may shift
|
|
||||||
# if the original host disconnected and someone else was promoted.
|
|
||||||
is_host = len(self.players) == 0
|
is_host = len(self.players) == 0
|
||||||
room_player = RoomPlayer(
|
room_player = RoomPlayer(
|
||||||
id=player_id,
|
id=player_id,
|
||||||
@ -176,9 +166,7 @@ class Room:
|
|||||||
if room_player.is_cpu:
|
if room_player.is_cpu:
|
||||||
release_profile(room_player.name, self.code)
|
release_profile(room_player.name, self.code)
|
||||||
|
|
||||||
# Assign new host if needed. next(iter(...)) gives us the first value in
|
# Assign new host if needed
|
||||||
# insertion order (Python 3.7+ dict guarantee). This means the longest-tenured
|
|
||||||
# player becomes host, which is the least surprising behavior.
|
|
||||||
if room_player.is_host and self.players:
|
if room_player.is_host and self.players:
|
||||||
next_host = next(iter(self.players.values()))
|
next_host = next(iter(self.players.values()))
|
||||||
next_host.is_host = True
|
next_host.is_host = True
|
||||||
|
|||||||
@ -5,7 +5,6 @@ Provides endpoints for user registration, login, password management,
|
|||||||
and session handling.
|
and session handling.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -16,7 +15,6 @@ from config import config
|
|||||||
from models.user import User
|
from models.user import User
|
||||||
from services.auth_service import AuthService
|
from services.auth_service import AuthService
|
||||||
from services.admin_service import AdminService
|
from services.admin_service import AdminService
|
||||||
from services.ratelimit import SignupLimiter
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -117,7 +115,6 @@ class SessionResponse(BaseModel):
|
|||||||
# These will be set by main.py during startup
|
# These will be set by main.py during startup
|
||||||
_auth_service: Optional[AuthService] = None
|
_auth_service: Optional[AuthService] = None
|
||||||
_admin_service: Optional[AdminService] = None
|
_admin_service: Optional[AdminService] = None
|
||||||
_signup_limiter: Optional[SignupLimiter] = None
|
|
||||||
|
|
||||||
|
|
||||||
def set_auth_service(service: AuthService) -> None:
|
def set_auth_service(service: AuthService) -> None:
|
||||||
@ -132,12 +129,6 @@ def set_admin_service_for_auth(service: AdminService) -> None:
|
|||||||
_admin_service = service
|
_admin_service = service
|
||||||
|
|
||||||
|
|
||||||
def set_signup_limiter(limiter: SignupLimiter) -> None:
|
|
||||||
"""Set the signup limiter instance (called from main.py)."""
|
|
||||||
global _signup_limiter
|
|
||||||
_signup_limiter = limiter
|
|
||||||
|
|
||||||
|
|
||||||
def get_auth_service_dep() -> AuthService:
|
def get_auth_service_dep() -> AuthService:
|
||||||
"""Dependency to get auth service."""
|
"""Dependency to get auth service."""
|
||||||
if _auth_service is None:
|
if _auth_service is None:
|
||||||
@ -220,51 +211,15 @@ async def register(
|
|||||||
auth_service: AuthService = Depends(get_auth_service_dep),
|
auth_service: AuthService = Depends(get_auth_service_dep),
|
||||||
):
|
):
|
||||||
"""Register a new user account."""
|
"""Register a new user account."""
|
||||||
has_invite = bool(request_body.invite_code)
|
# Validate invite code when invite-only mode is enabled
|
||||||
is_open_signup = not has_invite
|
if config.INVITE_ONLY:
|
||||||
client_ip = get_client_ip(request)
|
if not request_body.invite_code:
|
||||||
ip_hash = hashlib.sha256(client_ip.encode()).hexdigest()[:16] if client_ip else "unknown"
|
raise HTTPException(status_code=400, detail="Invite code required")
|
||||||
|
|
||||||
# --- Per-IP daily signup limit (applies to ALL signups) ---
|
|
||||||
if config.DAILY_SIGNUPS_PER_IP > 0 and _signup_limiter:
|
|
||||||
ip_allowed, ip_remaining = await _signup_limiter.check_ip_limit(
|
|
||||||
ip_hash, config.DAILY_SIGNUPS_PER_IP
|
|
||||||
)
|
|
||||||
if not ip_allowed:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail="Too many signups from this address today. Please try again tomorrow.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Invite code validation ---
|
|
||||||
if has_invite:
|
|
||||||
if not _admin_service:
|
if not _admin_service:
|
||||||
raise HTTPException(status_code=503, detail="Admin service not initialized")
|
raise HTTPException(status_code=503, detail="Admin service not initialized")
|
||||||
if not await _admin_service.validate_invite_code(request_body.invite_code):
|
if not await _admin_service.validate_invite_code(request_body.invite_code):
|
||||||
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
|
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
|
||||||
else:
|
|
||||||
# No invite code — check if open signups are allowed
|
|
||||||
if config.INVITE_ONLY and config.DAILY_OPEN_SIGNUPS == 0:
|
|
||||||
raise HTTPException(status_code=400, detail="Invite code required")
|
|
||||||
|
|
||||||
# Check daily open signup limit
|
|
||||||
if config.DAILY_OPEN_SIGNUPS != 0 and _signup_limiter:
|
|
||||||
daily_allowed, daily_remaining = await _signup_limiter.check_daily_limit(
|
|
||||||
config.DAILY_OPEN_SIGNUPS
|
|
||||||
)
|
|
||||||
if not daily_allowed:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail="Daily signup limit reached. Please try again tomorrow or use an invite code.",
|
|
||||||
)
|
|
||||||
elif config.DAILY_OPEN_SIGNUPS != 0 and not _signup_limiter:
|
|
||||||
# Signup limiter requires Redis — fail closed
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Registration temporarily unavailable. Please try again later.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Create the account ---
|
|
||||||
result = await auth_service.register(
|
result = await auth_service.register(
|
||||||
username=request_body.username,
|
username=request_body.username,
|
||||||
password=request_body.password,
|
password=request_body.password,
|
||||||
@ -274,19 +229,12 @@ async def register(
|
|||||||
if not result.success:
|
if not result.success:
|
||||||
raise HTTPException(status_code=400, detail=result.error)
|
raise HTTPException(status_code=400, detail=result.error)
|
||||||
|
|
||||||
# --- Post-registration bookkeeping ---
|
# Consume the invite code after successful registration
|
||||||
# Consume invite code if used
|
if config.INVITE_ONLY and request_body.invite_code:
|
||||||
if has_invite and _admin_service:
|
|
||||||
await _admin_service.use_invite_code(request_body.invite_code)
|
await _admin_service.use_invite_code(request_body.invite_code)
|
||||||
|
|
||||||
# Increment signup counters
|
|
||||||
if _signup_limiter:
|
|
||||||
if is_open_signup and config.DAILY_OPEN_SIGNUPS != 0:
|
|
||||||
await _signup_limiter.increment_daily()
|
|
||||||
if config.DAILY_SIGNUPS_PER_IP > 0:
|
|
||||||
await _signup_limiter.increment_ip(ip_hash)
|
|
||||||
|
|
||||||
if result.requires_verification:
|
if result.requires_verification:
|
||||||
|
# Return user info but note they need to verify
|
||||||
return {
|
return {
|
||||||
"user": _user_to_response(result.user),
|
"user": _user_to_response(result.user),
|
||||||
"token": "",
|
"token": "",
|
||||||
@ -299,7 +247,7 @@ async def register(
|
|||||||
username=request_body.username,
|
username=request_body.username,
|
||||||
password=request_body.password,
|
password=request_body.password,
|
||||||
device_info=get_device_info(request),
|
device_info=get_device_info(request),
|
||||||
ip_address=client_ip,
|
ip_address=get_client_ip(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not login_result.success:
|
if not login_result.success:
|
||||||
@ -312,32 +260,6 @@ async def register(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/signup-info")
|
|
||||||
async def signup_info():
|
|
||||||
"""
|
|
||||||
Public endpoint: returns signup availability info.
|
|
||||||
|
|
||||||
Tells the client whether invite codes are required,
|
|
||||||
and how many open signup slots remain today.
|
|
||||||
"""
|
|
||||||
open_signups_enabled = config.DAILY_OPEN_SIGNUPS != 0
|
|
||||||
invite_required = config.INVITE_ONLY and not open_signups_enabled
|
|
||||||
unlimited = config.DAILY_OPEN_SIGNUPS < 0
|
|
||||||
|
|
||||||
remaining = None
|
|
||||||
if open_signups_enabled and not unlimited and _signup_limiter:
|
|
||||||
daily_count = await _signup_limiter.get_daily_count()
|
|
||||||
remaining = max(0, config.DAILY_OPEN_SIGNUPS - daily_count)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"invite_required": invite_required,
|
|
||||||
"open_signups_enabled": open_signups_enabled,
|
|
||||||
"daily_limit": config.DAILY_OPEN_SIGNUPS if not unlimited else None,
|
|
||||||
"remaining_today": remaining,
|
|
||||||
"unlimited": unlimited,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-email")
|
@router.post("/verify-email")
|
||||||
async def verify_email(
|
async def verify_email(
|
||||||
request_body: VerifyEmailRequest,
|
request_body: VerifyEmailRequest,
|
||||||
|
|||||||
@ -91,42 +91,9 @@ class RateLimiter:
|
|||||||
|
|
||||||
except redis.RedisError as e:
|
except redis.RedisError as e:
|
||||||
# If Redis is unavailable, fail open (allow request)
|
# If Redis is unavailable, fail open (allow request)
|
||||||
# For auth-critical paths, callers should use fail_closed=True
|
|
||||||
logger.error(f"Rate limiter Redis error: {e}")
|
logger.error(f"Rate limiter Redis error: {e}")
|
||||||
return True, {"remaining": limit, "reset": window_seconds, "limit": limit}
|
return True, {"remaining": limit, "reset": window_seconds, "limit": limit}
|
||||||
|
|
||||||
async def is_allowed_strict(
|
|
||||||
self,
|
|
||||||
key: str,
|
|
||||||
limit: int,
|
|
||||||
window_seconds: int,
|
|
||||||
) -> tuple[bool, dict]:
|
|
||||||
"""
|
|
||||||
Like is_allowed but fails closed (denies) when Redis is unavailable.
|
|
||||||
Use for security-critical paths like auth endpoints.
|
|
||||||
"""
|
|
||||||
now = int(time.time())
|
|
||||||
window_key = f"ratelimit:{key}:{now // window_seconds}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with self.redis.pipeline(transaction=True) as pipe:
|
|
||||||
pipe.incr(window_key)
|
|
||||||
pipe.expire(window_key, window_seconds + 1)
|
|
||||||
results = await pipe.execute()
|
|
||||||
|
|
||||||
current_count = results[0]
|
|
||||||
remaining = max(0, limit - current_count)
|
|
||||||
reset = window_seconds - (now % window_seconds)
|
|
||||||
|
|
||||||
return current_count <= limit, {
|
|
||||||
"remaining": remaining,
|
|
||||||
"reset": reset,
|
|
||||||
"limit": limit,
|
|
||||||
}
|
|
||||||
except redis.RedisError as e:
|
|
||||||
logger.error(f"Rate limiter Redis error (fail-closed): {e}")
|
|
||||||
return False, {"remaining": 0, "reset": window_seconds, "limit": limit}
|
|
||||||
|
|
||||||
def get_client_key(
|
def get_client_key(
|
||||||
self,
|
self,
|
||||||
request: Request | WebSocket,
|
request: Request | WebSocket,
|
||||||
@ -230,110 +197,8 @@ class ConnectionMessageLimiter:
|
|||||||
self.timestamps = []
|
self.timestamps = []
|
||||||
|
|
||||||
|
|
||||||
class SignupLimiter:
|
|
||||||
"""
|
|
||||||
Daily signup metering for public beta.
|
|
||||||
|
|
||||||
Tracks two counters in Redis:
|
|
||||||
- Global daily open signups (no invite code)
|
|
||||||
- Per-IP daily signups (with or without invite code)
|
|
||||||
|
|
||||||
Keys auto-expire after 24 hours.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, redis_client: redis.Redis):
|
|
||||||
self.redis = redis_client
|
|
||||||
|
|
||||||
def _today_key(self, prefix: str) -> str:
|
|
||||||
"""Generate a Redis key scoped to today's date (UTC)."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
return f"signup:{prefix}:{today}"
|
|
||||||
|
|
||||||
async def check_daily_limit(self, daily_limit: int) -> tuple[bool, int]:
|
|
||||||
"""
|
|
||||||
Check if global daily open signup limit allows another registration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
daily_limit: Max open signups per day. -1 = unlimited, 0 = disabled.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (allowed, remaining). remaining is -1 when unlimited.
|
|
||||||
"""
|
|
||||||
if daily_limit == 0:
|
|
||||||
return False, 0
|
|
||||||
if daily_limit < 0:
|
|
||||||
return True, -1
|
|
||||||
|
|
||||||
key = self._today_key("daily_open")
|
|
||||||
try:
|
|
||||||
count = await self.redis.get(key)
|
|
||||||
current = int(count) if count else 0
|
|
||||||
remaining = max(0, daily_limit - current)
|
|
||||||
return current < daily_limit, remaining
|
|
||||||
except redis.RedisError as e:
|
|
||||||
logger.error(f"Signup limiter Redis error (daily check): {e}")
|
|
||||||
return False, 0 # Fail closed
|
|
||||||
|
|
||||||
async def check_ip_limit(self, ip_hash: str, ip_limit: int) -> tuple[bool, int]:
|
|
||||||
"""
|
|
||||||
Check if per-IP daily signup limit allows another registration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ip_hash: Hashed client IP.
|
|
||||||
ip_limit: Max signups per IP per day. 0 = unlimited.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (allowed, remaining).
|
|
||||||
"""
|
|
||||||
if ip_limit <= 0:
|
|
||||||
return True, -1
|
|
||||||
|
|
||||||
key = self._today_key(f"ip:{ip_hash}")
|
|
||||||
try:
|
|
||||||
count = await self.redis.get(key)
|
|
||||||
current = int(count) if count else 0
|
|
||||||
remaining = max(0, ip_limit - current)
|
|
||||||
return current < ip_limit, remaining
|
|
||||||
except redis.RedisError as e:
|
|
||||||
logger.error(f"Signup limiter Redis error (IP check): {e}")
|
|
||||||
return False, 0 # Fail closed
|
|
||||||
|
|
||||||
async def increment_daily(self) -> None:
|
|
||||||
"""Increment the global daily open signup counter."""
|
|
||||||
key = self._today_key("daily_open")
|
|
||||||
try:
|
|
||||||
async with self.redis.pipeline(transaction=True) as pipe:
|
|
||||||
pipe.incr(key)
|
|
||||||
pipe.expire(key, 86400 + 60) # 24h + 1min buffer
|
|
||||||
await pipe.execute()
|
|
||||||
except redis.RedisError as e:
|
|
||||||
logger.error(f"Signup limiter Redis error (daily incr): {e}")
|
|
||||||
|
|
||||||
async def increment_ip(self, ip_hash: str) -> None:
|
|
||||||
"""Increment the per-IP daily signup counter."""
|
|
||||||
key = self._today_key(f"ip:{ip_hash}")
|
|
||||||
try:
|
|
||||||
async with self.redis.pipeline(transaction=True) as pipe:
|
|
||||||
pipe.incr(key)
|
|
||||||
pipe.expire(key, 86400 + 60)
|
|
||||||
await pipe.execute()
|
|
||||||
except redis.RedisError as e:
|
|
||||||
logger.error(f"Signup limiter Redis error (IP incr): {e}")
|
|
||||||
|
|
||||||
async def get_daily_count(self) -> int:
|
|
||||||
"""Get current daily open signup count."""
|
|
||||||
key = self._today_key("daily_open")
|
|
||||||
try:
|
|
||||||
count = await self.redis.get(key)
|
|
||||||
return int(count) if count else 0
|
|
||||||
except redis.RedisError:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# Global rate limiter instance
|
# Global rate limiter instance
|
||||||
_rate_limiter: Optional[RateLimiter] = None
|
_rate_limiter: Optional[RateLimiter] = None
|
||||||
_signup_limiter: Optional[SignupLimiter] = None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
|
async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
|
||||||
@ -352,16 +217,7 @@ async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
|
|||||||
return _rate_limiter
|
return _rate_limiter
|
||||||
|
|
||||||
|
|
||||||
async def get_signup_limiter(redis_client: redis.Redis) -> SignupLimiter:
|
|
||||||
"""Get or create the global signup limiter instance."""
|
|
||||||
global _signup_limiter
|
|
||||||
if _signup_limiter is None:
|
|
||||||
_signup_limiter = SignupLimiter(redis_client)
|
|
||||||
return _signup_limiter
|
|
||||||
|
|
||||||
|
|
||||||
def close_rate_limiter():
|
def close_rate_limiter():
|
||||||
"""Close the global rate limiter."""
|
"""Close the global rate limiter."""
|
||||||
global _rate_limiter, _signup_limiter
|
global _rate_limiter
|
||||||
_rate_limiter = None
|
_rate_limiter = None
|
||||||
_signup_limiter = None
|
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "golf-tui"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Terminal client for the Golf card game"
|
|
||||||
requires-python = ">=3.11"
|
|
||||||
dependencies = [
|
|
||||||
"textual>=0.47.0",
|
|
||||||
"websockets>=12.0",
|
|
||||||
"httpx>=0.25.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
golf-tui = "tui_client.__main__:main"
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["setuptools>=68.0"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
|
||||||
where = ["src"]
|
|
||||||
@ -1 +0,0 @@
|
|||||||
"""TUI client for the Golf card game."""
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
"""Entry point: python -m tui_client [--server HOST] [--no-tls]
|
|
||||||
|
|
||||||
Reads defaults from ~/.config/golf-tui.conf (create with --save-config).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from tui_client.config import load_config, save_config, CONFIG_PATH
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
cfg = load_config()
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Golf Card Game TUI Client")
|
|
||||||
parser.add_argument(
|
|
||||||
"--server",
|
|
||||||
default=cfg.get("server", "golfcards.club"),
|
|
||||||
help=f"Server host[:port] (default: {cfg.get('server', 'golfcards.club')})",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-tls",
|
|
||||||
action="store_true",
|
|
||||||
default=cfg.get("tls", "true").lower() != "true",
|
|
||||||
help="Use ws:// and http:// instead of wss:// and https://",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--debug",
|
|
||||||
action="store_true",
|
|
||||||
help="Enable debug logging to tui_debug.log",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--save-config",
|
|
||||||
action="store_true",
|
|
||||||
help=f"Save current options as defaults to {CONFIG_PATH}",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.save_config:
|
|
||||||
save_config({
|
|
||||||
"server": args.server,
|
|
||||||
"tls": str(not args.no_tls).lower(),
|
|
||||||
})
|
|
||||||
print(f"Config saved to {CONFIG_PATH}")
|
|
||||||
print(f" server = {args.server}")
|
|
||||||
print(f" tls = {str(not args.no_tls).lower()}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if args.debug:
|
|
||||||
import logging
|
|
||||||
logging.basicConfig(level=logging.DEBUG, filename="tui_debug.log")
|
|
||||||
|
|
||||||
from tui_client.app import GolfApp
|
|
||||||
app = GolfApp(server=args.server, use_tls=not args.no_tls)
|
|
||||||
app.run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
"""Main Textual App for the Golf TUI client."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
|
||||||
from textual.message import Message
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
from tui_client.client import GameClient
|
|
||||||
|
|
||||||
|
|
||||||
class ServerMessage(Message):
|
|
||||||
"""A message received from the game server."""
|
|
||||||
|
|
||||||
def __init__(self, data: dict) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.msg_type: str = data.get("type", "")
|
|
||||||
self.data: dict = data
|
|
||||||
|
|
||||||
|
|
||||||
class KeymapBar(Static):
|
|
||||||
"""Bottom bar showing available keys for the current context."""
|
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
|
||||||
KeymapBar {
|
|
||||||
dock: bottom;
|
|
||||||
height: 1;
|
|
||||||
background: #1a1a2e;
|
|
||||||
color: #888888;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class GolfApp(App):
|
|
||||||
"""Golf Card Game TUI Application."""
|
|
||||||
|
|
||||||
TITLE = "GolfCards.club"
|
|
||||||
CSS_PATH = "styles.tcss"
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "esc_pressed", ""),
|
|
||||||
("q", "quit_app", ""),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, server: str, use_tls: bool = True):
|
|
||||||
super().__init__()
|
|
||||||
self.client = GameClient(server, use_tls)
|
|
||||||
self.client._app = self
|
|
||||||
self.player_id: str | None = None
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
yield KeymapBar(id="keymap-bar")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
from tui_client.screens.splash import SplashScreen
|
|
||||||
self.push_screen(SplashScreen())
|
|
||||||
self._update_keymap()
|
|
||||||
|
|
||||||
def on_screen_resume(self) -> None:
|
|
||||||
self._update_keymap()
|
|
||||||
|
|
||||||
def post_server_message(self, data: dict) -> None:
|
|
||||||
"""Called from GameClient listener to inject server messages."""
|
|
||||||
msg = ServerMessage(data)
|
|
||||||
self.call_later(self._route_server_message, msg)
|
|
||||||
|
|
||||||
def _route_server_message(self, msg: ServerMessage) -> None:
|
|
||||||
"""Forward a server message to the active screen."""
|
|
||||||
screen = self.screen
|
|
||||||
handler = getattr(screen, "on_server_message", None)
|
|
||||||
if handler:
|
|
||||||
handler(msg)
|
|
||||||
|
|
||||||
def action_esc_pressed(self) -> None:
|
|
||||||
"""Escape goes back — delegated to the active screen."""
|
|
||||||
handler = getattr(self.screen, "handle_escape", None)
|
|
||||||
if handler:
|
|
||||||
handler()
|
|
||||||
|
|
||||||
def action_quit_app(self) -> None:
|
|
||||||
"""[q] quits the app. Immediate on login, confirmation elsewhere."""
|
|
||||||
# Don't capture q when typing in input fields
|
|
||||||
focused = self.focused
|
|
||||||
if focused and hasattr(focused, "value"):
|
|
||||||
return
|
|
||||||
# Don't handle here on game screen (game has its own q binding)
|
|
||||||
if self.screen.__class__.__name__ == "GameScreen":
|
|
||||||
return
|
|
||||||
|
|
||||||
screen_name = self.screen.__class__.__name__
|
|
||||||
if screen_name == "ConnectScreen":
|
|
||||||
self.exit()
|
|
||||||
else:
|
|
||||||
from tui_client.screens.confirm import ConfirmScreen
|
|
||||||
self.push_screen(
|
|
||||||
ConfirmScreen("Quit GolfCards?"),
|
|
||||||
callback=self._on_quit_confirm,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_quit_confirm(self, confirmed: bool) -> None:
|
|
||||||
if confirmed:
|
|
||||||
self.exit()
|
|
||||||
|
|
||||||
def _update_keymap(self) -> None:
|
|
||||||
"""Update the keymap bar based on current screen."""
|
|
||||||
screen_name = self.screen.__class__.__name__
|
|
||||||
keymap = getattr(self.screen, "KEYMAP_HINT", None)
|
|
||||||
if keymap:
|
|
||||||
text = keymap
|
|
||||||
elif screen_name == "ConnectScreen":
|
|
||||||
text = "[Tab] Navigate [Enter] Submit [q] Quit"
|
|
||||||
elif screen_name == "LobbyScreen":
|
|
||||||
text = "[Esc] Back [Tab] Navigate [Enter] Create/Join [q] Quit"
|
|
||||||
else:
|
|
||||||
text = "[q] Quit"
|
|
||||||
try:
|
|
||||||
self.query_one("#keymap-bar", KeymapBar).update(text)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def set_keymap(self, text: str) -> None:
|
|
||||||
"""Allow screens to update the keymap bar dynamically."""
|
|
||||||
try:
|
|
||||||
self.query_one("#keymap-bar", KeymapBar).update(text)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def on_unmount(self) -> None:
|
|
||||||
await self.client.disconnect()
|
|
||||||
@ -1,196 +0,0 @@
|
|||||||
"""WebSocket + HTTP networking for the TUI client."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import websockets
|
|
||||||
from websockets.asyncio.client import ClientConnection
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_SESSION_DIR = Path.home() / ".config" / "golfcards"
|
|
||||||
_SESSION_FILE = _SESSION_DIR / "session.json"
|
|
||||||
|
|
||||||
|
|
||||||
class GameClient:
|
|
||||||
"""Handles HTTP auth and WebSocket game communication."""
|
|
||||||
|
|
||||||
def __init__(self, host: str, use_tls: bool = True):
|
|
||||||
self.host = host
|
|
||||||
self.use_tls = use_tls
|
|
||||||
self._token: Optional[str] = None
|
|
||||||
self._ws: Optional[ClientConnection] = None
|
|
||||||
self._listener_task: Optional[asyncio.Task] = None
|
|
||||||
self._app = None # Set by GolfApp
|
|
||||||
self._username: Optional[str] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def http_base(self) -> str:
|
|
||||||
scheme = "https" if self.use_tls else "http"
|
|
||||||
return f"{scheme}://{self.host}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ws_url(self) -> str:
|
|
||||||
scheme = "wss" if self.use_tls else "ws"
|
|
||||||
url = f"{scheme}://{self.host}/ws"
|
|
||||||
if self._token:
|
|
||||||
url += f"?token={self._token}"
|
|
||||||
return url
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_authenticated(self) -> bool:
|
|
||||||
return self._token is not None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def username(self) -> Optional[str]:
|
|
||||||
return self._username
|
|
||||||
|
|
||||||
def save_session(self) -> None:
|
|
||||||
"""Persist token and server info to disk."""
|
|
||||||
if not self._token:
|
|
||||||
return
|
|
||||||
_SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
data = {
|
|
||||||
"host": self.host,
|
|
||||||
"use_tls": self.use_tls,
|
|
||||||
"token": self._token,
|
|
||||||
"username": self._username,
|
|
||||||
}
|
|
||||||
_SESSION_FILE.write_text(json.dumps(data))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def load_session() -> dict | None:
|
|
||||||
"""Load saved session from disk, or None if not found."""
|
|
||||||
if not _SESSION_FILE.exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return json.loads(_SESSION_FILE.read_text())
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def clear_session() -> None:
|
|
||||||
"""Delete saved session file."""
|
|
||||||
try:
|
|
||||||
_SESSION_FILE.unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def verify_token(self) -> bool:
|
|
||||||
"""Check if the current token is still valid via /api/auth/me."""
|
|
||||||
if not self._token:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(verify=self.use_tls) as http:
|
|
||||||
resp = await http.get(
|
|
||||||
f"{self.http_base}/api/auth/me",
|
|
||||||
headers={"Authorization": f"Bearer {self._token}"},
|
|
||||||
)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
data = resp.json()
|
|
||||||
self._username = data.get("username", self._username)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def restore_session(self, session: dict) -> None:
|
|
||||||
"""Restore client state from a saved session dict."""
|
|
||||||
self.host = session["host"]
|
|
||||||
self.use_tls = session["use_tls"]
|
|
||||||
self._token = session["token"]
|
|
||||||
self._username = session.get("username")
|
|
||||||
|
|
||||||
async def login(self, username: str, password: str) -> dict:
|
|
||||||
"""Login via HTTP and store JWT token.
|
|
||||||
|
|
||||||
Returns the response dict on success, raises on failure.
|
|
||||||
"""
|
|
||||||
async with httpx.AsyncClient(verify=self.use_tls) as http:
|
|
||||||
resp = await http.post(
|
|
||||||
f"{self.http_base}/api/auth/login",
|
|
||||||
json={"username": username, "password": password},
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
detail = resp.json().get("detail", "Login failed")
|
|
||||||
raise ConnectionError(detail)
|
|
||||||
data = resp.json()
|
|
||||||
self._token = data["token"]
|
|
||||||
self._username = data["user"]["username"]
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def register(
|
|
||||||
self, username: str, password: str, invite_code: str = "", email: str = ""
|
|
||||||
) -> dict:
|
|
||||||
"""Register a new account via HTTP and store JWT token."""
|
|
||||||
payload: dict = {"username": username, "password": password}
|
|
||||||
if invite_code:
|
|
||||||
payload["invite_code"] = invite_code
|
|
||||||
if email:
|
|
||||||
payload["email"] = email
|
|
||||||
async with httpx.AsyncClient(verify=self.use_tls) as http:
|
|
||||||
resp = await http.post(
|
|
||||||
f"{self.http_base}/api/auth/register",
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
detail = resp.json().get("detail", "Registration failed")
|
|
||||||
raise ConnectionError(detail)
|
|
||||||
data = resp.json()
|
|
||||||
self._token = data["token"]
|
|
||||||
self._username = data["user"]["username"]
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def connect(self) -> None:
|
|
||||||
"""Open WebSocket connection to the server."""
|
|
||||||
self._ws = await websockets.connect(self.ws_url)
|
|
||||||
self._listener_task = asyncio.create_task(self._listen())
|
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
|
||||||
"""Close WebSocket connection."""
|
|
||||||
if self._listener_task:
|
|
||||||
self._listener_task.cancel()
|
|
||||||
try:
|
|
||||||
await self._listener_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
self._listener_task = None
|
|
||||||
if self._ws:
|
|
||||||
await self._ws.close()
|
|
||||||
self._ws = None
|
|
||||||
|
|
||||||
async def send(self, msg_type: str, **kwargs) -> None:
|
|
||||||
"""Send a JSON message over WebSocket."""
|
|
||||||
if not self._ws:
|
|
||||||
raise ConnectionError("Not connected")
|
|
||||||
msg = {"type": msg_type, **kwargs}
|
|
||||||
logger.debug(f"TX: {msg}")
|
|
||||||
await self._ws.send(json.dumps(msg))
|
|
||||||
|
|
||||||
async def _listen(self) -> None:
|
|
||||||
"""Background task: read messages from WebSocket and post to app."""
|
|
||||||
try:
|
|
||||||
async for raw in self._ws:
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
logger.debug(f"RX: {data.get('type', '?')}")
|
|
||||||
if self._app:
|
|
||||||
self._app.post_server_message(data)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.warning(f"Non-JSON message: {raw[:100]}")
|
|
||||||
except websockets.ConnectionClosed as e:
|
|
||||||
logger.info(f"WebSocket closed: {e}")
|
|
||||||
if self._app:
|
|
||||||
self._app.post_server_message({"type": "connection_closed", "reason": str(e)})
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"WebSocket listener error: {e}")
|
|
||||||
if self._app:
|
|
||||||
self._app.post_server_message({"type": "connection_error", "reason": str(e)})
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
"""User configuration for the TUI client.
|
|
||||||
|
|
||||||
Config file: ~/.config/golf-tui.conf
|
|
||||||
|
|
||||||
Example contents:
|
|
||||||
server = golfcards.club
|
|
||||||
tls = true
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
CONFIG_PATH = Path(os.environ.get("GOLF_TUI_CONFIG", "~/.config/golf-tui.conf")).expanduser()
|
|
||||||
|
|
||||||
DEFAULTS = {
|
|
||||||
"server": "golfcards.club",
|
|
||||||
"tls": "true",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> dict[str, str]:
|
|
||||||
"""Load config from file, falling back to defaults."""
|
|
||||||
cfg = dict(DEFAULTS)
|
|
||||||
if CONFIG_PATH.exists():
|
|
||||||
for line in CONFIG_PATH.read_text().splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
if "=" in line:
|
|
||||||
key, _, value = line.partition("=")
|
|
||||||
cfg[key.strip().lower()] = value.strip()
|
|
||||||
return cfg
|
|
||||||
|
|
||||||
|
|
||||||
def save_config(cfg: dict[str, str]) -> None:
|
|
||||||
"""Write config to file."""
|
|
||||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
lines = [f"{k} = {v}" for k, v in sorted(cfg.items())]
|
|
||||||
CONFIG_PATH.write_text("\n".join(lines) + "\n")
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
"""Data models for the TUI client."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CardData:
|
|
||||||
"""A single card as received from the server."""
|
|
||||||
|
|
||||||
suit: Optional[str] = None # "hearts", "diamonds", "clubs", "spades"
|
|
||||||
rank: Optional[str] = None # "A", "2".."10", "J", "Q", "K", "★"
|
|
||||||
face_up: bool = False
|
|
||||||
deck_id: Optional[int] = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, d: dict) -> CardData:
|
|
||||||
return cls(
|
|
||||||
suit=d.get("suit"),
|
|
||||||
rank=d.get("rank"),
|
|
||||||
face_up=d.get("face_up", False),
|
|
||||||
deck_id=d.get("deck_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_suit(self) -> str:
|
|
||||||
"""Unicode suit symbol."""
|
|
||||||
return {
|
|
||||||
"hearts": "\u2665",
|
|
||||||
"diamonds": "\u2666",
|
|
||||||
"clubs": "\u2663",
|
|
||||||
"spades": "\u2660",
|
|
||||||
}.get(self.suit or "", "")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_rank(self) -> str:
|
|
||||||
if self.rank == "10":
|
|
||||||
return "10"
|
|
||||||
return self.rank or ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_red(self) -> bool:
|
|
||||||
return self.suit in ("hearts", "diamonds")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_joker(self) -> bool:
|
|
||||||
return self.rank == "\u2605"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PlayerData:
|
|
||||||
"""A player as received in game state."""
|
|
||||||
|
|
||||||
id: str = ""
|
|
||||||
name: str = ""
|
|
||||||
cards: list[CardData] = field(default_factory=list)
|
|
||||||
score: Optional[int] = None
|
|
||||||
total_score: int = 0
|
|
||||||
rounds_won: int = 0
|
|
||||||
all_face_up: bool = False
|
|
||||||
|
|
||||||
# Standard card values for visible score calculation
|
|
||||||
_CARD_VALUES = {
|
|
||||||
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6,
|
|
||||||
'7': 7, '8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def visible_score(self) -> int:
|
|
||||||
"""Compute score from face-up cards, zeroing matched columns."""
|
|
||||||
if len(self.cards) < 6:
|
|
||||||
return 0
|
|
||||||
values = [0] * 6
|
|
||||||
for i, c in enumerate(self.cards):
|
|
||||||
if c.face_up and c.rank:
|
|
||||||
values[i] = self._CARD_VALUES.get(c.rank, 0)
|
|
||||||
# Zero out matched columns (same rank, both face-up)
|
|
||||||
for col in range(3):
|
|
||||||
top, bot = self.cards[col], self.cards[col + 3]
|
|
||||||
if top.face_up and bot.face_up and top.rank and top.rank == bot.rank:
|
|
||||||
values[col] = 0
|
|
||||||
values[col + 3] = 0
|
|
||||||
return sum(values)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, d: dict) -> PlayerData:
|
|
||||||
return cls(
|
|
||||||
id=d.get("id", ""),
|
|
||||||
name=d.get("name", ""),
|
|
||||||
cards=[CardData.from_dict(c) for c in d.get("cards", [])],
|
|
||||||
score=d.get("score"),
|
|
||||||
total_score=d.get("total_score", 0),
|
|
||||||
rounds_won=d.get("rounds_won", 0),
|
|
||||||
all_face_up=d.get("all_face_up", False),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GameState:
|
|
||||||
"""Full game state from the server."""
|
|
||||||
|
|
||||||
phase: str = "waiting"
|
|
||||||
players: list[PlayerData] = field(default_factory=list)
|
|
||||||
current_player_id: Optional[str] = None
|
|
||||||
dealer_id: Optional[str] = None
|
|
||||||
discard_top: Optional[CardData] = None
|
|
||||||
deck_remaining: int = 0
|
|
||||||
current_round: int = 1
|
|
||||||
total_rounds: int = 1
|
|
||||||
has_drawn_card: bool = False
|
|
||||||
drawn_card: Optional[CardData] = None
|
|
||||||
drawn_player_id: Optional[str] = None
|
|
||||||
can_discard: bool = True
|
|
||||||
waiting_for_initial_flip: bool = False
|
|
||||||
initial_flips: int = 2
|
|
||||||
flip_on_discard: bool = False
|
|
||||||
flip_mode: str = "never"
|
|
||||||
flip_is_optional: bool = False
|
|
||||||
flip_as_action: bool = False
|
|
||||||
knock_early: bool = False
|
|
||||||
finisher_id: Optional[str] = None
|
|
||||||
card_values: dict = field(default_factory=dict)
|
|
||||||
active_rules: list = field(default_factory=list)
|
|
||||||
deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"])
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, d: dict) -> GameState:
|
|
||||||
discard = d.get("discard_top")
|
|
||||||
drawn = d.get("drawn_card")
|
|
||||||
return cls(
|
|
||||||
phase=d.get("phase", "waiting"),
|
|
||||||
players=[PlayerData.from_dict(p) for p in d.get("players", [])],
|
|
||||||
current_player_id=d.get("current_player_id"),
|
|
||||||
dealer_id=d.get("dealer_id"),
|
|
||||||
discard_top=CardData.from_dict(discard) if discard else None,
|
|
||||||
deck_remaining=d.get("deck_remaining", 0),
|
|
||||||
current_round=d.get("current_round", 1),
|
|
||||||
total_rounds=d.get("total_rounds", 1),
|
|
||||||
has_drawn_card=d.get("has_drawn_card", False),
|
|
||||||
drawn_card=CardData.from_dict(drawn) if drawn else None,
|
|
||||||
drawn_player_id=d.get("drawn_player_id"),
|
|
||||||
can_discard=d.get("can_discard", True),
|
|
||||||
waiting_for_initial_flip=d.get("waiting_for_initial_flip", False),
|
|
||||||
initial_flips=d.get("initial_flips", 2),
|
|
||||||
flip_on_discard=d.get("flip_on_discard", False),
|
|
||||||
flip_mode=d.get("flip_mode", "never"),
|
|
||||||
flip_is_optional=d.get("flip_is_optional", False),
|
|
||||||
flip_as_action=d.get("flip_as_action", False),
|
|
||||||
knock_early=d.get("knock_early", False),
|
|
||||||
finisher_id=d.get("finisher_id"),
|
|
||||||
card_values=d.get("card_values", {}),
|
|
||||||
active_rules=d.get("active_rules", []),
|
|
||||||
deck_colors=d.get("deck_colors", ["red", "blue", "gold"]),
|
|
||||||
)
|
|
||||||
@ -1 +0,0 @@
|
|||||||
"""Screen modules for the TUI client."""
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
"""Reusable confirmation dialog."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, Horizontal
|
|
||||||
from textual.screen import ModalScreen
|
|
||||||
from textual.widgets import Button, Static
|
|
||||||
|
|
||||||
|
|
||||||
class ConfirmScreen(ModalScreen[bool]):
|
|
||||||
"""Modal confirmation prompt. Dismisses with True/False."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("y", "confirm", "Yes"),
|
|
||||||
("n", "cancel", "No"),
|
|
||||||
("escape", "cancel", "Cancel"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, message: str) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self._message = message
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="confirm-dialog"):
|
|
||||||
yield Static(self._message, id="confirm-message")
|
|
||||||
with Horizontal(id="confirm-buttons"):
|
|
||||||
yield Button("Yes [Y]", id="btn-yes", variant="error")
|
|
||||||
yield Button("No [N]", id="btn-no", variant="primary")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "btn-yes":
|
|
||||||
self.dismiss(True)
|
|
||||||
else:
|
|
||||||
self.dismiss(False)
|
|
||||||
|
|
||||||
def action_confirm(self) -> None:
|
|
||||||
self.dismiss(True)
|
|
||||||
|
|
||||||
def action_cancel(self) -> None:
|
|
||||||
self.dismiss(False)
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
"""Connection screen: login or sign up form."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, Horizontal, Vertical
|
|
||||||
from textual.screen import Screen
|
|
||||||
from textual.widgets import Button, Input, Static
|
|
||||||
|
|
||||||
|
|
||||||
_TITLE = (
|
|
||||||
"⛳🏌️ [bold]GolfCards.club[/bold] "
|
|
||||||
"[bold #aaaaaa]♠[/bold #aaaaaa]"
|
|
||||||
"[bold #cc0000]♥[/bold #cc0000]"
|
|
||||||
"[bold #aaaaaa]♣[/bold #aaaaaa]"
|
|
||||||
"[bold #cc0000]♦[/bold #cc0000]"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectScreen(Screen):
|
|
||||||
"""Initial screen for logging in or signing up."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._mode: str = "login" # "login" or "signup"
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="connect-container"):
|
|
||||||
yield Static(_TITLE, id="connect-title")
|
|
||||||
|
|
||||||
# Login form
|
|
||||||
with Vertical(id="login-form"):
|
|
||||||
yield Static("Log in to play")
|
|
||||||
yield Input(placeholder="Username", id="input-username")
|
|
||||||
yield Input(placeholder="Password", password=True, id="input-password")
|
|
||||||
with Horizontal(id="connect-buttons"):
|
|
||||||
yield Button("Login", id="btn-login", variant="primary")
|
|
||||||
yield Button(
|
|
||||||
"No account? [bold cyan]Sign Up[/bold cyan]",
|
|
||||||
id="btn-toggle-signup",
|
|
||||||
variant="default",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Signup form
|
|
||||||
with Vertical(id="signup-form"):
|
|
||||||
yield Static("Create an account")
|
|
||||||
yield Input(placeholder="Invite Code", id="input-invite-code")
|
|
||||||
yield Input(placeholder="Username", id="input-signup-username")
|
|
||||||
yield Input(placeholder="Email (optional)", id="input-signup-email")
|
|
||||||
yield Input(
|
|
||||||
placeholder="Password (min 8 chars)",
|
|
||||||
password=True,
|
|
||||||
id="input-signup-password",
|
|
||||||
)
|
|
||||||
with Horizontal(id="signup-buttons"):
|
|
||||||
yield Button("Sign Up", id="btn-signup", variant="primary")
|
|
||||||
yield Button(
|
|
||||||
"Have an account? [bold cyan]Log In[/bold cyan]",
|
|
||||||
id="btn-toggle-login",
|
|
||||||
variant="default",
|
|
||||||
)
|
|
||||||
|
|
||||||
yield Static("", id="connect-status")
|
|
||||||
|
|
||||||
with Horizontal(classes="screen-footer"):
|
|
||||||
yield Static("", id="connect-footer-left", classes="screen-footer-left")
|
|
||||||
yield Static("\\[q] quit", id="connect-footer-right", classes="screen-footer-right")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self._update_form_visibility()
|
|
||||||
self._update_footer()
|
|
||||||
|
|
||||||
def _update_form_visibility(self) -> None:
|
|
||||||
try:
|
|
||||||
self.query_one("#login-form").display = self._mode == "login"
|
|
||||||
self.query_one("#signup-form").display = self._mode == "signup"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._update_footer()
|
|
||||||
|
|
||||||
def _update_footer(self) -> None:
|
|
||||||
try:
|
|
||||||
left = self.query_one("#connect-footer-left", Static)
|
|
||||||
if self._mode == "signup":
|
|
||||||
left.update("\\[esc] back")
|
|
||||||
else:
|
|
||||||
left.update("")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "btn-login":
|
|
||||||
self._do_login()
|
|
||||||
elif event.button.id == "btn-signup":
|
|
||||||
self._do_signup()
|
|
||||||
elif event.button.id == "btn-toggle-signup":
|
|
||||||
self._mode = "signup"
|
|
||||||
self._set_status("")
|
|
||||||
self._update_form_visibility()
|
|
||||||
elif event.button.id == "btn-toggle-login":
|
|
||||||
self._mode = "login"
|
|
||||||
self._set_status("")
|
|
||||||
self._update_form_visibility()
|
|
||||||
|
|
||||||
def handle_escape(self) -> None:
|
|
||||||
"""Escape goes back to login if on signup form."""
|
|
||||||
if self._mode == "signup":
|
|
||||||
self._mode = "login"
|
|
||||||
self._set_status("")
|
|
||||||
self._update_form_visibility()
|
|
||||||
|
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
||||||
if event.input.id == "input-password":
|
|
||||||
self._do_login()
|
|
||||||
elif event.input.id == "input-signup-password":
|
|
||||||
self._do_signup()
|
|
||||||
|
|
||||||
def _do_login(self) -> None:
|
|
||||||
self._set_status("Logging in...")
|
|
||||||
self._disable_buttons()
|
|
||||||
self.run_worker(self._login_flow(), exclusive=True)
|
|
||||||
|
|
||||||
def _do_signup(self) -> None:
|
|
||||||
self._set_status("Signing up...")
|
|
||||||
self._disable_buttons()
|
|
||||||
self.run_worker(self._signup_flow(), exclusive=True)
|
|
||||||
|
|
||||||
async def _login_flow(self) -> None:
|
|
||||||
client = self.app.client
|
|
||||||
try:
|
|
||||||
username = self.query_one("#input-username", Input).value.strip()
|
|
||||||
password = self.query_one("#input-password", Input).value
|
|
||||||
if not username or not password:
|
|
||||||
self._set_status("Username and password required")
|
|
||||||
self._enable_buttons()
|
|
||||||
return
|
|
||||||
await client.login(username, password)
|
|
||||||
self._set_status(f"Logged in as {client.username}")
|
|
||||||
await self._connect_ws()
|
|
||||||
except Exception as e:
|
|
||||||
self._set_status(f"[red]{e}[/red]")
|
|
||||||
self._enable_buttons()
|
|
||||||
|
|
||||||
async def _signup_flow(self) -> None:
|
|
||||||
client = self.app.client
|
|
||||||
try:
|
|
||||||
invite = self.query_one("#input-invite-code", Input).value.strip()
|
|
||||||
username = self.query_one("#input-signup-username", Input).value.strip()
|
|
||||||
email = self.query_one("#input-signup-email", Input).value.strip()
|
|
||||||
password = self.query_one("#input-signup-password", Input).value
|
|
||||||
if not username or not password:
|
|
||||||
self._set_status("Username and password required")
|
|
||||||
self._enable_buttons()
|
|
||||||
return
|
|
||||||
if len(password) < 8:
|
|
||||||
self._set_status("Password must be at least 8 characters")
|
|
||||||
self._enable_buttons()
|
|
||||||
return
|
|
||||||
await client.register(username, password, invite_code=invite, email=email)
|
|
||||||
self._set_status(f"Account created! Welcome, {client.username}")
|
|
||||||
await self._connect_ws()
|
|
||||||
except Exception as e:
|
|
||||||
self._set_status(f"[red]{e}[/red]")
|
|
||||||
self._enable_buttons()
|
|
||||||
|
|
||||||
async def _connect_ws(self) -> None:
|
|
||||||
client = self.app.client
|
|
||||||
self._set_status("Connecting...")
|
|
||||||
await client.connect()
|
|
||||||
client.save_session()
|
|
||||||
self._set_status("Connected!")
|
|
||||||
from tui_client.screens.lobby import LobbyScreen
|
|
||||||
self.app.switch_screen(LobbyScreen())
|
|
||||||
|
|
||||||
def _set_status(self, text: str) -> None:
|
|
||||||
self.query_one("#connect-status", Static).update(text)
|
|
||||||
|
|
||||||
def _disable_buttons(self) -> None:
|
|
||||||
for btn in self.query("Button"):
|
|
||||||
btn.disabled = True
|
|
||||||
|
|
||||||
def _enable_buttons(self) -> None:
|
|
||||||
for btn in self.query("Button"):
|
|
||||||
btn.disabled = False
|
|
||||||
@ -1,810 +0,0 @@
|
|||||||
"""Main game board screen with keyboard actions and message dispatch."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, Horizontal
|
|
||||||
from textual.events import Resize
|
|
||||||
from textual.screen import ModalScreen, Screen
|
|
||||||
from textual.widgets import Button, Static
|
|
||||||
|
|
||||||
from tui_client.models import GameState, PlayerData
|
|
||||||
from tui_client.screens.confirm import ConfirmScreen
|
|
||||||
from tui_client.widgets.hand import HandWidget
|
|
||||||
from tui_client.widgets.play_area import PlayAreaWidget
|
|
||||||
from tui_client.widgets.scoreboard import ScoreboardScreen
|
|
||||||
from tui_client.widgets.status_bar import StatusBarWidget
|
|
||||||
|
|
||||||
|
|
||||||
_HELP_TEXT = """\
|
|
||||||
[bold]Keyboard Commands[/bold]
|
|
||||||
|
|
||||||
[bold]Drawing[/bold]
|
|
||||||
\\[d] Draw from deck
|
|
||||||
\\[s] Pick from discard pile
|
|
||||||
|
|
||||||
[bold]Card Actions[/bold]
|
|
||||||
\\[1]-\\[6] Select card position
|
|
||||||
(flip, swap, or initial flip)
|
|
||||||
\\[x] Discard held card
|
|
||||||
\\[c] Cancel draw (from discard)
|
|
||||||
|
|
||||||
[bold]Special Actions[/bold]
|
|
||||||
\\[f] Flip a card (when enabled)
|
|
||||||
\\[p] Skip optional flip
|
|
||||||
\\[k] Knock early (when enabled)
|
|
||||||
|
|
||||||
[bold]Game Flow[/bold]
|
|
||||||
\\[n] Next hole
|
|
||||||
\\[tab] Standings
|
|
||||||
\\[q] Quit / leave game
|
|
||||||
\\[h] This help screen
|
|
||||||
|
|
||||||
[dim]\\[esc] to close[/dim]\
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class StandingsScreen(ModalScreen):
|
|
||||||
"""Modal overlay showing current game standings."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "close", "Close"),
|
|
||||||
("tab", "close", "Close"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, players: list, current_round: int, total_rounds: int) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self._players = players
|
|
||||||
self._current_round = current_round
|
|
||||||
self._total_rounds = total_rounds
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="standings-dialog"):
|
|
||||||
yield Static(
|
|
||||||
f"[bold]Standings — Hole {self._current_round}/{self._total_rounds}[/bold]",
|
|
||||||
id="standings-title",
|
|
||||||
)
|
|
||||||
yield Static(self._build_table(), id="standings-body")
|
|
||||||
yield Static("[dim]\\[esc] to close[/dim]", id="standings-hint")
|
|
||||||
|
|
||||||
def _build_table(self) -> str:
|
|
||||||
sorted_players = sorted(self._players, key=lambda p: p.total_score)
|
|
||||||
lines = []
|
|
||||||
for i, p in enumerate(sorted_players, 1):
|
|
||||||
score_str = f"{p.total_score:>4}"
|
|
||||||
lines.append(f" {i}. {p.name:<16} {score_str}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def action_close(self) -> None:
|
|
||||||
self.dismiss()
|
|
||||||
|
|
||||||
|
|
||||||
class HelpScreen(ModalScreen):
|
|
||||||
"""Modal help overlay showing all keyboard commands."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "close", "Close"),
|
|
||||||
("h", "close", "Close"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="help-dialog"):
|
|
||||||
yield Static(_HELP_TEXT, id="help-text")
|
|
||||||
|
|
||||||
def action_close(self) -> None:
|
|
||||||
self.dismiss()
|
|
||||||
|
|
||||||
|
|
||||||
class GameScreen(Screen):
|
|
||||||
"""Main game board with card display and keyboard controls."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("d", "draw_deck", "Draw from deck"),
|
|
||||||
("s", "pick_discard", "Pick from discard"),
|
|
||||||
("1", "select_1", "Position 1"),
|
|
||||||
("2", "select_2", "Position 2"),
|
|
||||||
("3", "select_3", "Position 3"),
|
|
||||||
("4", "select_4", "Position 4"),
|
|
||||||
("5", "select_5", "Position 5"),
|
|
||||||
("6", "select_6", "Position 6"),
|
|
||||||
("x", "discard_held", "Discard held card"),
|
|
||||||
("c", "cancel_draw", "Cancel draw"),
|
|
||||||
("f", "flip_mode", "Flip card"),
|
|
||||||
("p", "skip_flip", "Skip flip"),
|
|
||||||
("k", "knock_early", "Knock early"),
|
|
||||||
("n", "next_round", "Next round"),
|
|
||||||
("q", "quit_game", "Quit game"),
|
|
||||||
("h", "show_help", "Help"),
|
|
||||||
("tab", "show_standings", "Standings"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, initial_state: dict, is_host: bool = False):
|
|
||||||
super().__init__()
|
|
||||||
self._state = GameState.from_dict(initial_state)
|
|
||||||
self._is_host = is_host
|
|
||||||
self._player_id: str = ""
|
|
||||||
self._awaiting_flip = False
|
|
||||||
self._awaiting_initial_flip = False
|
|
||||||
self._initial_flip_positions: list[int] = []
|
|
||||||
self._can_flip_optional = False
|
|
||||||
self._term_width: int = 80
|
|
||||||
self._swap_flash: dict[str, int] = {} # player_id -> position of last swap
|
|
||||||
self._discard_flash: bool = False # discard pile just changed
|
|
||||||
self._pending_reveal: dict | None = None # server-sent reveal for opponents
|
|
||||||
self._reveal_active: bool = False # reveal animation in progress
|
|
||||||
self._deferred_state: GameState | None = None # queued state during reveal
|
|
||||||
self._term_height: int = 24
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
yield StatusBarWidget(id="status-bar")
|
|
||||||
with Container(id="game-content"):
|
|
||||||
yield Static("", id="opponents-area")
|
|
||||||
with Horizontal(id="play-area-row"):
|
|
||||||
yield PlayAreaWidget(id="play-area")
|
|
||||||
yield Static("", id="local-hand-label")
|
|
||||||
yield HandWidget(id="local-hand")
|
|
||||||
with Horizontal(id="game-footer"):
|
|
||||||
yield Static("s\\[⇥]andings \\[h]elp", id="footer-left")
|
|
||||||
yield Static("", id="footer-center")
|
|
||||||
yield Static("\\[q]uit", id="footer-right")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self._player_id = self.app.player_id or ""
|
|
||||||
self._term_width = self.app.size.width
|
|
||||||
self._term_height = self.app.size.height
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
def on_resize(self, event: Resize) -> None:
|
|
||||||
self._term_width = event.size.width
|
|
||||||
self._term_height = event.size.height
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
def on_server_message(self, event) -> None:
|
|
||||||
"""Dispatch server messages to handlers."""
|
|
||||||
handler = getattr(self, f"_handle_{event.msg_type}", None)
|
|
||||||
if handler:
|
|
||||||
handler(event.data)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Server message handlers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _handle_game_state(self, data: dict) -> None:
|
|
||||||
state_data = data.get("game_state", data)
|
|
||||||
old_state = self._state
|
|
||||||
new_state = GameState.from_dict(state_data)
|
|
||||||
reveal = self._detect_swaps(old_state, new_state)
|
|
||||||
if reveal:
|
|
||||||
# Briefly show the old face-down card before applying new state
|
|
||||||
self._show_reveal_then_update(reveal, new_state)
|
|
||||||
elif self._reveal_active:
|
|
||||||
# A reveal is showing — queue this state for after it finishes
|
|
||||||
self._deferred_state = new_state
|
|
||||||
else:
|
|
||||||
self._state = new_state
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
def _show_reveal_then_update(
|
|
||||||
self,
|
|
||||||
reveal: dict,
|
|
||||||
new_state: GameState,
|
|
||||||
) -> None:
|
|
||||||
"""Show the old card face-up for 1s, then apply the new state."""
|
|
||||||
from tui_client.models import CardData
|
|
||||||
|
|
||||||
player_id = reveal["player_id"]
|
|
||||||
position = reveal["position"]
|
|
||||||
old_card_data = reveal["card"]
|
|
||||||
|
|
||||||
# Modify current state to show old card face-up
|
|
||||||
for p in self._state.players:
|
|
||||||
if p.id == player_id and position < len(p.cards):
|
|
||||||
p.cards[position] = CardData(
|
|
||||||
suit=old_card_data.get("suit"),
|
|
||||||
rank=old_card_data.get("rank"),
|
|
||||||
face_up=True,
|
|
||||||
deck_id=old_card_data.get("deck_id"),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
self._reveal_active = True
|
|
||||||
self._deferred_state = new_state
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
# After 1 second, apply the real new state
|
|
||||||
def apply_new():
|
|
||||||
self._reveal_active = False
|
|
||||||
state = self._deferred_state
|
|
||||||
self._deferred_state = None
|
|
||||||
if state:
|
|
||||||
self._state = state
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
self.set_timer(1.0, apply_new)
|
|
||||||
|
|
||||||
def _handle_card_revealed(self, data: dict) -> None:
|
|
||||||
"""Server sent old card data for an opponent's face-down swap."""
|
|
||||||
# Store the reveal data so next game_state can use it
|
|
||||||
self._pending_reveal = {
|
|
||||||
"player_id": data.get("player_id"),
|
|
||||||
"position": data.get("position", 0),
|
|
||||||
"card": data.get("card", {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _handle_your_turn(self, data: dict) -> None:
|
|
||||||
self._awaiting_flip = False
|
|
||||||
self._refresh_action_bar()
|
|
||||||
|
|
||||||
def _handle_card_drawn(self, data: dict) -> None:
|
|
||||||
from tui_client.models import CardData
|
|
||||||
|
|
||||||
card = CardData.from_dict(data.get("card", {}))
|
|
||||||
source = data.get("source", "deck")
|
|
||||||
rank = card.display_rank
|
|
||||||
suit = card.display_suit
|
|
||||||
|
|
||||||
if source == "discard":
|
|
||||||
self._set_action(
|
|
||||||
f"Holding {rank}{suit} — Choose spot \\[1] thru \\[6] or \\[c]ancel", active=True
|
|
||||||
)
|
|
||||||
self._set_keymap("[1-6] Swap [C] Cancel")
|
|
||||||
else:
|
|
||||||
self._set_action(
|
|
||||||
f"Holding {rank}{suit} — Choose spot \\[1] thru \\[6] or \\[x] to discard", active=True
|
|
||||||
)
|
|
||||||
self._set_keymap("[1-6] Swap [X] Discard")
|
|
||||||
|
|
||||||
def _handle_can_flip(self, data: dict) -> None:
|
|
||||||
self._awaiting_flip = True
|
|
||||||
optional = data.get("optional", False)
|
|
||||||
self._can_flip_optional = optional
|
|
||||||
if optional:
|
|
||||||
self._set_action("Flip a card \\[1] thru \\[6] or \\[p] to skip", active=True)
|
|
||||||
self._set_keymap("[1-6] Flip card [P] Skip")
|
|
||||||
else:
|
|
||||||
self._set_action("Flip a face-down card \\[1] thru \\[6]", active=True)
|
|
||||||
self._set_keymap("[1-6] Flip card")
|
|
||||||
|
|
||||||
def _handle_round_over(self, data: dict) -> None:
|
|
||||||
scores = data.get("scores", [])
|
|
||||||
round_num = data.get("round", 1)
|
|
||||||
total_rounds = data.get("total_rounds", 1)
|
|
||||||
finisher_id = data.get("finisher_id")
|
|
||||||
|
|
||||||
# Delay so players can see the final card layout before the overlay
|
|
||||||
self.set_timer(
|
|
||||||
3.0,
|
|
||||||
lambda: self.app.push_screen(
|
|
||||||
ScoreboardScreen(
|
|
||||||
scores=scores,
|
|
||||||
title=f"Hole {round_num} Complete",
|
|
||||||
is_game_over=False,
|
|
||||||
is_host=self._is_host,
|
|
||||||
round_num=round_num,
|
|
||||||
total_rounds=total_rounds,
|
|
||||||
finisher_id=finisher_id,
|
|
||||||
),
|
|
||||||
callback=self._on_scoreboard_dismiss,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _handle_game_over(self, data: dict) -> None:
|
|
||||||
scores = data.get("final_scores", [])
|
|
||||||
|
|
||||||
self.app.push_screen(
|
|
||||||
ScoreboardScreen(
|
|
||||||
scores=scores,
|
|
||||||
title="Game Over!",
|
|
||||||
is_game_over=True,
|
|
||||||
is_host=self._is_host,
|
|
||||||
),
|
|
||||||
callback=self._on_scoreboard_dismiss,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _handle_round_started(self, data: dict) -> None:
|
|
||||||
state_data = data.get("game_state", data)
|
|
||||||
self._state = GameState.from_dict(state_data)
|
|
||||||
self._awaiting_flip = False
|
|
||||||
self._awaiting_initial_flip = False
|
|
||||||
self._initial_flip_positions = []
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
def _handle_game_ended(self, data: dict) -> None:
|
|
||||||
reason = data.get("reason", "Game ended")
|
|
||||||
self._set_action(f"{reason}. Press Escape to return to lobby.")
|
|
||||||
|
|
||||||
def _handle_error(self, data: dict) -> None:
|
|
||||||
msg = data.get("message", "Unknown error")
|
|
||||||
self._set_action(f"[red]Error: {msg}[/red]")
|
|
||||||
|
|
||||||
def _handle_connection_closed(self, data: dict) -> None:
|
|
||||||
self._set_action("[red]Connection lost.[/red]")
|
|
||||||
|
|
||||||
def _on_scoreboard_dismiss(self, result: str | None) -> None:
|
|
||||||
if result == "next_round":
|
|
||||||
self.run_worker(self._send("next_round"))
|
|
||||||
elif result == "lobby":
|
|
||||||
self.run_worker(self._send("leave_game"))
|
|
||||||
self.app.pop_screen()
|
|
||||||
# Reset lobby back to create/join state
|
|
||||||
lobby = self.app.screen
|
|
||||||
if hasattr(lobby, "reset_to_pre_room"):
|
|
||||||
lobby.reset_to_pre_room()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Click handlers (from widget messages)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def on_hand_widget_card_clicked(self, event: HandWidget.CardClicked) -> None:
|
|
||||||
"""Handle click on a card in the local hand."""
|
|
||||||
self._select_position(event.position)
|
|
||||||
|
|
||||||
def on_play_area_widget_deck_clicked(self, event: PlayAreaWidget.DeckClicked) -> None:
|
|
||||||
"""Handle click on the deck."""
|
|
||||||
self.action_draw_deck()
|
|
||||||
|
|
||||||
def on_play_area_widget_discard_clicked(self, event: PlayAreaWidget.DiscardClicked) -> None:
|
|
||||||
"""Handle click on the discard pile.
|
|
||||||
|
|
||||||
If holding a card, discard it. Otherwise, draw from discard.
|
|
||||||
"""
|
|
||||||
if self._state and self._state.has_drawn_card:
|
|
||||||
self.action_discard_held()
|
|
||||||
else:
|
|
||||||
self.action_pick_discard()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Keyboard actions
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def action_draw_deck(self) -> None:
|
|
||||||
if not self._is_my_turn() or self._state.has_drawn_card:
|
|
||||||
return
|
|
||||||
self.run_worker(self._send("draw", source="deck"))
|
|
||||||
|
|
||||||
def action_pick_discard(self) -> None:
|
|
||||||
if not self._is_my_turn() or self._state.has_drawn_card:
|
|
||||||
return
|
|
||||||
if not self._state.discard_top:
|
|
||||||
return
|
|
||||||
self.run_worker(self._send("draw", source="discard"))
|
|
||||||
|
|
||||||
def action_select_1(self) -> None:
|
|
||||||
self._select_position(0)
|
|
||||||
|
|
||||||
def action_select_2(self) -> None:
|
|
||||||
self._select_position(1)
|
|
||||||
|
|
||||||
def action_select_3(self) -> None:
|
|
||||||
self._select_position(2)
|
|
||||||
|
|
||||||
def action_select_4(self) -> None:
|
|
||||||
self._select_position(3)
|
|
||||||
|
|
||||||
def action_select_5(self) -> None:
|
|
||||||
self._select_position(4)
|
|
||||||
|
|
||||||
def action_select_6(self) -> None:
|
|
||||||
self._select_position(5)
|
|
||||||
|
|
||||||
def _select_position(self, pos: int) -> None:
|
|
||||||
# Initial flip phase
|
|
||||||
if self._state.waiting_for_initial_flip:
|
|
||||||
self._handle_initial_flip_select(pos)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Flip after discard
|
|
||||||
if self._awaiting_flip:
|
|
||||||
self._do_flip(pos)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Swap with held card
|
|
||||||
if self._state.has_drawn_card and self._is_my_turn():
|
|
||||||
self.run_worker(self._send("swap", position=pos))
|
|
||||||
return
|
|
||||||
|
|
||||||
def _handle_initial_flip_select(self, pos: int) -> None:
|
|
||||||
if pos in self._initial_flip_positions:
|
|
||||||
return # already selected
|
|
||||||
# Reject already face-up cards
|
|
||||||
me = self._get_local_player()
|
|
||||||
if me and pos < len(me.cards) and me.cards[pos].face_up:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._initial_flip_positions.append(pos)
|
|
||||||
|
|
||||||
# Immediately show the card as face-up locally for visual feedback
|
|
||||||
if me and pos < len(me.cards):
|
|
||||||
me.cards[pos].face_up = True
|
|
||||||
hand = self.query_one("#local-hand", HandWidget)
|
|
||||||
hand.update_player(
|
|
||||||
me,
|
|
||||||
deck_colors=self._state.deck_colors,
|
|
||||||
is_current_turn=False,
|
|
||||||
is_knocker=False,
|
|
||||||
is_dealer=(me.id == self._state.dealer_id),
|
|
||||||
highlight=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
needed = self._state.initial_flips
|
|
||||||
selected = len(self._initial_flip_positions)
|
|
||||||
|
|
||||||
if selected >= needed:
|
|
||||||
self.run_worker(
|
|
||||||
self._send("flip_initial", positions=self._initial_flip_positions)
|
|
||||||
)
|
|
||||||
self._awaiting_initial_flip = False
|
|
||||||
self._initial_flip_positions = []
|
|
||||||
else:
|
|
||||||
self._set_action(
|
|
||||||
f"Choose {needed - selected} more card(s) to flip ({selected}/{needed})", active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def _do_flip(self, pos: int) -> None:
|
|
||||||
me = self._get_local_player()
|
|
||||||
if me and pos < len(me.cards) and me.cards[pos].face_up:
|
|
||||||
self._set_action("That card is already face-up! Pick a face-down card.")
|
|
||||||
return
|
|
||||||
self.run_worker(self._send("flip_card", position=pos))
|
|
||||||
self._awaiting_flip = False
|
|
||||||
|
|
||||||
def action_discard_held(self) -> None:
|
|
||||||
if not self._is_my_turn() or not self._state.has_drawn_card:
|
|
||||||
return
|
|
||||||
if not self._state.can_discard:
|
|
||||||
self._set_action("Can't discard a card drawn from discard. Swap or cancel.")
|
|
||||||
return
|
|
||||||
self.run_worker(self._send("discard"))
|
|
||||||
|
|
||||||
def action_cancel_draw(self) -> None:
|
|
||||||
if not self._is_my_turn() or not self._state.has_drawn_card:
|
|
||||||
return
|
|
||||||
self.run_worker(self._send("cancel_draw"))
|
|
||||||
|
|
||||||
def action_flip_mode(self) -> None:
|
|
||||||
if self._state.flip_as_action and self._is_my_turn() and not self._state.has_drawn_card:
|
|
||||||
self._awaiting_flip = True
|
|
||||||
self._set_action("Flip mode: select a face-down card [1-6]", active=True)
|
|
||||||
|
|
||||||
def action_skip_flip(self) -> None:
|
|
||||||
if self._awaiting_flip and self._can_flip_optional:
|
|
||||||
self.run_worker(self._send("skip_flip"))
|
|
||||||
self._awaiting_flip = False
|
|
||||||
|
|
||||||
def action_knock_early(self) -> None:
|
|
||||||
if not self._is_my_turn() or self._state.has_drawn_card:
|
|
||||||
return
|
|
||||||
if not self._state.knock_early:
|
|
||||||
return
|
|
||||||
self.run_worker(self._send("knock_early"))
|
|
||||||
|
|
||||||
def action_next_round(self) -> None:
|
|
||||||
if self._is_host and self._state.phase == "round_over":
|
|
||||||
self.run_worker(self._send("next_round"))
|
|
||||||
|
|
||||||
def action_show_help(self) -> None:
|
|
||||||
self.app.push_screen(HelpScreen())
|
|
||||||
|
|
||||||
def action_show_standings(self) -> None:
|
|
||||||
self.app.push_screen(StandingsScreen(
|
|
||||||
self._state.players,
|
|
||||||
self._state.current_round,
|
|
||||||
self._state.total_rounds,
|
|
||||||
))
|
|
||||||
|
|
||||||
def action_quit_game(self) -> None:
|
|
||||||
if self._is_host:
|
|
||||||
msg = "End the game for everyone?"
|
|
||||||
else:
|
|
||||||
msg = "Leave this game?"
|
|
||||||
self.app.push_screen(ConfirmScreen(msg), callback=self._on_quit_confirm)
|
|
||||||
|
|
||||||
def _on_quit_confirm(self, confirmed: bool) -> None:
|
|
||||||
if confirmed:
|
|
||||||
self.action_leave_game()
|
|
||||||
|
|
||||||
def action_leave_game(self) -> None:
|
|
||||||
self.run_worker(self._send("leave_game"))
|
|
||||||
self.app.pop_screen()
|
|
||||||
# Reset lobby back to create/join state
|
|
||||||
lobby = self.app.screen
|
|
||||||
if hasattr(lobby, "reset_to_pre_room"):
|
|
||||||
lobby.reset_to_pre_room()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Swap/discard detection
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _detect_swaps(self, old: GameState, new: GameState) -> dict | None:
|
|
||||||
"""Compare old and new state to find which card positions changed.
|
|
||||||
|
|
||||||
Returns reveal info dict if a face-down card was swapped, else None.
|
|
||||||
"""
|
|
||||||
reveal = None
|
|
||||||
if not old or not new or not old.players or not new.players:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Only reveal during active play, not initial flip or round end
|
|
||||||
reveal_eligible = old.phase in ("playing", "final_turn")
|
|
||||||
|
|
||||||
old_map = {p.id: p for p in old.players}
|
|
||||||
for np in new.players:
|
|
||||||
op = old_map.get(np.id)
|
|
||||||
if not op:
|
|
||||||
continue
|
|
||||||
for i, (oc, nc) in enumerate(zip(op.cards, np.cards)):
|
|
||||||
# Card changed: rank/suit differ and new card is face-up
|
|
||||||
if (oc.rank != nc.rank or oc.suit != nc.suit) and nc.face_up:
|
|
||||||
self._swap_flash[np.id] = i
|
|
||||||
|
|
||||||
# Was old card face-down? If we have its data, reveal it
|
|
||||||
if reveal_eligible and not oc.face_up and oc.rank and oc.suit:
|
|
||||||
# Local player — we know face-down card values
|
|
||||||
reveal = {
|
|
||||||
"player_id": np.id,
|
|
||||||
"position": i,
|
|
||||||
"card": {"rank": oc.rank, "suit": oc.suit, "deck_id": oc.deck_id},
|
|
||||||
}
|
|
||||||
elif reveal_eligible and not oc.face_up and self._pending_reveal:
|
|
||||||
# Opponent — use server-sent reveal data
|
|
||||||
pr = self._pending_reveal
|
|
||||||
if pr.get("player_id") == np.id and pr.get("position") == i:
|
|
||||||
reveal = {
|
|
||||||
"player_id": np.id,
|
|
||||||
"position": i,
|
|
||||||
"card": pr["card"],
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
self._pending_reveal = None
|
|
||||||
|
|
||||||
# Detect discard change (new discard top differs from old)
|
|
||||||
if old.discard_top and new.discard_top:
|
|
||||||
if (old.discard_top.rank != new.discard_top.rank or
|
|
||||||
old.discard_top.suit != new.discard_top.suit):
|
|
||||||
self._discard_flash = True
|
|
||||||
elif not old.discard_top and new.discard_top:
|
|
||||||
self._discard_flash = True
|
|
||||||
|
|
||||||
# Schedule flash clear after 2 seconds
|
|
||||||
if self._swap_flash or self._discard_flash:
|
|
||||||
self.set_timer(1.0, self._clear_flash)
|
|
||||||
|
|
||||||
return reveal
|
|
||||||
|
|
||||||
def _clear_flash(self) -> None:
|
|
||||||
"""Clear swap/discard flash highlights and re-render."""
|
|
||||||
self._swap_flash.clear()
|
|
||||||
self._discard_flash = False
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _is_my_turn(self) -> bool:
|
|
||||||
return self._state.current_player_id == self._player_id
|
|
||||||
|
|
||||||
def _get_local_player(self) -> PlayerData | None:
|
|
||||||
for p in self._state.players:
|
|
||||||
if p.id == self._player_id:
|
|
||||||
return p
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _send(self, msg_type: str, **kwargs) -> None:
|
|
||||||
try:
|
|
||||||
await self.app.client.send(msg_type, **kwargs)
|
|
||||||
except Exception as e:
|
|
||||||
self._set_action(f"[red]Send error: {e}[/red]")
|
|
||||||
|
|
||||||
def _set_action(self, text: str, active: bool = False) -> None:
|
|
||||||
import re
|
|
||||||
try:
|
|
||||||
if active:
|
|
||||||
# Highlight bracketed keys and parenthesized counts in amber,
|
|
||||||
# rest in bold white
|
|
||||||
ac = "#ffaa00"
|
|
||||||
# Color \\[...] key hints and (...) counts
|
|
||||||
text = re.sub(
|
|
||||||
r"(\\?\[.*?\]|\([\d/]+\))",
|
|
||||||
rf"[bold {ac}]\1[/]",
|
|
||||||
text,
|
|
||||||
)
|
|
||||||
text = f"[bold white]{text}[/]"
|
|
||||||
self.query_one("#footer-center", Static).update(text)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Rendering
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _full_refresh(self) -> None:
|
|
||||||
"""Refresh all widgets from current game state."""
|
|
||||||
state = self._state
|
|
||||||
|
|
||||||
# Status bar
|
|
||||||
status = self.query_one("#status-bar", StatusBarWidget)
|
|
||||||
status.update_state(state, self._player_id)
|
|
||||||
|
|
||||||
# Play area
|
|
||||||
play_area = self.query_one("#play-area", PlayAreaWidget)
|
|
||||||
play_area.update_state(state, local_player_id=self._player_id, discard_flash=self._discard_flash)
|
|
||||||
is_active = self._is_my_turn() and not state.waiting_for_initial_flip
|
|
||||||
play_area.set_class(is_active, "my-turn")
|
|
||||||
|
|
||||||
# Local player hand (in bordered box with turn/knocker indicators)
|
|
||||||
me = self._get_local_player()
|
|
||||||
if me:
|
|
||||||
self.query_one("#local-hand-label", Static).update("")
|
|
||||||
hand = self.query_one("#local-hand", HandWidget)
|
|
||||||
hand._is_local = True
|
|
||||||
# During initial flip, don't show current_turn borders (no one is "taking a turn")
|
|
||||||
show_turn = not state.waiting_for_initial_flip and me.id == state.current_player_id
|
|
||||||
hand.update_player(
|
|
||||||
me,
|
|
||||||
deck_colors=state.deck_colors,
|
|
||||||
is_current_turn=show_turn,
|
|
||||||
is_knocker=(me.id == state.finisher_id and state.phase == "final_turn"),
|
|
||||||
is_dealer=(me.id == state.dealer_id),
|
|
||||||
highlight=state.waiting_for_initial_flip,
|
|
||||||
flash_position=self._swap_flash.get(me.id),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.query_one("#local-hand-label", Static).update("")
|
|
||||||
|
|
||||||
# Opponents - bordered boxes in a single Static
|
|
||||||
opponents = [p for p in state.players if p.id != self._player_id]
|
|
||||||
self._render_opponents(opponents)
|
|
||||||
|
|
||||||
# Action bar
|
|
||||||
self._refresh_action_bar()
|
|
||||||
|
|
||||||
def _render_opponents(self, opponents: list[PlayerData]) -> None:
|
|
||||||
"""Render all opponent hands as bordered boxes into the opponents area.
|
|
||||||
|
|
||||||
Adapts layout based on terminal width:
|
|
||||||
- Narrow (<80): stack opponents vertically
|
|
||||||
- Medium (80-119): 2-3 side-by-side with moderate spacing
|
|
||||||
- Wide (120+): all side-by-side with generous spacing
|
|
||||||
"""
|
|
||||||
if not opponents:
|
|
||||||
self.query_one("#opponents-area", Static).update("")
|
|
||||||
return
|
|
||||||
|
|
||||||
from tui_client.widgets.hand import _check_column_match, _render_card_lines
|
|
||||||
from tui_client.widgets.player_box import _visible_len, render_player_box
|
|
||||||
|
|
||||||
state = self._state
|
|
||||||
deck_colors = state.deck_colors
|
|
||||||
width = self._term_width
|
|
||||||
|
|
||||||
# Build each opponent's boxed display
|
|
||||||
opp_blocks: list[list[str]] = []
|
|
||||||
for opp in opponents:
|
|
||||||
cards = opp.cards
|
|
||||||
matched = _check_column_match(cards)
|
|
||||||
card_lines = _render_card_lines(
|
|
||||||
cards, deck_colors=deck_colors, matched=matched,
|
|
||||||
flash_position=self._swap_flash.get(opp.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
opp_turn = not state.waiting_for_initial_flip and opp.id == state.current_player_id
|
|
||||||
display_score = opp.score if opp.score is not None else opp.visible_score
|
|
||||||
box = render_player_box(
|
|
||||||
opp.name,
|
|
||||||
score=display_score,
|
|
||||||
total_score=opp.total_score,
|
|
||||||
content_lines=card_lines,
|
|
||||||
is_current_turn=opp_turn,
|
|
||||||
is_knocker=(opp.id == state.finisher_id and state.phase == "final_turn"),
|
|
||||||
is_dealer=(opp.id == state.dealer_id),
|
|
||||||
)
|
|
||||||
opp_blocks.append(box)
|
|
||||||
|
|
||||||
# Determine how many opponents fit per row
|
|
||||||
# Account for padding on the opponents-area widget (2 chars each side)
|
|
||||||
try:
|
|
||||||
opp_widget = self.query_one("#opponents-area", Static)
|
|
||||||
avail_width = opp_widget.content_size.width or (width - 4)
|
|
||||||
except Exception:
|
|
||||||
avail_width = width - 4
|
|
||||||
|
|
||||||
box_widths = [_visible_len(b[0]) if b else 22 for b in opp_blocks]
|
|
||||||
gap = " " if avail_width < 120 else " "
|
|
||||||
gap_len = len(gap)
|
|
||||||
|
|
||||||
# Greedily fit as many as possible in one row
|
|
||||||
per_row = 0
|
|
||||||
row_width = 0
|
|
||||||
for bw in box_widths:
|
|
||||||
needed_width = bw if per_row == 0 else gap_len + bw
|
|
||||||
if row_width + needed_width <= avail_width:
|
|
||||||
row_width += needed_width
|
|
||||||
per_row += 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
per_row = max(1, per_row)
|
|
||||||
|
|
||||||
# Render in rows of per_row opponents
|
|
||||||
all_row_lines: list[str] = []
|
|
||||||
for chunk_start in range(0, len(opp_blocks), per_row):
|
|
||||||
chunk = opp_blocks[chunk_start : chunk_start + per_row]
|
|
||||||
|
|
||||||
if len(chunk) == 1:
|
|
||||||
all_row_lines.extend(chunk[0])
|
|
||||||
else:
|
|
||||||
max_height = max(len(b) for b in chunk)
|
|
||||||
# Pad shorter blocks with spaces matching each block's visible width
|
|
||||||
for b in chunk:
|
|
||||||
if b:
|
|
||||||
pad_width = _visible_len(b[0])
|
|
||||||
else:
|
|
||||||
pad_width = 0
|
|
||||||
while len(b) < max_height:
|
|
||||||
b.append(" " * pad_width)
|
|
||||||
for row_idx in range(max_height):
|
|
||||||
parts = [b[row_idx] for b in chunk]
|
|
||||||
all_row_lines.append(gap.join(parts))
|
|
||||||
|
|
||||||
if chunk_start + per_row < len(opp_blocks):
|
|
||||||
all_row_lines.append("")
|
|
||||||
|
|
||||||
self.query_one("#opponents-area", Static).update("\n".join(all_row_lines))
|
|
||||||
|
|
||||||
def _refresh_action_bar(self) -> None:
|
|
||||||
"""Update action bar and keymap based on current game state."""
|
|
||||||
state = self._state
|
|
||||||
|
|
||||||
if state.phase in ("round_over", "game_over"):
|
|
||||||
self._set_action("\\[n]ext hole", active=True)
|
|
||||||
self._set_keymap("[N] Next hole")
|
|
||||||
return
|
|
||||||
|
|
||||||
if state.waiting_for_initial_flip:
|
|
||||||
needed = state.initial_flips
|
|
||||||
selected = len(self._initial_flip_positions)
|
|
||||||
self._set_action(
|
|
||||||
f"Choose {needed} cards \\[1] thru \\[6] to flip ({selected}/{needed})", active=True
|
|
||||||
)
|
|
||||||
self._set_keymap("[1-6] Select card")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self._is_my_turn():
|
|
||||||
if state.current_player_id:
|
|
||||||
for p in state.players:
|
|
||||||
if p.id == state.current_player_id:
|
|
||||||
self._set_action(f"Waiting for {p.name}...")
|
|
||||||
self._set_keymap("Waiting...")
|
|
||||||
return
|
|
||||||
self._set_action("Waiting...")
|
|
||||||
self._set_keymap("Waiting...")
|
|
||||||
return
|
|
||||||
|
|
||||||
if state.has_drawn_card:
|
|
||||||
keys = ["[1-6] Swap"]
|
|
||||||
if state.can_discard:
|
|
||||||
self._set_action("Choose spot \\[1] thru \\[6] or \\[x] to discard", active=True)
|
|
||||||
keys.append("[X] Discard")
|
|
||||||
else:
|
|
||||||
self._set_action("Choose spot \\[1] thru \\[6] or \\[c]ancel", active=True)
|
|
||||||
keys.append("[C] Cancel")
|
|
||||||
self._set_keymap(" ".join(keys))
|
|
||||||
return
|
|
||||||
|
|
||||||
parts = ["Choose \\[d]eck or di\\[s]card pile"]
|
|
||||||
keys = ["[D] Draw", "[S] Pick discard"]
|
|
||||||
if state.flip_as_action:
|
|
||||||
parts.append("\\[f]lip a card")
|
|
||||||
keys.append("[F] Flip")
|
|
||||||
if state.knock_early:
|
|
||||||
parts.append("\\[k]nock early")
|
|
||||||
keys.append("[K] Knock")
|
|
||||||
self._set_action(" or ".join(parts), active=True)
|
|
||||||
self._set_keymap(" ".join(keys))
|
|
||||||
|
|
||||||
def _set_keymap(self, text: str) -> None:
|
|
||||||
try:
|
|
||||||
self.app.set_keymap(text)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
@ -1,561 +0,0 @@
|
|||||||
"""Lobby screen: create/join room, add CPUs, configure, start game."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, Horizontal, Vertical
|
|
||||||
from textual.screen import Screen
|
|
||||||
from textual.widgets import (
|
|
||||||
Button,
|
|
||||||
Collapsible,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
OptionList,
|
|
||||||
Select,
|
|
||||||
Static,
|
|
||||||
Switch,
|
|
||||||
)
|
|
||||||
from textual.widgets.option_list import Option
|
|
||||||
|
|
||||||
|
|
||||||
DECK_PRESETS = {
|
|
||||||
"classic": ["red", "blue", "gold"],
|
|
||||||
"ninja": ["green", "purple", "orange"],
|
|
||||||
"ocean": ["blue", "teal", "cyan"],
|
|
||||||
"forest": ["green", "gold", "brown"],
|
|
||||||
"sunset": ["orange", "red", "purple"],
|
|
||||||
"berry": ["purple", "pink", "red"],
|
|
||||||
"neon": ["pink", "cyan", "green"],
|
|
||||||
"royal": ["purple", "gold", "red"],
|
|
||||||
"earth": ["brown", "green", "gold"],
|
|
||||||
"all-red": ["red", "red", "red"],
|
|
||||||
"all-blue": ["blue", "blue", "blue"],
|
|
||||||
"all-green": ["green", "green", "green"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class LobbyScreen(Screen):
|
|
||||||
"""Room creation, joining, and pre-game configuration."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("plus_sign", "add_cpu", "Add CPU"),
|
|
||||||
("equals_sign", "add_cpu", "Add CPU"),
|
|
||||||
("hyphen_minus", "remove_cpu", "Remove CPU"),
|
|
||||||
("enter", "start_or_create", "Start/Create"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._room_code: str | None = None
|
|
||||||
self._player_id: str | None = None
|
|
||||||
self._is_host: bool = False
|
|
||||||
self._players: list[dict] = []
|
|
||||||
self._in_room: bool = False
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="lobby-container"):
|
|
||||||
yield Static(
|
|
||||||
"⛳🏌️ [bold]GolfCards.club[/bold] "
|
|
||||||
"[bold #aaaaaa]♠[/bold #aaaaaa]"
|
|
||||||
"[bold #cc0000]♥[/bold #cc0000]"
|
|
||||||
"[bold #aaaaaa]♣[/bold #aaaaaa]"
|
|
||||||
"[bold #cc0000]♦[/bold #cc0000]",
|
|
||||||
id="lobby-title",
|
|
||||||
)
|
|
||||||
# Pre-room: join/create
|
|
||||||
with Vertical(id="pre-room"):
|
|
||||||
yield Input(placeholder="Room code (leave blank to create new)", id="input-room-code")
|
|
||||||
with Horizontal(id="pre-room-buttons"):
|
|
||||||
yield Button("Create Room", id="btn-create", variant="primary")
|
|
||||||
yield Button("Join Room", id="btn-join", variant="default")
|
|
||||||
|
|
||||||
# In-room: player list + controls + settings
|
|
||||||
with Vertical(id="in-room"):
|
|
||||||
yield Static("", id="room-info")
|
|
||||||
yield Static("[bold]Players[/bold]", id="player-list-label")
|
|
||||||
yield Static("", id="player-list")
|
|
||||||
|
|
||||||
# CPU controls: compact [+] [-]
|
|
||||||
with Horizontal(id="cpu-controls"):
|
|
||||||
yield Label("CPU:", id="cpu-label")
|
|
||||||
yield Button("+", id="btn-cpu-add", variant="default")
|
|
||||||
yield Button("−", id="btn-cpu-remove", variant="warning")
|
|
||||||
yield Button("?", id="btn-cpu-random", variant="default")
|
|
||||||
|
|
||||||
# CPU profile picker (hidden by default)
|
|
||||||
yield OptionList(id="cpu-profile-list")
|
|
||||||
|
|
||||||
# Host settings (collapsible sections)
|
|
||||||
with Vertical(id="host-settings"):
|
|
||||||
with Collapsible(title="Game Settings", collapsed=True, id="coll-game"):
|
|
||||||
with Horizontal(classes="setting-row"):
|
|
||||||
yield Label("Holes")
|
|
||||||
yield Select(
|
|
||||||
[(str(v), v) for v in (1, 3, 9, 18)],
|
|
||||||
value=9,
|
|
||||||
id="sel-rounds",
|
|
||||||
allow_blank=False,
|
|
||||||
)
|
|
||||||
with Horizontal(classes="setting-row"):
|
|
||||||
yield Label("Decks")
|
|
||||||
yield Select(
|
|
||||||
[(str(v), v) for v in (1, 2, 3)],
|
|
||||||
value=1,
|
|
||||||
id="sel-decks",
|
|
||||||
allow_blank=False,
|
|
||||||
)
|
|
||||||
with Horizontal(classes="setting-row"):
|
|
||||||
yield Label("Initial Flips")
|
|
||||||
yield Select(
|
|
||||||
[(str(v), v) for v in (0, 1, 2)],
|
|
||||||
value=2,
|
|
||||||
id="sel-initial-flips",
|
|
||||||
allow_blank=False,
|
|
||||||
)
|
|
||||||
with Horizontal(classes="setting-row"):
|
|
||||||
yield Label("Flip Mode")
|
|
||||||
yield Select(
|
|
||||||
[("Never", "never"), ("Always", "always"), ("Endgame", "endgame")],
|
|
||||||
value="never",
|
|
||||||
id="sel-flip-mode",
|
|
||||||
allow_blank=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
with Collapsible(title="House Rules", collapsed=True, id="coll-rules"):
|
|
||||||
# Joker variant
|
|
||||||
with Horizontal(classes="setting-row"):
|
|
||||||
yield Label("Jokers")
|
|
||||||
yield Select(
|
|
||||||
[
|
|
||||||
("None", "none"),
|
|
||||||
("Standard (−2)", "standard"),
|
|
||||||
("Lucky Swing (−5)", "lucky_swing"),
|
|
||||||
("Eagle Eye (+2/−4)", "eagle_eye"),
|
|
||||||
],
|
|
||||||
value="none",
|
|
||||||
id="sel-jokers",
|
|
||||||
allow_blank=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Scoring rules
|
|
||||||
yield Static("[bold]Scoring[/bold]", classes="rules-header")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Super Kings (K = −2)")
|
|
||||||
yield Switch(id="sw-super_kings")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Ten Penny (10 = 1)")
|
|
||||||
yield Switch(id="sw-ten_penny")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("One-Eyed Jacks (J♥/J♠ = 0)")
|
|
||||||
yield Switch(id="sw-one_eyed_jacks")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Negative Pairs Keep Value")
|
|
||||||
yield Switch(id="sw-negative_pairs_keep_value")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Four of a Kind (−20)")
|
|
||||||
yield Switch(id="sw-four_of_a_kind")
|
|
||||||
|
|
||||||
# Knock & Endgame
|
|
||||||
yield Static("[bold]Knock & Endgame[/bold]", classes="rules-header")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Knock Penalty (+10)")
|
|
||||||
yield Switch(id="sw-knock_penalty")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Knock Bonus (−5)")
|
|
||||||
yield Switch(id="sw-knock_bonus")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Knock Early")
|
|
||||||
yield Switch(id="sw-knock_early")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Flip as Action")
|
|
||||||
yield Switch(id="sw-flip_as_action")
|
|
||||||
|
|
||||||
# Bonuses & Penalties
|
|
||||||
yield Static("[bold]Bonuses & Penalties[/bold]", classes="rules-header")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Underdog Bonus (−3)")
|
|
||||||
yield Switch(id="sw-underdog_bonus")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Tied Shame (+5)")
|
|
||||||
yield Switch(id="sw-tied_shame")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Blackjack (21→0)")
|
|
||||||
yield Switch(id="sw-blackjack")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Wolfpack")
|
|
||||||
yield Switch(id="sw-wolfpack")
|
|
||||||
|
|
||||||
with Collapsible(title="Deck Style", collapsed=True, id="coll-deck"):
|
|
||||||
with Horizontal(classes="setting-row"):
|
|
||||||
yield Select(
|
|
||||||
[(name.replace("-", " ").title(), name) for name in DECK_PRESETS],
|
|
||||||
value="classic",
|
|
||||||
id="sel-deck-style",
|
|
||||||
allow_blank=False,
|
|
||||||
)
|
|
||||||
yield Static(
|
|
||||||
self._render_deck_preview("classic"),
|
|
||||||
id="deck-preview",
|
|
||||||
)
|
|
||||||
|
|
||||||
yield Button("Start Game", id="btn-start", variant="success")
|
|
||||||
|
|
||||||
yield Static("", id="lobby-status")
|
|
||||||
|
|
||||||
with Horizontal(classes="screen-footer"): # Outside lobby-container
|
|
||||||
yield Static("\\[esc] back", id="lobby-footer-left", classes="screen-footer-left")
|
|
||||||
yield Static("\\[q] quit", id="lobby-footer-right", classes="screen-footer-right")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self._update_visibility()
|
|
||||||
self._update_keymap()
|
|
||||||
self._update_footer()
|
|
||||||
|
|
||||||
def reset_to_pre_room(self) -> None:
|
|
||||||
"""Reset lobby back to create/join state after leaving a game."""
|
|
||||||
self._room_code = None
|
|
||||||
self._player_id = None
|
|
||||||
self._is_host = False
|
|
||||||
self._players = []
|
|
||||||
self._in_room = False
|
|
||||||
self._set_room_info("")
|
|
||||||
self._set_status("")
|
|
||||||
try:
|
|
||||||
self.query_one("#input-room-code", Input).value = ""
|
|
||||||
self.query_one("#player-list", Static).update("")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._update_visibility()
|
|
||||||
self._update_keymap()
|
|
||||||
|
|
||||||
def _update_visibility(self) -> None:
|
|
||||||
try:
|
|
||||||
self.query_one("#pre-room").display = not self._in_room
|
|
||||||
self.query_one("#in-room").display = self._in_room
|
|
||||||
# Host-only controls
|
|
||||||
self.query_one("#cpu-controls").display = self._in_room and self._is_host
|
|
||||||
self.query_one("#host-settings").display = self._in_room and self._is_host
|
|
||||||
self.query_one("#btn-start").display = self._in_room and self._is_host
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _update_footer(self) -> None:
|
|
||||||
try:
|
|
||||||
left = self.query_one("#lobby-footer-left", Static)
|
|
||||||
if self._in_room:
|
|
||||||
left.update("\\[esc] leave room")
|
|
||||||
else:
|
|
||||||
left.update("\\[esc] log out")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _update_keymap(self) -> None:
|
|
||||||
self._update_footer()
|
|
||||||
try:
|
|
||||||
if self._in_room and self._is_host:
|
|
||||||
self.app.set_keymap("[Esc] Leave [+] Add CPU [−] Remove [Enter] Start [q] Quit")
|
|
||||||
elif self._in_room:
|
|
||||||
self.app.set_keymap("[Esc] Leave Waiting for host... [q] Quit")
|
|
||||||
else:
|
|
||||||
self.app.set_keymap("[Esc] Log out [Tab] Navigate [Enter] Create/Join [q] Quit")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def handle_escape(self) -> None:
|
|
||||||
"""Single escape: leave room (with confirm if host), or log out."""
|
|
||||||
if self._in_room:
|
|
||||||
if self._is_host:
|
|
||||||
from tui_client.screens.confirm import ConfirmScreen
|
|
||||||
self.app.push_screen(
|
|
||||||
ConfirmScreen("End the game for everyone?"),
|
|
||||||
callback=self._on_leave_confirm,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.run_worker(self._send("leave_game"))
|
|
||||||
self.reset_to_pre_room()
|
|
||||||
else:
|
|
||||||
from tui_client.screens.confirm import ConfirmScreen
|
|
||||||
self.app.push_screen(
|
|
||||||
ConfirmScreen("Log out and return to login?"),
|
|
||||||
callback=self._on_logout_confirm,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_leave_confirm(self, confirmed: bool) -> None:
|
|
||||||
if confirmed:
|
|
||||||
self.run_worker(self._send("leave_game"))
|
|
||||||
self.reset_to_pre_room()
|
|
||||||
|
|
||||||
def _on_logout_confirm(self, confirmed: bool) -> None:
|
|
||||||
if confirmed:
|
|
||||||
from tui_client.client import GameClient
|
|
||||||
from tui_client.screens.connect import ConnectScreen
|
|
||||||
|
|
||||||
GameClient.clear_session()
|
|
||||||
self.app.client._token = None
|
|
||||||
self.app.client._username = None
|
|
||||||
self.app.switch_screen(ConnectScreen())
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "btn-create":
|
|
||||||
self._create_room()
|
|
||||||
elif event.button.id == "btn-join":
|
|
||||||
self._join_room()
|
|
||||||
elif event.button.id == "btn-cpu-add":
|
|
||||||
self._show_cpu_picker()
|
|
||||||
elif event.button.id == "btn-cpu-remove":
|
|
||||||
self._remove_cpu()
|
|
||||||
elif event.button.id == "btn-cpu-random":
|
|
||||||
self._add_random_cpu()
|
|
||||||
elif event.button.id == "btn-start":
|
|
||||||
self._start_game()
|
|
||||||
|
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
||||||
if event.input.id == "input-room-code":
|
|
||||||
code = event.value.strip()
|
|
||||||
if code:
|
|
||||||
self._join_room()
|
|
||||||
else:
|
|
||||||
self._create_room()
|
|
||||||
|
|
||||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
||||||
if event.option_list.id == "cpu-profile-list":
|
|
||||||
profile_name = str(event.option.id) if event.option.id else ""
|
|
||||||
self.run_worker(self._send("add_cpu", profile_name=profile_name))
|
|
||||||
event.option_list.display = False
|
|
||||||
|
|
||||||
def on_select_changed(self, event: Select.Changed) -> None:
|
|
||||||
if event.select.id == "sel-deck-style" and event.value is not None:
|
|
||||||
try:
|
|
||||||
preview = self.query_one("#deck-preview", Static)
|
|
||||||
preview.update(self._render_deck_preview(str(event.value)))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def action_add_cpu(self) -> None:
|
|
||||||
if self._in_room and self._is_host:
|
|
||||||
self._show_cpu_picker()
|
|
||||||
|
|
||||||
def action_remove_cpu(self) -> None:
|
|
||||||
if self._in_room and self._is_host:
|
|
||||||
self._remove_cpu()
|
|
||||||
|
|
||||||
def action_start_or_create(self) -> None:
|
|
||||||
if self._in_room and self._is_host:
|
|
||||||
self._start_game()
|
|
||||||
elif not self._in_room:
|
|
||||||
code = self.query_one("#input-room-code", Input).value.strip()
|
|
||||||
if code:
|
|
||||||
self._join_room()
|
|
||||||
else:
|
|
||||||
self._create_room()
|
|
||||||
|
|
||||||
def _create_room(self) -> None:
|
|
||||||
player_name = self.app.client.username or "Player"
|
|
||||||
self.run_worker(self._send("create_room", player_name=player_name))
|
|
||||||
|
|
||||||
def _join_room(self) -> None:
|
|
||||||
code = self.query_one("#input-room-code", Input).value.strip().upper()
|
|
||||||
if not code:
|
|
||||||
self._set_status("Enter a room code to join")
|
|
||||||
return
|
|
||||||
player_name = self.app.client.username or "Player"
|
|
||||||
self.run_worker(self._send("join_room", room_code=code, player_name=player_name))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _render_deck_preview(preset_name: str) -> str:
|
|
||||||
"""Render mini card-back swatches for a deck color preset."""
|
|
||||||
from tui_client.widgets.card import BACK_COLORS, BORDER_COLOR
|
|
||||||
|
|
||||||
colors = DECK_PRESETS.get(preset_name, ["red", "blue", "gold"])
|
|
||||||
# Show unique colors only (e.g. all-red shows one wider swatch)
|
|
||||||
seen: list[str] = []
|
|
||||||
for c in colors:
|
|
||||||
if c not in seen:
|
|
||||||
seen.append(c)
|
|
||||||
|
|
||||||
bc = BORDER_COLOR
|
|
||||||
parts: list[str] = []
|
|
||||||
for color_name in seen:
|
|
||||||
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
|
|
||||||
parts.append(
|
|
||||||
f"[{bc}]┌───┐[/{bc}] "
|
|
||||||
)
|
|
||||||
line1 = "".join(parts)
|
|
||||||
|
|
||||||
parts2: list[str] = []
|
|
||||||
for color_name in seen:
|
|
||||||
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
|
|
||||||
parts2.append(
|
|
||||||
f"[{bc}]│[/{bc}][{hc}]▓▒▓[/{hc}][{bc}]│[/{bc}] "
|
|
||||||
)
|
|
||||||
line2 = "".join(parts2)
|
|
||||||
|
|
||||||
parts3: list[str] = []
|
|
||||||
for color_name in seen:
|
|
||||||
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
|
|
||||||
parts3.append(
|
|
||||||
f"[{bc}]│[/{bc}][{hc}]▒▓▒[/{hc}][{bc}]│[/{bc}] "
|
|
||||||
)
|
|
||||||
line3 = "".join(parts3)
|
|
||||||
|
|
||||||
parts4: list[str] = []
|
|
||||||
for color_name in seen:
|
|
||||||
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
|
|
||||||
parts4.append(
|
|
||||||
f"[{bc}]└───┘[/{bc}] "
|
|
||||||
)
|
|
||||||
line4 = "".join(parts4)
|
|
||||||
|
|
||||||
return f"{line1}\n{line2}\n{line3}\n{line4}"
|
|
||||||
|
|
||||||
def _add_random_cpu(self) -> None:
|
|
||||||
"""Add a random CPU (server picks the profile)."""
|
|
||||||
self.run_worker(self._send("add_cpu"))
|
|
||||||
|
|
||||||
def _show_cpu_picker(self) -> None:
|
|
||||||
"""Request CPU profiles from server and show picker."""
|
|
||||||
self.run_worker(self._send("get_cpu_profiles"))
|
|
||||||
|
|
||||||
def _handle_cpu_profiles(self, data: dict) -> None:
|
|
||||||
"""Populate and show the CPU profile option list."""
|
|
||||||
profiles = data.get("profiles", [])
|
|
||||||
option_list = self.query_one("#cpu-profile-list", OptionList)
|
|
||||||
option_list.clear_options()
|
|
||||||
for p in profiles:
|
|
||||||
name = p.get("name", "?")
|
|
||||||
style = p.get("style", "")
|
|
||||||
option_list.add_option(Option(f"{name} — {style}", id=name))
|
|
||||||
option_list.display = True
|
|
||||||
option_list.focus()
|
|
||||||
|
|
||||||
def _remove_cpu(self) -> None:
|
|
||||||
self.run_worker(self._send("remove_cpu"))
|
|
||||||
|
|
||||||
def _collect_settings(self) -> dict:
|
|
||||||
"""Read all Select/Switch values and return kwargs for start_game."""
|
|
||||||
settings: dict = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings["rounds"] = self.query_one("#sel-rounds", Select).value
|
|
||||||
settings["decks"] = self.query_one("#sel-decks", Select).value
|
|
||||||
settings["initial_flips"] = self.query_one("#sel-initial-flips", Select).value
|
|
||||||
settings["flip_mode"] = self.query_one("#sel-flip-mode", Select).value
|
|
||||||
except Exception:
|
|
||||||
settings.setdefault("rounds", 9)
|
|
||||||
settings.setdefault("decks", 1)
|
|
||||||
settings.setdefault("initial_flips", 2)
|
|
||||||
settings.setdefault("flip_mode", "never")
|
|
||||||
|
|
||||||
# Joker variant → booleans
|
|
||||||
try:
|
|
||||||
joker_mode = self.query_one("#sel-jokers", Select).value
|
|
||||||
except Exception:
|
|
||||||
joker_mode = "none"
|
|
||||||
|
|
||||||
settings["use_jokers"] = joker_mode != "none"
|
|
||||||
settings["lucky_swing"] = joker_mode == "lucky_swing"
|
|
||||||
settings["eagle_eye"] = joker_mode == "eagle_eye"
|
|
||||||
|
|
||||||
# Boolean house rules from switches
|
|
||||||
rule_ids = [
|
|
||||||
"super_kings", "ten_penny", "one_eyed_jacks",
|
|
||||||
"negative_pairs_keep_value", "four_of_a_kind",
|
|
||||||
"knock_penalty", "knock_bonus", "knock_early", "flip_as_action",
|
|
||||||
"underdog_bonus", "tied_shame", "blackjack", "wolfpack",
|
|
||||||
]
|
|
||||||
for rule_id in rule_ids:
|
|
||||||
try:
|
|
||||||
settings[rule_id] = self.query_one(f"#sw-{rule_id}", Switch).value
|
|
||||||
except Exception:
|
|
||||||
settings[rule_id] = False
|
|
||||||
|
|
||||||
# Deck colors from preset
|
|
||||||
try:
|
|
||||||
preset = self.query_one("#sel-deck-style", Select).value
|
|
||||||
settings["deck_colors"] = DECK_PRESETS.get(preset, ["red", "blue", "gold"])
|
|
||||||
except Exception:
|
|
||||||
settings["deck_colors"] = ["red", "blue", "gold"]
|
|
||||||
|
|
||||||
return settings
|
|
||||||
|
|
||||||
def _start_game(self) -> None:
|
|
||||||
self._set_status("Starting game...")
|
|
||||||
settings = self._collect_settings()
|
|
||||||
self.run_worker(self._send("start_game", **settings))
|
|
||||||
|
|
||||||
async def _send(self, msg_type: str, **kwargs) -> None:
|
|
||||||
try:
|
|
||||||
await self.app.client.send(msg_type, **kwargs)
|
|
||||||
except Exception as e:
|
|
||||||
self._set_status(f"Error: {e}")
|
|
||||||
|
|
||||||
def on_server_message(self, event) -> None:
|
|
||||||
handler = getattr(self, f"_handle_{event.msg_type}", None)
|
|
||||||
if handler:
|
|
||||||
handler(event.data)
|
|
||||||
|
|
||||||
def _handle_room_created(self, data: dict) -> None:
|
|
||||||
self._room_code = data.get("room_code", "")
|
|
||||||
self._player_id = data.get("player_id", "")
|
|
||||||
self.app.player_id = self._player_id
|
|
||||||
self._is_host = True
|
|
||||||
self._in_room = True
|
|
||||||
self._set_room_info(f"Room Code: [bold]{self._room_code}[/bold] (You are host)")
|
|
||||||
self._set_status("Add CPU opponents, then start when ready.")
|
|
||||||
self._update_visibility()
|
|
||||||
self._update_keymap()
|
|
||||||
|
|
||||||
def _handle_room_joined(self, data: dict) -> None:
|
|
||||||
self._room_code = data.get("room_code", "")
|
|
||||||
self._player_id = data.get("player_id", "")
|
|
||||||
self.app.player_id = self._player_id
|
|
||||||
self._in_room = True
|
|
||||||
self._set_room_info(f"Room Code: [bold]{self._room_code}[/bold]")
|
|
||||||
self._set_status("Waiting for host to start the game.")
|
|
||||||
self._update_visibility()
|
|
||||||
self._update_keymap()
|
|
||||||
|
|
||||||
def _handle_player_joined(self, data: dict) -> None:
|
|
||||||
self._players = data.get("players", [])
|
|
||||||
self._refresh_player_list()
|
|
||||||
self._auto_adjust_decks()
|
|
||||||
|
|
||||||
def _handle_game_started(self, data: dict) -> None:
|
|
||||||
from tui_client.screens.game import GameScreen
|
|
||||||
game_state = data.get("game_state", {})
|
|
||||||
self.app.push_screen(GameScreen(game_state, self._is_host))
|
|
||||||
|
|
||||||
def _handle_error(self, data: dict) -> None:
|
|
||||||
self._set_status(f"[red]Error: {data.get('message', 'Unknown error')}[/red]")
|
|
||||||
|
|
||||||
def _refresh_player_list(self) -> None:
|
|
||||||
lines = []
|
|
||||||
for i, p in enumerate(self._players, 1):
|
|
||||||
name = p.get("name", "?")
|
|
||||||
tags = []
|
|
||||||
if p.get("is_host"):
|
|
||||||
tags.append("[bold cyan]Host[/bold cyan]")
|
|
||||||
if p.get("is_cpu"):
|
|
||||||
tags.append("[yellow]CPU[/yellow]")
|
|
||||||
suffix = f" {' '.join(tags)}" if tags else ""
|
|
||||||
lines.append(f" {i}. {name}{suffix}")
|
|
||||||
self.query_one("#player-list", Static).update("\n".join(lines) if lines else " (empty)")
|
|
||||||
|
|
||||||
def _auto_adjust_decks(self) -> None:
|
|
||||||
"""Auto-set decks to 2 when more than 3 players."""
|
|
||||||
if not self._is_host:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
sel = self.query_one("#sel-decks", Select)
|
|
||||||
if len(self._players) > 3 and sel.value == 1:
|
|
||||||
sel.value = 2
|
|
||||||
elif len(self._players) <= 3 and sel.value == 2:
|
|
||||||
sel.value = 1
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _set_room_info(self, text: str) -> None:
|
|
||||||
self.query_one("#room-info", Static).update(text)
|
|
||||||
|
|
||||||
def _set_status(self, text: str) -> None:
|
|
||||||
self.query_one("#lobby-status", Static).update(text)
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
"""Splash screen: check for saved session token before showing login."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, Horizontal
|
|
||||||
from textual.screen import Screen
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
|
|
||||||
_TITLE = (
|
|
||||||
"⛳🏌️ [bold]GolfCards.club[/bold] "
|
|
||||||
"[bold #aaaaaa]♠[/bold #aaaaaa]"
|
|
||||||
"[bold #cc0000]♥[/bold #cc0000]"
|
|
||||||
"[bold #aaaaaa]♣[/bold #aaaaaa]"
|
|
||||||
"[bold #cc0000]♦[/bold #cc0000]"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SplashScreen(Screen):
|
|
||||||
"""Shows session check status, then routes to lobby or login."""
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="connect-container"):
|
|
||||||
yield Static(_TITLE, id="connect-title")
|
|
||||||
yield Static("", id="splash-status")
|
|
||||||
|
|
||||||
with Horizontal(classes="screen-footer"):
|
|
||||||
yield Static("", classes="screen-footer-left")
|
|
||||||
yield Static("\\[q] quit", classes="screen-footer-right")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.run_worker(self._check_session(), exclusive=True)
|
|
||||||
|
|
||||||
async def _check_session(self) -> None:
|
|
||||||
from tui_client.client import GameClient
|
|
||||||
|
|
||||||
status = self.query_one("#splash-status", Static)
|
|
||||||
status.update("Checking for session token...")
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
session = GameClient.load_session()
|
|
||||||
if not session:
|
|
||||||
status.update("Checking for session token... [bold yellow]NONE FOUND[/bold yellow]")
|
|
||||||
await asyncio.sleep(0.8)
|
|
||||||
self._go_to_login()
|
|
||||||
return
|
|
||||||
|
|
||||||
client = self.app.client
|
|
||||||
client.restore_session(session)
|
|
||||||
if await client.verify_token():
|
|
||||||
status.update(f"Checking for session token... [bold green]SUCCESS[/bold green]")
|
|
||||||
await asyncio.sleep(0.8)
|
|
||||||
await self._go_to_lobby()
|
|
||||||
else:
|
|
||||||
GameClient.clear_session()
|
|
||||||
status.update("Checking for session token... [bold red]EXPIRED[/bold red]")
|
|
||||||
await asyncio.sleep(0.8)
|
|
||||||
self._go_to_login()
|
|
||||||
|
|
||||||
def _go_to_login(self) -> None:
|
|
||||||
from tui_client.screens.connect import ConnectScreen
|
|
||||||
|
|
||||||
self.app.switch_screen(ConnectScreen())
|
|
||||||
|
|
||||||
async def _go_to_lobby(self) -> None:
|
|
||||||
client = self.app.client
|
|
||||||
await client.connect()
|
|
||||||
client.save_session()
|
|
||||||
from tui_client.screens.lobby import LobbyScreen
|
|
||||||
|
|
||||||
self.app.switch_screen(LobbyScreen())
|
|
||||||
@ -1,452 +0,0 @@
|
|||||||
/* Base app styles */
|
|
||||||
Screen {
|
|
||||||
background: $surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Splash screen */
|
|
||||||
SplashScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#splash-status {
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Connect screen */
|
|
||||||
ConnectScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-container {
|
|
||||||
width: 80%;
|
|
||||||
max-width: 64;
|
|
||||||
min-width: 40;
|
|
||||||
height: auto;
|
|
||||||
border: thick #f4a460;
|
|
||||||
background: #0a2a1a;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-container Static {
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-title {
|
|
||||||
text-style: bold;
|
|
||||||
color: $text;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-container Input {
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#login-form, #signup-form {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#signup-form {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-buttons, #signup-buttons {
|
|
||||||
height: 3;
|
|
||||||
align: center middle;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-buttons Button, #signup-buttons Button {
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#btn-toggle-signup, #btn-toggle-login {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-status {
|
|
||||||
text-align: center;
|
|
||||||
color: $warning;
|
|
||||||
margin-top: 1;
|
|
||||||
height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Screen footer bar (shared by connect + lobby) */
|
|
||||||
.screen-footer {
|
|
||||||
dock: bottom;
|
|
||||||
width: 100%;
|
|
||||||
height: 1;
|
|
||||||
background: #1a1a2e;
|
|
||||||
color: #888888;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
.screen-footer-left {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.screen-footer-right {
|
|
||||||
width: 1fr;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lobby screen */
|
|
||||||
LobbyScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#lobby-container {
|
|
||||||
width: 80%;
|
|
||||||
max-width: 72;
|
|
||||||
min-width: 40;
|
|
||||||
height: auto;
|
|
||||||
border: thick #f4a460;
|
|
||||||
background: #0a2a1a;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#lobby-title {
|
|
||||||
text-style: bold;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#room-info {
|
|
||||||
text-align: center;
|
|
||||||
height: auto;
|
|
||||||
margin-bottom: 1;
|
|
||||||
border: tall #f4a460;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pre-room: join/create controls */
|
|
||||||
#pre-room {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#input-room-code {
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pre-room-buttons {
|
|
||||||
height: 3;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pre-room-buttons Button {
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* In-room: player list + controls */
|
|
||||||
#in-room {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#player-list-label {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#player-list {
|
|
||||||
height: auto;
|
|
||||||
min-height: 3;
|
|
||||||
max-height: 12;
|
|
||||||
border: tall $primary;
|
|
||||||
padding: 0 1;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CPU controls: compact [+] [-] */
|
|
||||||
#cpu-controls {
|
|
||||||
height: 3;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#cpu-controls Button {
|
|
||||||
min-width: 5;
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#cpu-label {
|
|
||||||
padding: 1 1 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#cpu-profile-list {
|
|
||||||
height: auto;
|
|
||||||
max-height: 12;
|
|
||||||
border: tall $accent;
|
|
||||||
margin-bottom: 1;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Host settings */
|
|
||||||
#host-settings {
|
|
||||||
height: auto;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-row {
|
|
||||||
height: 3;
|
|
||||||
align: left middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-row Label {
|
|
||||||
width: 1fr;
|
|
||||||
padding: 1 1 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-row Select {
|
|
||||||
width: 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
#deck-preview {
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
padding: 1 1 0 1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-row {
|
|
||||||
height: 3;
|
|
||||||
align: left middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-row Label {
|
|
||||||
width: 1fr;
|
|
||||||
padding: 1 1 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-row Switch {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rules-header {
|
|
||||||
margin-top: 1;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#btn-start {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#lobby-status {
|
|
||||||
text-align: center;
|
|
||||||
color: $warning;
|
|
||||||
height: auto;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Game screen */
|
|
||||||
GameScreen {
|
|
||||||
align: center top;
|
|
||||||
layout: vertical;
|
|
||||||
background: #0a2a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
#game-content {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 120;
|
|
||||||
height: 100%;
|
|
||||||
layout: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-bar {
|
|
||||||
height: 1;
|
|
||||||
dock: top;
|
|
||||||
background: #2a1a0a;
|
|
||||||
color: #f4a460;
|
|
||||||
padding: 0 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#opponents-area {
|
|
||||||
height: auto;
|
|
||||||
max-height: 50%;
|
|
||||||
padding: 1 2 1 2;
|
|
||||||
text-align: center;
|
|
||||||
content-align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#play-area-row {
|
|
||||||
height: auto;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#play-area {
|
|
||||||
height: auto;
|
|
||||||
width: auto;
|
|
||||||
padding: 0 2;
|
|
||||||
border: round $primary-lighten-2;
|
|
||||||
text-align: center;
|
|
||||||
content-align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#play-area.my-turn {
|
|
||||||
border: round #ffd700;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Local hand label */
|
|
||||||
#local-hand-label {
|
|
||||||
text-align: center;
|
|
||||||
height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Local hand widget */
|
|
||||||
#local-hand {
|
|
||||||
height: auto;
|
|
||||||
margin-top: 1;
|
|
||||||
text-align: center;
|
|
||||||
content-align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scoreboard overlay */
|
|
||||||
ScoreboardScreen {
|
|
||||||
align: center middle;
|
|
||||||
background: $surface 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scoreboard-container {
|
|
||||||
width: 80%;
|
|
||||||
max-width: 64;
|
|
||||||
min-width: 40;
|
|
||||||
height: auto;
|
|
||||||
max-height: 80%;
|
|
||||||
border: thick $primary;
|
|
||||||
padding: 1 2;
|
|
||||||
background: $surface;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scoreboard-title {
|
|
||||||
text-style: bold;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scoreboard-table {
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scoreboard-buttons {
|
|
||||||
height: 3;
|
|
||||||
align: center middle;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Confirm quit dialog */
|
|
||||||
ConfirmScreen, ConfirmQuitScreen {
|
|
||||||
align: center middle;
|
|
||||||
background: $surface 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#confirm-dialog {
|
|
||||||
width: auto;
|
|
||||||
max-width: 48;
|
|
||||||
height: auto;
|
|
||||||
border: thick $error;
|
|
||||||
padding: 1 2;
|
|
||||||
background: $surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
#confirm-message {
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#confirm-buttons {
|
|
||||||
height: 3;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#confirm-buttons Button {
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Game footer: [h]elp <action> [tab] standings [q]uit */
|
|
||||||
#game-footer {
|
|
||||||
height: 1;
|
|
||||||
dock: bottom;
|
|
||||||
background: $surface-darken-1;
|
|
||||||
padding: 0 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#footer-left {
|
|
||||||
width: auto;
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
#footer-center {
|
|
||||||
width: 1fr;
|
|
||||||
text-align: center;
|
|
||||||
content-align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#footer-right {
|
|
||||||
width: auto;
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Help dialog */
|
|
||||||
HelpScreen {
|
|
||||||
align: center middle;
|
|
||||||
background: $surface 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#help-dialog {
|
|
||||||
width: 48;
|
|
||||||
height: auto;
|
|
||||||
max-height: 80%;
|
|
||||||
border: thick $primary;
|
|
||||||
padding: 1 2;
|
|
||||||
background: $surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
#help-text {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Standings dialog */
|
|
||||||
StandingsScreen {
|
|
||||||
align: center middle;
|
|
||||||
background: $surface 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#standings-dialog {
|
|
||||||
width: 48;
|
|
||||||
height: auto;
|
|
||||||
max-height: 80%;
|
|
||||||
border: thick $primary;
|
|
||||||
padding: 1 2;
|
|
||||||
background: $surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
#standings-title {
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#standings-body {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#standings-hint {
|
|
||||||
width: 100%;
|
|
||||||
height: 1;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#standings-hint {
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
"""Widget modules for the TUI client."""
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
"""Single card widget using Unicode box-drawing with Rich color markup."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
from tui_client.models import CardData
|
|
||||||
|
|
||||||
|
|
||||||
# Web UI card back colors mapped to terminal hex equivalents
|
|
||||||
BACK_COLORS: dict[str, str] = {
|
|
||||||
"red": "#c41e3a",
|
|
||||||
"blue": "#2e5cb8",
|
|
||||||
"green": "#228b22",
|
|
||||||
"gold": "#daa520",
|
|
||||||
"purple": "#6a0dad",
|
|
||||||
"teal": "#008b8b",
|
|
||||||
"pink": "#db7093",
|
|
||||||
"slate": "#4a5568",
|
|
||||||
"orange": "#e67e22",
|
|
||||||
"cyan": "#00bcd4",
|
|
||||||
"brown": "#8b4513",
|
|
||||||
"yellow": "#daa520",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Face-up card text colors (matching web UI)
|
|
||||||
SUIT_RED = "#ff4444" # hearts, diamonds — bright red
|
|
||||||
SUIT_BLACK = "#ffffff" # clubs, spades — white for dark terminal bg
|
|
||||||
JOKER_COLOR = "#9b59b6" # purple
|
|
||||||
BORDER_COLOR = "#888888" # card border
|
|
||||||
EMPTY_COLOR = "#555555" # empty card slot
|
|
||||||
POSITION_COLOR = "#f0e68c" # pale yellow — distinct from suits and card backs
|
|
||||||
HIGHLIGHT_COLOR = "#ffaa00" # bright amber — initial flip / attention
|
|
||||||
FLASH_COLOR = "#00ffff" # bright cyan — swap/discard flash
|
|
||||||
|
|
||||||
|
|
||||||
def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -> str:
|
|
||||||
"""Get the hex color for a face-down card's back based on deck_id."""
|
|
||||||
if deck_colors and card.deck_id is not None and card.deck_id < len(deck_colors):
|
|
||||||
name = deck_colors[card.deck_id]
|
|
||||||
else:
|
|
||||||
name = "red"
|
|
||||||
return BACK_COLORS.get(name, BACK_COLORS["red"])
|
|
||||||
|
|
||||||
|
|
||||||
def _top_border(position: int | None, d: str, color: str, highlight: bool = False) -> str:
|
|
||||||
"""Top border line, with position number replacing ┌ when present."""
|
|
||||||
if position is not None:
|
|
||||||
if highlight:
|
|
||||||
hc = HIGHLIGHT_COLOR
|
|
||||||
return f"[bold {hc}]{position}[/][{d}{color}]───┐[/{d}{color}]"
|
|
||||||
return f"[{d}{color}]{position}───┐[/{d}{color}]"
|
|
||||||
return f"[{d}{color}]┌───┐[/{d}{color}]"
|
|
||||||
|
|
||||||
|
|
||||||
def render_card(
|
|
||||||
card: CardData | None,
|
|
||||||
selected: bool = False,
|
|
||||||
position: int | None = None,
|
|
||||||
deck_colors: list[str] | None = None,
|
|
||||||
dim: bool = False,
|
|
||||||
highlight: bool = False,
|
|
||||||
flash: bool = False,
|
|
||||||
connect_top: bool = False,
|
|
||||||
connect_bottom: bool = False,
|
|
||||||
) -> str:
|
|
||||||
"""Render a card as a 4-line Rich-markup string.
|
|
||||||
|
|
||||||
Face-up: Face-down: Empty:
|
|
||||||
┌───┐ 1───┐ ┌───┐
|
|
||||||
│ A │ │▓▓▓│ │ │
|
|
||||||
│ ♠ │ │▓▓▓│ │ │
|
|
||||||
└───┘ └───┘ └───┘
|
|
||||||
|
|
||||||
connect_top/connect_bottom merge borders for matched column pairs.
|
|
||||||
"""
|
|
||||||
d = "dim " if dim else ""
|
|
||||||
bc = FLASH_COLOR if flash else HIGHLIGHT_COLOR if highlight else BORDER_COLOR
|
|
||||||
bot = f"[{d}{bc}]├───┤[/{d}{bc}]" if connect_bottom else f"[{d}{bc}]└───┘[/{d}{bc}]"
|
|
||||||
|
|
||||||
# Empty slot
|
|
||||||
if card is None:
|
|
||||||
c = EMPTY_COLOR
|
|
||||||
top_line = f"[{d}{c}]├───┤[/{d}{c}]" if connect_top else f"[{d}{c}]┌───┐[/{d}{c}]"
|
|
||||||
bot_line = f"[{d}{c}]├───┤[/{d}{c}]" if connect_bottom else f"[{d}{c}]└───┘[/{d}{c}]"
|
|
||||||
return (
|
|
||||||
f"{top_line}\n"
|
|
||||||
f"[{d}{c}]│ │[/{d}{c}]\n"
|
|
||||||
f"[{d}{c}]│ │[/{d}{c}]\n"
|
|
||||||
f"{bot_line}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if connect_top:
|
|
||||||
top = f"[{d}{bc}]├───┤[/{d}{bc}]"
|
|
||||||
else:
|
|
||||||
top = _top_border(position, d, bc, highlight=highlight)
|
|
||||||
|
|
||||||
# Face-down card with colored back
|
|
||||||
if not card.face_up:
|
|
||||||
back = _back_color_for_card(card, deck_colors)
|
|
||||||
return (
|
|
||||||
f"{top}\n"
|
|
||||||
f"[{d}{bc}]│[/{d}{bc}][{d}{back}]▓▒▓[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n"
|
|
||||||
f"[{d}{bc}]│[/{d}{bc}][{d}{back}]▒▓▒[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n"
|
|
||||||
f"{bot}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Joker
|
|
||||||
if card.is_joker:
|
|
||||||
jc = JOKER_COLOR
|
|
||||||
icon = "🐉" if card.suit == "hearts" else "👹"
|
|
||||||
return (
|
|
||||||
f"{top}\n"
|
|
||||||
f"[{d}{bc}]│[/{d}{bc}][{d}{jc}] {icon}[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n"
|
|
||||||
f"[{d}{bc}]│[/{d}{bc}][{d}{jc}]JKR[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n"
|
|
||||||
f"{bot}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Face-up normal card
|
|
||||||
fc = SUIT_RED if card.is_red else SUIT_BLACK
|
|
||||||
b = "bold " if dim else ""
|
|
||||||
rank = card.display_rank
|
|
||||||
suit = card.display_suit
|
|
||||||
rank_line = f"{rank:^3}"
|
|
||||||
suit_line = f"{suit:^3}"
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"{top}\n"
|
|
||||||
f"[{d}{bc}]│[/{d}{bc}][{b}{d}{fc}]{rank_line}[/{b}{d}{fc}][{d}{bc}]│[/{d}{bc}]\n"
|
|
||||||
f"[{d}{bc}]│[/{d}{bc}][{b}{d}{fc}]{suit_line}[/{b}{d}{fc}][{d}{bc}]│[/{d}{bc}]\n"
|
|
||||||
f"{bot}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CardWidget(Static):
|
|
||||||
"""A single card display widget."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
card: CardData | None = None,
|
|
||||||
selected: bool = False,
|
|
||||||
position: int | None = None,
|
|
||||||
matched: bool = False,
|
|
||||||
deck_colors: list[str] | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._card = card
|
|
||||||
self._selected = selected
|
|
||||||
self._position = position
|
|
||||||
self._matched = matched
|
|
||||||
self._deck_colors = deck_colors
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self._refresh_display()
|
|
||||||
|
|
||||||
def update_card(
|
|
||||||
self,
|
|
||||||
card: CardData | None,
|
|
||||||
selected: bool = False,
|
|
||||||
matched: bool = False,
|
|
||||||
deck_colors: list[str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._card = card
|
|
||||||
self._selected = selected
|
|
||||||
self._matched = matched
|
|
||||||
if deck_colors is not None:
|
|
||||||
self._deck_colors = deck_colors
|
|
||||||
self._refresh_display()
|
|
||||||
|
|
||||||
def _refresh_display(self) -> None:
|
|
||||||
text = render_card(
|
|
||||||
self._card,
|
|
||||||
self._selected,
|
|
||||||
self._position,
|
|
||||||
deck_colors=self._deck_colors,
|
|
||||||
dim=self._matched,
|
|
||||||
)
|
|
||||||
self.update(text)
|
|
||||||
@ -1,222 +0,0 @@
|
|||||||
"""2x3 card grid for one player's hand."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.events import Click
|
|
||||||
from textual.message import Message
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
from tui_client.models import CardData, PlayerData
|
|
||||||
from tui_client.widgets.card import render_card
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _check_column_match(cards: list[CardData]) -> list[bool]:
|
|
||||||
"""Check which cards are in matched columns (both face-up, same rank).
|
|
||||||
|
|
||||||
Cards layout: [0][1][2]
|
|
||||||
[3][4][5]
|
|
||||||
Columns: (0,3), (1,4), (2,5)
|
|
||||||
"""
|
|
||||||
matched = [False] * 6
|
|
||||||
if len(cards) < 6:
|
|
||||||
return matched
|
|
||||||
|
|
||||||
for col in range(3):
|
|
||||||
top = cards[col]
|
|
||||||
bot = cards[col + 3]
|
|
||||||
if (
|
|
||||||
top.face_up
|
|
||||||
and bot.face_up
|
|
||||||
and top.rank is not None
|
|
||||||
and top.rank == bot.rank
|
|
||||||
):
|
|
||||||
matched[col] = True
|
|
||||||
matched[col + 3] = True
|
|
||||||
return matched
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _render_card_lines(
|
|
||||||
cards: list[CardData],
|
|
||||||
*,
|
|
||||||
is_local: bool = False,
|
|
||||||
deck_colors: list[str] | None = None,
|
|
||||||
matched: list[bool] | None = None,
|
|
||||||
highlight: bool = False,
|
|
||||||
flash_position: int | None = None,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Render the 2x3 card grid as a list of text lines (no box).
|
|
||||||
|
|
||||||
Matched columns use connected borders (├───┤) instead of separate
|
|
||||||
└───┘/┌───┐ to avoid an extra connector row.
|
|
||||||
"""
|
|
||||||
if matched is None:
|
|
||||||
matched = _check_column_match(cards)
|
|
||||||
lines: list[str] = []
|
|
||||||
for row_idx, row_start in enumerate((0, 3)):
|
|
||||||
row_line_parts: list[list[str]] = []
|
|
||||||
for i in range(3):
|
|
||||||
idx = row_start + i
|
|
||||||
card = cards[idx] if idx < len(cards) else None
|
|
||||||
pos = idx + 1 if is_local else None
|
|
||||||
# Top row cards: connect_bottom if matched
|
|
||||||
# Bottom row cards: connect_top if matched
|
|
||||||
cb = matched[idx] if row_idx == 0 else False
|
|
||||||
ct = matched[idx] if row_idx == 1 else False
|
|
||||||
text = render_card(
|
|
||||||
card,
|
|
||||||
position=pos,
|
|
||||||
deck_colors=deck_colors,
|
|
||||||
dim=matched[idx],
|
|
||||||
highlight=highlight,
|
|
||||||
flash=(flash_position == idx),
|
|
||||||
connect_bottom=cb,
|
|
||||||
connect_top=ct,
|
|
||||||
)
|
|
||||||
card_lines = text.split("\n")
|
|
||||||
while len(row_line_parts) < len(card_lines):
|
|
||||||
row_line_parts.append([])
|
|
||||||
for ln_idx, ln in enumerate(card_lines):
|
|
||||||
row_line_parts[ln_idx].append(ln)
|
|
||||||
for parts in row_line_parts:
|
|
||||||
lines.append(" ".join(parts))
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
class HandWidget(Static):
|
|
||||||
"""Displays a player's 2x3 card grid as rich text, wrapped in a player box."""
|
|
||||||
|
|
||||||
class CardClicked(Message):
|
|
||||||
"""Posted when a card position is clicked in the local hand."""
|
|
||||||
|
|
||||||
def __init__(self, position: int) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.position = position
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
player: PlayerData | None = None,
|
|
||||||
is_local: bool = False,
|
|
||||||
deck_colors: list[str] | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._player = player
|
|
||||||
self._is_local = is_local
|
|
||||||
self._deck_colors = deck_colors
|
|
||||||
# State flags for the player box
|
|
||||||
self._is_current_turn: bool = False
|
|
||||||
self._is_knocker: bool = False
|
|
||||||
self._is_dealer: bool = False
|
|
||||||
self._highlight: bool = False
|
|
||||||
self._flash_position: int | None = None
|
|
||||||
self._box_width: int = 0
|
|
||||||
|
|
||||||
def update_player(
|
|
||||||
self,
|
|
||||||
player: PlayerData,
|
|
||||||
deck_colors: list[str] | None = None,
|
|
||||||
*,
|
|
||||||
is_current_turn: bool = False,
|
|
||||||
is_knocker: bool = False,
|
|
||||||
is_dealer: bool = False,
|
|
||||||
highlight: bool = False,
|
|
||||||
flash_position: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._player = player
|
|
||||||
if deck_colors is not None:
|
|
||||||
self._deck_colors = deck_colors
|
|
||||||
self._is_current_turn = is_current_turn
|
|
||||||
self._is_knocker = is_knocker
|
|
||||||
self._is_dealer = is_dealer
|
|
||||||
self._highlight = highlight
|
|
||||||
self._flash_position = flash_position
|
|
||||||
self._refresh()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self._refresh()
|
|
||||||
|
|
||||||
def on_click(self, event: Click) -> None:
|
|
||||||
"""Map click coordinates to card position (0-5)."""
|
|
||||||
if not self._is_local or not self._box_width:
|
|
||||||
return
|
|
||||||
|
|
||||||
# The content is centered in the widget — compute the x offset
|
|
||||||
x_offset = max(0, (self.size.width - self._box_width) // 2)
|
|
||||||
x = event.x - x_offset
|
|
||||||
y = event.y
|
|
||||||
|
|
||||||
# Box layout:
|
|
||||||
# Line 0: top border
|
|
||||||
# Lines 1-4: row 0 cards (4 lines each)
|
|
||||||
# Lines 5-8: row 1 cards
|
|
||||||
# Line 9: bottom border
|
|
||||||
#
|
|
||||||
# Content x: │ <space> then cards at x offsets 2, 8, 14 (each 5 wide, 1 gap)
|
|
||||||
|
|
||||||
# Determine column from x (content starts at x=2 inside box)
|
|
||||||
# Card 0: x 2-6, Card 1: x 8-12, Card 2: x 14-18
|
|
||||||
col = -1
|
|
||||||
if 2 <= x <= 6:
|
|
||||||
col = 0
|
|
||||||
elif 8 <= x <= 12:
|
|
||||||
col = 1
|
|
||||||
elif 14 <= x <= 18:
|
|
||||||
col = 2
|
|
||||||
|
|
||||||
if col < 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Determine row from y
|
|
||||||
# y=0: top border, y=1..4: row 0, y=5..8: row 1, y=9: bottom border
|
|
||||||
row = -1
|
|
||||||
if 1 <= y <= 4:
|
|
||||||
row = 0
|
|
||||||
elif 5 <= y <= 8:
|
|
||||||
row = 1
|
|
||||||
|
|
||||||
if row < 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
position = row * 3 + col
|
|
||||||
self.post_message(self.CardClicked(position))
|
|
||||||
|
|
||||||
def _refresh(self) -> None:
|
|
||||||
if not self._player or not self._player.cards:
|
|
||||||
self.update("")
|
|
||||||
return
|
|
||||||
|
|
||||||
from tui_client.widgets.player_box import _visible_len, render_player_box
|
|
||||||
|
|
||||||
cards = self._player.cards
|
|
||||||
matched = _check_column_match(cards)
|
|
||||||
|
|
||||||
card_lines = _render_card_lines(
|
|
||||||
cards,
|
|
||||||
is_local=self._is_local,
|
|
||||||
deck_colors=self._deck_colors,
|
|
||||||
matched=matched,
|
|
||||||
highlight=self._highlight,
|
|
||||||
flash_position=self._flash_position,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use visible_score (computed from face-up cards) during play,
|
|
||||||
# server-provided score at round/game over
|
|
||||||
display_score = self._player.score if self._player.score is not None else self._player.visible_score
|
|
||||||
box_lines = render_player_box(
|
|
||||||
self._player.name,
|
|
||||||
score=display_score,
|
|
||||||
total_score=self._player.total_score,
|
|
||||||
content_lines=card_lines,
|
|
||||||
is_current_turn=self._is_current_turn,
|
|
||||||
is_knocker=self._is_knocker,
|
|
||||||
is_dealer=self._is_dealer,
|
|
||||||
is_local=self._is_local,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store box width for click coordinate mapping
|
|
||||||
if box_lines:
|
|
||||||
self._box_width = _visible_len(box_lines[0])
|
|
||||||
|
|
||||||
self.update("\n".join(box_lines))
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
"""Deck + discard + held card area."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import replace
|
|
||||||
|
|
||||||
from textual.events import Click
|
|
||||||
from textual.message import Message
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
from tui_client.models import CardData, GameState
|
|
||||||
from tui_client.widgets.card import render_card
|
|
||||||
|
|
||||||
# Fixed column width for each card section (card is 5 wide)
|
|
||||||
_COL_WIDTH = 12
|
|
||||||
|
|
||||||
# Lime green for the held card highlight
|
|
||||||
_HOLDING_COLOR = "#80ff00"
|
|
||||||
|
|
||||||
|
|
||||||
def _pad_center(text: str, width: int) -> str:
|
|
||||||
"""Center-pad a plain or Rich-markup string to *width* visible chars."""
|
|
||||||
visible = re.sub(r"\[.*?\]", "", text)
|
|
||||||
pad = max(0, width - len(visible))
|
|
||||||
left = pad // 2
|
|
||||||
right = pad - left
|
|
||||||
return " " * left + text + " " * right
|
|
||||||
|
|
||||||
|
|
||||||
class PlayAreaWidget(Static):
|
|
||||||
"""Displays the deck, discard pile, and held card.
|
|
||||||
|
|
||||||
Layout order: DECK [HOLDING] DISCARD
|
|
||||||
HOLDING only appears when the player has drawn a card.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class DeckClicked(Message):
|
|
||||||
"""Posted when the deck is clicked."""
|
|
||||||
|
|
||||||
class DiscardClicked(Message):
|
|
||||||
"""Posted when the discard pile is clicked."""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._state: GameState | None = None
|
|
||||||
self._local_player_id: str = ""
|
|
||||||
self._has_holding: bool = False
|
|
||||||
self._discard_flash: bool = False
|
|
||||||
|
|
||||||
def update_state(self, state: GameState, local_player_id: str = "", discard_flash: bool = False) -> None:
|
|
||||||
self._state = state
|
|
||||||
self._discard_flash = discard_flash
|
|
||||||
if local_player_id:
|
|
||||||
self._local_player_id = local_player_id
|
|
||||||
self._refresh()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self._refresh()
|
|
||||||
|
|
||||||
def on_click(self, event: Click) -> None:
|
|
||||||
"""Map click to deck or discard column."""
|
|
||||||
# Content is always 3 columns wide; account for centering within widget
|
|
||||||
content_width = 3 * _COL_WIDTH
|
|
||||||
x_offset = max(0, (self.content_size.width - content_width) // 2)
|
|
||||||
x = event.x - x_offset
|
|
||||||
|
|
||||||
# Layout: DECK (col 0..11) | HOLDING (col 12..23) | DISCARD (col 24..35)
|
|
||||||
if 0 <= x < _COL_WIDTH:
|
|
||||||
self.post_message(self.DeckClicked())
|
|
||||||
elif 2 * _COL_WIDTH <= x < 3 * _COL_WIDTH:
|
|
||||||
self.post_message(self.DiscardClicked())
|
|
||||||
|
|
||||||
def _refresh(self) -> None:
|
|
||||||
if not self._state:
|
|
||||||
self.update("")
|
|
||||||
return
|
|
||||||
|
|
||||||
state = self._state
|
|
||||||
|
|
||||||
# Deck card (face-down)
|
|
||||||
deck_card = CardData(face_up=False, deck_id=0)
|
|
||||||
deck_text = render_card(deck_card, deck_colors=state.deck_colors)
|
|
||||||
deck_lines = deck_text.split("\n")
|
|
||||||
|
|
||||||
# Discard card
|
|
||||||
discard_text = render_card(state.discard_top, deck_colors=state.deck_colors, flash=self._discard_flash)
|
|
||||||
discard_lines = discard_text.split("\n")
|
|
||||||
|
|
||||||
# Held card — show for any player holding
|
|
||||||
held_lines = None
|
|
||||||
is_local_holding = False
|
|
||||||
if state.has_drawn_card and state.drawn_card:
|
|
||||||
revealed = replace(state.drawn_card, face_up=True)
|
|
||||||
held_text = render_card(revealed, deck_colors=state.deck_colors)
|
|
||||||
held_lines = held_text.split("\n")
|
|
||||||
is_local_holding = state.drawn_player_id == self._local_player_id
|
|
||||||
|
|
||||||
self._has_holding = held_lines is not None
|
|
||||||
|
|
||||||
# Always render 3 columns so the box stays a fixed width
|
|
||||||
num_card_lines = max(len(deck_lines), len(discard_lines))
|
|
||||||
lines = []
|
|
||||||
for i in range(num_card_lines):
|
|
||||||
d = deck_lines[i] if i < len(deck_lines) else " "
|
|
||||||
c = discard_lines[i] if i < len(discard_lines) else " "
|
|
||||||
row = _pad_center(d, _COL_WIDTH)
|
|
||||||
if held_lines:
|
|
||||||
h = held_lines[i] if i < len(held_lines) else " "
|
|
||||||
row += _pad_center(h, _COL_WIDTH)
|
|
||||||
else:
|
|
||||||
row += " " * _COL_WIDTH
|
|
||||||
row += _pad_center(c, _COL_WIDTH)
|
|
||||||
lines.append(row)
|
|
||||||
|
|
||||||
# Labels row — always 3 columns
|
|
||||||
deck_label = f"DECK [dim]{state.deck_remaining}[/dim]"
|
|
||||||
discard_label = "DISCARD"
|
|
||||||
label = _pad_center(deck_label, _COL_WIDTH)
|
|
||||||
if held_lines:
|
|
||||||
if is_local_holding:
|
|
||||||
holding_label = f"[bold {_HOLDING_COLOR}]HOLDING[/]"
|
|
||||||
else:
|
|
||||||
holding_label = "[dim]HOLDING[/dim]"
|
|
||||||
label += _pad_center(holding_label, _COL_WIDTH)
|
|
||||||
else:
|
|
||||||
label += " " * _COL_WIDTH
|
|
||||||
label += _pad_center(discard_label, _COL_WIDTH)
|
|
||||||
lines.append(label)
|
|
||||||
|
|
||||||
self.update("\n".join(lines))
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
"""Bordered player container with name, score, and state indicators."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Border colors matching web UI palette
|
|
||||||
_BORDER_NORMAL = "#555555"
|
|
||||||
_BORDER_TURN_LOCAL = "#f4a460" # sandy orange — your turn (matches opponent turn)
|
|
||||||
_BORDER_TURN_OPPONENT = "#f4a460" # sandy orange — opponent's turn
|
|
||||||
_BORDER_KNOCKER = "#ff6b35" # red-orange — went out
|
|
||||||
_NAME_COLOR = "#e0e0e0"
|
|
||||||
|
|
||||||
|
|
||||||
def _visible_len(text: str) -> int:
|
|
||||||
"""Length of text with Rich markup tags stripped."""
|
|
||||||
return len(re.sub(r"\[.*?\]", "", text))
|
|
||||||
|
|
||||||
|
|
||||||
def render_player_box(
|
|
||||||
name: str,
|
|
||||||
score: int | None,
|
|
||||||
total_score: int,
|
|
||||||
content_lines: list[str],
|
|
||||||
*,
|
|
||||||
is_current_turn: bool = False,
|
|
||||||
is_knocker: bool = False,
|
|
||||||
is_dealer: bool = False,
|
|
||||||
is_local: bool = False,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Render a bordered player container with name/score header.
|
|
||||||
|
|
||||||
Every line in the returned list has the same visible width (``box_width``).
|
|
||||||
|
|
||||||
Layout::
|
|
||||||
|
|
||||||
╭─ Name ────── 15 ─╮
|
|
||||||
│ ┌───┐ ┌───┐ ┌───┐│
|
|
||||||
│ │ A │ │░░░│ │ 7 ││
|
|
||||||
│ │ ♠ │ │░░░│ │ ♦ ││
|
|
||||||
│ └───┘ └───┘ └───┘│
|
|
||||||
│ ┌───┐ ┌───┐ ┌───┐│
|
|
||||||
│ │ 4 │ │ 5 │ │ Q ││
|
|
||||||
│ │ ♣ │ │ ♥ │ │ ♦ ││
|
|
||||||
│ └───┘ └───┘ └───┘│
|
|
||||||
╰──────────────────╯
|
|
||||||
"""
|
|
||||||
# Pick border color based on state
|
|
||||||
if is_knocker:
|
|
||||||
bc = _BORDER_KNOCKER
|
|
||||||
elif is_current_turn and is_local:
|
|
||||||
bc = _BORDER_TURN_LOCAL
|
|
||||||
elif is_current_turn:
|
|
||||||
bc = _BORDER_TURN_OPPONENT
|
|
||||||
else:
|
|
||||||
bc = _BORDER_NORMAL
|
|
||||||
|
|
||||||
# Build display name
|
|
||||||
display_name = name
|
|
||||||
# Score text
|
|
||||||
score_val = f"{score}" if score is not None else f"{total_score}"
|
|
||||||
score_text = f"{score_val}"
|
|
||||||
|
|
||||||
# Compute box width. Every line is exactly box_width visible chars.
|
|
||||||
# Content row: │ <space> <content> <pad> │ => box_width = vis(content) + 4
|
|
||||||
max_vis = max((_visible_len(line) for line in content_lines), default=17)
|
|
||||||
name_part = f" {display_name} "
|
|
||||||
score_part = f" {score_text} "
|
|
||||||
# Top row: ╭─ <name_part> <fill> <score_part> ─╮
|
|
||||||
# = 4 + len(name_part) + fill + len(score_part)
|
|
||||||
min_top = 4 + len(name_part) + 1 + len(score_part) # fill>=1
|
|
||||||
box_width = max(max_vis + 4, 21, min_top)
|
|
||||||
|
|
||||||
# Possibly truncate name if it still doesn't fit
|
|
||||||
fill_len = box_width - 4 - len(name_part) - len(score_part)
|
|
||||||
if fill_len < 1:
|
|
||||||
max_name = box_width - 4 - len(score_part) - 4
|
|
||||||
display_name = display_name[: max(3, max_name)] + "…"
|
|
||||||
name_part = f" {display_name} "
|
|
||||||
fill_len = box_width - 4 - len(name_part) - len(score_part)
|
|
||||||
|
|
||||||
fill = "─" * max(1, fill_len)
|
|
||||||
|
|
||||||
# Top border
|
|
||||||
top = (
|
|
||||||
f"[{bc}]╭─[/]"
|
|
||||||
f"[bold {_NAME_COLOR}]{name_part}[/]"
|
|
||||||
f"[{bc}]{fill}[/]"
|
|
||||||
f"[bold]{score_part}[/]"
|
|
||||||
f"[{bc}]─╮[/]"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = [top]
|
|
||||||
|
|
||||||
# Content lines
|
|
||||||
inner = box_width - 2 # chars between │ and │
|
|
||||||
for line in content_lines:
|
|
||||||
vis_len = _visible_len(line)
|
|
||||||
right_pad = max(0, inner - 1 - vis_len)
|
|
||||||
result.append(
|
|
||||||
f"[{bc}]│[/] {line}{' ' * right_pad}[{bc}]│[/]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Bottom border — dealer (D)on left, OUT on right
|
|
||||||
left_label = " (D)" if is_dealer else ""
|
|
||||||
right_label = " OUT " if is_knocker else ""
|
|
||||||
mid_fill = max(1, inner - len(left_label) - len(right_label))
|
|
||||||
parts = f"[{bc}]╰[/]"
|
|
||||||
if left_label:
|
|
||||||
parts += f"[bold {bc}]{left_label}[/]"
|
|
||||||
parts += f"[{bc}]{'─' * mid_fill}[/]"
|
|
||||||
if right_label:
|
|
||||||
parts += f"[bold {bc}]{right_label}[/]"
|
|
||||||
parts += f"[{bc}]╯[/]"
|
|
||||||
result.append(parts)
|
|
||||||
|
|
||||||
return result
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
"""Scoreboard overlay for round/game over."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, Horizontal
|
|
||||||
from textual.screen import ModalScreen
|
|
||||||
from textual.widgets import Button, DataTable, Static
|
|
||||||
|
|
||||||
|
|
||||||
class ScoreboardScreen(ModalScreen[str]):
|
|
||||||
"""Modal overlay showing round or game scores."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
scores: list[dict],
|
|
||||||
title: str = "Hole Over",
|
|
||||||
is_game_over: bool = False,
|
|
||||||
is_host: bool = False,
|
|
||||||
round_num: int = 1,
|
|
||||||
total_rounds: int = 1,
|
|
||||||
finisher_id: str | None = None,
|
|
||||||
):
|
|
||||||
super().__init__()
|
|
||||||
self._scores = scores
|
|
||||||
self._title = title
|
|
||||||
self._is_game_over = is_game_over
|
|
||||||
self._is_host = is_host
|
|
||||||
self._round_num = round_num
|
|
||||||
self._total_rounds = total_rounds
|
|
||||||
self._finisher_id = finisher_id
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="scoreboard-container"):
|
|
||||||
yield Static(self._title, id="scoreboard-title")
|
|
||||||
yield DataTable(id="scoreboard-table")
|
|
||||||
with Horizontal(id="scoreboard-buttons"):
|
|
||||||
if self._is_game_over:
|
|
||||||
yield Button("Back to Lobby", id="btn-lobby", variant="primary")
|
|
||||||
elif self._is_host:
|
|
||||||
yield Button("Next Round", id="btn-next-round", variant="primary")
|
|
||||||
else:
|
|
||||||
yield Button("Waiting for host...", id="btn-waiting", disabled=True)
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
table = self.query_one("#scoreboard-table", DataTable)
|
|
||||||
|
|
||||||
# Find lowest hole score for tagging
|
|
||||||
if not self._is_game_over and self._scores:
|
|
||||||
min_score = min(s.get("score", 999) for s in self._scores)
|
|
||||||
else:
|
|
||||||
min_score = None
|
|
||||||
|
|
||||||
if self._is_game_over:
|
|
||||||
table.add_columns("Rank", "Player", "Total", "Holes Won")
|
|
||||||
for i, s in enumerate(self._scores, 1):
|
|
||||||
table.add_row(
|
|
||||||
str(i),
|
|
||||||
s.get("name", "?"),
|
|
||||||
str(s.get("total", 0)),
|
|
||||||
str(s.get("rounds_won", 0)),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
table.add_columns("Player", "Hole Score", "Total", "Holes Won", "")
|
|
||||||
for s in self._scores:
|
|
||||||
# Build tags
|
|
||||||
tags = []
|
|
||||||
pid = s.get("id")
|
|
||||||
score = s.get("score", 0)
|
|
||||||
if pid and pid == self._finisher_id:
|
|
||||||
tags.append("OUT")
|
|
||||||
if min_score is not None and score == min_score:
|
|
||||||
tags.append("⭐")
|
|
||||||
tag_str = " ".join(tags)
|
|
||||||
|
|
||||||
table.add_row(
|
|
||||||
s.get("name", "?"),
|
|
||||||
str(score),
|
|
||||||
str(s.get("total", 0)),
|
|
||||||
str(s.get("rounds_won", 0)),
|
|
||||||
tag_str,
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "btn-next-round":
|
|
||||||
self.dismiss("next_round")
|
|
||||||
elif event.button.id == "btn-lobby":
|
|
||||||
self.dismiss("lobby")
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
"""Status bar showing phase, turn info, and action prompts."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
from tui_client.models import GameState
|
|
||||||
|
|
||||||
|
|
||||||
class StatusBarWidget(Static):
|
|
||||||
"""Top status bar with round, phase, and turn info."""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._state: GameState | None = None
|
|
||||||
self._player_id: str | None = None
|
|
||||||
self._extra: str = ""
|
|
||||||
|
|
||||||
def update_state(self, state: GameState, player_id: str | None = None) -> None:
|
|
||||||
self._state = state
|
|
||||||
self._player_id = player_id
|
|
||||||
self._refresh()
|
|
||||||
|
|
||||||
def set_extra(self, text: str) -> None:
|
|
||||||
self._extra = text
|
|
||||||
self._refresh()
|
|
||||||
|
|
||||||
def _refresh(self) -> None:
|
|
||||||
if not self._state:
|
|
||||||
self.update("Connecting...")
|
|
||||||
return
|
|
||||||
|
|
||||||
state = self._state
|
|
||||||
parts = []
|
|
||||||
|
|
||||||
# Round info
|
|
||||||
parts.append(f"⛳ {state.current_round}/{state.total_rounds}")
|
|
||||||
|
|
||||||
# Phase
|
|
||||||
phase_display = {
|
|
||||||
"waiting": "Waiting",
|
|
||||||
"initial_flip": "[bold white on #6a0dad] Flip Phase [/bold white on #6a0dad]",
|
|
||||||
"playing": "",
|
|
||||||
"final_turn": "[bold white on #c62828] FINAL TURN [/bold white on #c62828]",
|
|
||||||
"round_over": "[white on #555555] Hole Over [/white on #555555]",
|
|
||||||
"game_over": "[bold white on #b8860b] Game Over [/bold white on #b8860b]",
|
|
||||||
}.get(state.phase, state.phase)
|
|
||||||
parts.append(phase_display)
|
|
||||||
|
|
||||||
# Turn info (skip during initial flip - it's misleading)
|
|
||||||
if state.current_player_id and state.players and state.phase != "initial_flip":
|
|
||||||
if state.current_player_id == self._player_id:
|
|
||||||
parts.append(
|
|
||||||
"[on #00bcd4]"
|
|
||||||
" [bold #000000]♣[/bold #000000]"
|
|
||||||
"[bold #cc0000]♦[/bold #cc0000]"
|
|
||||||
" [bold #000000]YOUR TURN![/bold #000000] "
|
|
||||||
"[bold #000000]♠[/bold #000000]"
|
|
||||||
"[bold #cc0000]♥[/bold #cc0000]"
|
|
||||||
" [/on #00bcd4]"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
for p in state.players:
|
|
||||||
if p.id == state.current_player_id:
|
|
||||||
parts.append(f"[white on #555555] {p.name}'s Turn [/white on #555555]")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Finisher indicator
|
|
||||||
if state.finisher_id:
|
|
||||||
for p in state.players:
|
|
||||||
if p.id == state.finisher_id:
|
|
||||||
parts.append(f"[bold white on #b8860b] {p.name} finished! [/bold white on #b8860b]")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Active rules
|
|
||||||
if state.active_rules:
|
|
||||||
parts.append(f"Rules: {', '.join(state.active_rules)}")
|
|
||||||
|
|
||||||
text = " │ ".join(p for p in parts if p)
|
|
||||||
if self._extra:
|
|
||||||
text += f" {self._extra}"
|
|
||||||
|
|
||||||
self.update(text)
|
|
||||||
Loading…
Reference in New Issue
Block a user