Compare commits

...

16 Commits
v3.1.6 ... main

Author SHA1 Message Date
adlee-was-taken
ea34ddf8e4 Fix swap animation stutter and remove 1s server-side dead delay
- Remove unused card_revealed broadcast + 1s asyncio.sleep in swap handler
  (client never handled this message, causing pure dead wait before game_state)
- Defer swap-out (opacity:0) on hand cards to onStart callback so overlay
  covers the card before hiding it — eliminates visual gap for all players
- Defer heldCardFloating visibility hide to onStart — held card stays visible
  until animation overlay replaces it
- Thread onStart callback through animateUnifiedSwap → _runUnifiedSwap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:47:26 -05:00
adlee-was-taken
5408867921 Harden .gitignore and add detect-secrets baseline
Add 19 missing secret file patterns to .gitignore (.env.* variants,
private keys, certificates, credentials, SSH keys). Add detect-secrets
baseline for pre-commit hook secret scanning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:47:02 -05:00
adlee-was-taken
a8b521f7f7 Fix two production crashes and bump to v3.2.0
1. Fix IndexError in current_player() when player leaves mid-game
   - remove_player() now adjusts current_player_index after popping
   - current_player() has safety bounds check as defensive fallback

2. Fix AssertionError in StaticFiles catching WebSocket upgrades
   - Wrap static file mount to reject non-HTTP requests gracefully
   - Starlette's StaticFiles asserts scope["type"] == "http"

Both crashes were observed in production on 2026-02-28 during a
multi-player session. The IndexError cascaded into reconnection
attempts that hit the StaticFiles assertion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:30:08 -05:00
adlee-was-taken
7f0f580631 Add client-side card reveal before swap and YOUR TURN badge update
Reveal face-down cards briefly (1s) before swap completes, using
client-side state diffing instead of a separate server message.
Local player reveals use existing card data; opponent reveals use
server-sent card_revealed as a fallback. Defers incoming game_state
updates during the reveal window to prevent overwrites.

Also update YOUR TURN badge to cyan with suit symbols.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:35:49 -05:00
adlee-was-taken
215849703c Add inline comments across client and server codebase
Full-codebase commenting pass focused on the tricky, fragile, and
non-obvious spots: animation coordination flags in app.js, AI decision
safety checks in ai.py, scoring evaluation order in game.py, animation
engine magic numbers in card-animations.js, and server infrastructure
coupling in main.py/handlers.py/room.py. No logic changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:17:19 -05:00
adlee-was-taken
72eab2c811 TUI visual polish: felt table, status bar, scoreboard delay
- Dark green felt background for game screen
- Status bar: dark brown bg with amber text instead of blue
- YOUR TURN badge: green bg with white text instead of bright gold
- 3s delay before hole-complete scoreboard overlay
- Dealer indicator changed from Ⓓ to (D)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:56:01 -05:00
adlee-was-taken
dfb3397dcb Overhaul TUI navigation, quit handling, and scoreboard tags
- Replace [esc][esc] quit with [q] quit globally (immediate on login,
  confirmation prompt elsewhere)
- [esc] is now consistently "back": signup→login, lobby→log out (with
  confirm), in-room host→leave (with confirm), in-room guest→leave
- Extract ConfirmScreen to shared screens/confirm.py
- Move dealer Ⓓ indicator to bottom-left corner of player box border
- Scoreboard now tags OUT (went out first) and  (lowest score)
- Send finisher_id and player id in round_over server message
- Room code moved inside in-room section with amber border
- Lobby title uses branded 🏌️ GolfCards.club ♠♥♣♦
- Amber borders and dark green backgrounds on login/lobby containers
- Deck preview renders actual card-back shapes (▓▒▓/▒▓▒)
- Help/standings panels close only with [esc], hint updated
- Game footer: s[⇥]andings [h]elp on left, [q]uit on right

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:41:45 -05:00
adlee-was-taken
b1d3aa7b77 Add session persistence, splash screen, and TUI polish
Save JWT token to ~/.config/golfcards/session.json after login so
subsequent launches skip the login screen when the session is still
valid. A new splash screen shows the token check status (SUCCESS /
NONE FOUND / EXPIRED) before routing to lobby or login.

Also: move OUT indicator to player box bottom border, remove checkmark,
center scoreboard overlay, use alternating shade blocks (▓▒▓/▒▓▒) for
card backs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:35:03 -05:00
adlee-was-taken
67d06d9799 Mark stale games as abandoned in DB during cleanup and on startup
- Periodic room cleanup now updates games_v2 status to 'abandoned'
- Server startup marks all orphaned active games as abandoned
- Prevents stale games from accumulating in the admin portal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:25:03 -05:00
adlee-was-taken
82aa3dfb3e Add auto-cleanup of stale game rooms after 5 minutes of inactivity
Rooms that sit idle (no player actions or CPU turns) for longer than
ROOM_IDLE_TIMEOUT_SECONDS (default 300s) are now automatically cleaned
up: CPU tasks cancelled, players notified with room_expired, WebSockets
closed, and room removed from memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:17:57 -05:00
adlee-was-taken
7001232658 Add single-escape navigation: back from signup/lobby, leave room
- Single Esc: goes back one step (signup→login, lobby→connect, room→lobby)
- Double Esc: still quits the app
- Footer bar shows [Esc] Back and [Esc][Esc] Quit hints on all screens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:17:10 -05:00
adlee-was-taken
13e98d330a Add TUI signup flow, quit/help/standings modals, and UI refinements
- Add signup with invite code support, remove guest login
- Add quit confirmation (q), help screen (h), standings tab
- Unified footer: [h]elp [q]uit | action text | [tab] standings
- Amber card highlighting persists through entire initial flip phase
- Player box border only highlights on turn (green) or knock (red)
- Play area gold border only during player's actual turn
- Game end returns to lobby create/join instead of login screen
- Lobby reset_to_pre_room for replayability without reconnecting
- Dynamic opponent layout fits all in one row when terminal is wide enough
- Hole emoji () in status bar, branded title with suits on connect screen
- DECK label spacing, Hole terminology in scoreboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:14:04 -05:00
adlee-was-taken
bfe29bb665 Add TUI lobby settings, clickable cards, and UI polish
- Lobby: collapsible Game Settings, House Rules, Deck Style sections
- Lobby: CPU profile picker via [+], random CPU via [?], remove via [-]
- Lobby: all settings (rounds, decks, flip mode, house rules, deck colors)
  sent to server on start_game instead of hardcoded defaults
- Game: clickable cards (hand positions, deck, discard pile)
- Game: immediate visual feedback on initial card flips
- Game: action bar shows escaped keyboard hints (Keyboard: Choose [d]eck...)
- Game: play area uses fixed-width rounded box instead of horizontal lines
- Game: position numbers on card top-left corner (replacing ┌) on all states
- Game: deck color preview swatches next to style dropdown
- Fix opponent box height mismatch when match connectors present
- Rebrand to GolfCards.club
- Add spacing between status bar/opponents and above local hand

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:23:27 -05:00
adlee-was-taken
e601c3eac4 Add DAILY_OPEN_SIGNUPS and DAILY_SIGNUPS_PER_IP to compose env vars
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:38:25 -05:00
adlee-was-taken
6461a7f0c7 Add metered open signups, per-IP limits, and auth security hardening
Enables public beta signup metering: DAILY_OPEN_SIGNUPS env var controls
how many users can register without an invite code per day (0=disabled,
-1=unlimited, N=daily cap). Invite codes always bypass the limit.

Also adds per-IP signup throttling (DAILY_SIGNUPS_PER_IP, default 3/day)
and fail-closed rate limiting on auth endpoints when Redis is down.

Client dynamically fetches /api/auth/signup-info to show invite field
as optional with remaining slots when open signups are enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:28:28 -05:00
adlee-was-taken
3d02d739e5 Set prod log level default to WARNING
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:00:33 -05:00
41 changed files with 4597 additions and 90 deletions

View File

@ -75,6 +75,15 @@ SECRET_KEY=
# Enable invite-only mode (requires invitation to register)
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)
# Remove these after first login!
# BOOTSTRAP_ADMIN_USERNAME=admin

24
.gitignore vendored
View File

@ -136,7 +136,31 @@ celerybeat.pid
# Environments
.env
.env.*
!.env.example
.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
env/
venv/

300
.secrets.baseline Normal file
View File

@ -0,0 +1,300 @@
{
"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"
}

View File

@ -31,14 +31,17 @@ 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) {
if (!movements || movements.length === 0) {
if (onComplete) onComplete();
return;
}
// Add completion callback to last movement
// Attach callback to last movement only
const movementsWithCallback = movements.map((m, i) => ({
...m,
onComplete: i === movements.length - 1 ? onComplete : null
@ -185,7 +188,9 @@ class AnimationQueue {
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');
heldCard.classList.add('fade-out');
await this.delay(150);

View File

@ -30,7 +30,14 @@ class GolfGame {
this.soundEnabled = true;
this.audioCtx = null;
// Swap animation state
// --- Animation coordination flags ---
// 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.swapAnimationCardEl = null;
this.swapAnimationFront = null;
@ -44,19 +51,19 @@ class GolfGame {
// Animation lock - prevent overlapping animations on same elements
this.animatingPositions = new Set();
// Track opponent swap animation in progress (to apply swap-out class after render)
// Blocks discard update: opponent swap animation in progress
this.opponentSwapAnimation = null; // { playerId, position }
// Track draw pulse animation in progress (defer held card display until pulse completes)
// Blocks held card display: draw pulse animation hasn't finished yet
this.drawPulseAnimation = false;
// Track local discard animation in progress (prevent renderGame from updating discard)
// Blocks discard update: local player discarding drawn card to pile
this.localDiscardAnimating = false;
// Track opponent discard animation in progress (prevent renderGame from updating discard)
// Blocks discard update: opponent discarding without swap
this.opponentDiscardAnimating = false;
// Track deal animation in progress (suppress flip prompts until dealing complete)
// Blocks discard update + suppresses flip prompts: deal animation in progress
this.dealAnimationInProgress = false;
// Track round winners for visual highlight
@ -74,6 +81,7 @@ class GolfGame {
this.initCardTooltips();
this.bindEvents();
this.initMobileDetection();
this.initDesktopScorecard();
this.checkUrlParams();
}
@ -104,9 +112,11 @@ class GolfGame {
this.isMobile = e.matches;
document.body.classList.toggle('mobile-portrait', e.matches);
setAppHeight();
// Close any open drawers on layout change
// Close any open drawers/overlays on layout change
if (!e.matches) {
this.closeDrawers();
} else {
this.closeDesktopScorecard();
}
};
mql.addEventListener('change', update);
@ -147,6 +157,31 @@ class GolfGame {
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() {
// Initialize audio context on first user interaction
const initCtx = () => {
@ -537,6 +572,13 @@ class GolfGame {
this.gameUsername = document.getElementById('game-username');
this.gameLogoutBtn = document.getElementById('game-logout-btn');
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() {
@ -818,7 +860,11 @@ class GolfGame {
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' &&
newState.phase === 'round_over';
@ -834,7 +880,8 @@ class GolfGame {
}
// Build preRevealState from oldState, but mark swap position as
// already handled so reveal animation doesn't double-flip it
// 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;
@ -1332,14 +1379,16 @@ class GolfGame {
this.heldCardFloating.classList.add('hidden');
this.heldCardFloating.style.cssText = '';
// Pre-emptively skip the flip animation - the server may broadcast the new state
// before our animation completes, and we don't want renderGame() to trigger
// the flip-in animation (which starts with opacity: 0, causing a flash)
// Three-part race guard. All three are needed, and they protect different things:
// 1. skipNextDiscardFlip: prevents the CSS flip-in animation from firing
// (it starts at opacity:0, which causes a visible 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;
// Also update lastDiscardKey so renderGame() won't see a "change"
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
// Block renderGame from updating discard during animation (prevents race condition)
this.localDiscardAnimating = true;
// Animate held card to discard using anime.js
@ -1450,12 +1499,8 @@ class GolfGame {
this.swapAnimationCardEl = handCardEl;
this.swapAnimationHandCardEl = handCardEl;
// Hide originals and UI during animation
handCardEl.classList.add('swap-out');
// Hide discard button during animation (held card hidden later by onStart)
this.discardBtn.classList.add('hidden');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
// Store drawn card data before clearing
const drawnCardData = this.drawnCard;
@ -1478,6 +1523,12 @@ class GolfGame {
{
rotation: 0,
wasHandFaceDown: false,
onStart: () => {
handCardEl.classList.add('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
},
onComplete: () => {
handCardEl.classList.remove('swap-out');
if (this.heldCardFloating) {
@ -1541,6 +1592,12 @@ class GolfGame {
{
rotation: 0,
wasHandFaceDown: true,
onStart: () => {
if (handCardEl) handCardEl.classList.add('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
},
onComplete: () => {
if (handCardEl) handCardEl.classList.remove('swap-out');
if (this.heldCardFloating) {
@ -2414,7 +2471,13 @@ class GolfGame {
};
}
// Fire-and-forget animation triggers based on state changes
// Fire-and-forget animation triggers based on state diffs.
// 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) {
if (!oldState) return;
@ -2522,18 +2585,18 @@ class GolfGame {
}
// STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances)
// Skip if we just detected a draw - the discard change was from REMOVING a card, not adding one
// Skip if we just detected a draw — see comment at top of function.
if (discardChanged && wasOtherPlayer && !justDetectedDraw) {
// Check if the previous player actually SWAPPED (has a new face-up card)
// vs just discarding the drawn card (no hand change)
// Figure out if the previous player SWAPPED (a card in their hand changed)
// or just discarded their drawn card (hand is identical).
// 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 newPlayer = newState.players.find(p => p.id === previousPlayerId);
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 wasFaceUp = false; // Track if old card was already face-up
@ -2867,9 +2930,6 @@ class GolfGame {
return;
}
// Hide the source card during animation
sourceCardEl.classList.add('swap-out');
// Use unified swap animation
if (window.cardAnimations) {
const heldRect = window.cardAnimations.getHoldingRect();
@ -2882,6 +2942,9 @@ class GolfGame {
{
rotation: sourceRotation,
wasHandFaceDown: !wasFaceUp,
onStart: () => {
sourceCardEl.classList.add('swap-out');
},
onComplete: () => {
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null;
@ -3993,7 +4056,11 @@ class GolfGame {
// Not holding - show normal discard pile
this.discard.classList.remove('picked-up');
// Skip discard update during any discard-related animation - animation handles the visual
// The discard pile is touched by four different animation paths.
// 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' :
this.opponentSwapAnimation ? 'opponentSwapAnimation' :
this.opponentDiscardAnimating ? 'opponentDiscardAnimating' :
@ -4017,7 +4084,9 @@ class GolfGame {
const discardCard = this.gameState.discard_top;
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' &&
this.gameState.phase !== 'game_over';
const shouldAnimate = isActivePlay && this.lastDiscardKey &&
@ -4321,6 +4390,11 @@ class GolfGame {
`;
this.scoreTable.appendChild(tr);
});
// Mirror to desktop overlay
if (this.desktopScoreTable) {
this.desktopScoreTable.innerHTML = this.scoreTable.innerHTML;
}
}
updateStandings() {
@ -4358,7 +4432,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>`;
}).join('');
this.standingsList.innerHTML = `
const standingsContent = `
<div class="standings-section">
<div class="standings-title">By Score</div>
${pointsHtml}
@ -4368,6 +4442,10 @@ class GolfGame {
${holesHtml}
</div>
`;
this.standingsList.innerHTML = standingsContent;
if (this.desktopStandingsList) {
this.desktopStandingsList.innerHTML = standingsContent;
}
}
renderCard(card, clickable, selected) {
@ -4447,6 +4525,11 @@ class GolfGame {
this.scoreTable.appendChild(tr);
});
// Mirror to desktop overlay
if (this.desktopScoreTable) {
this.desktopScoreTable.innerHTML = this.scoreTable.innerHTML;
}
// Show rankings announcement only for final results
const existingAnnouncement = document.getElementById('rankings-announcement');
if (existingAnnouncement) existingAnnouncement.remove();
@ -4762,11 +4845,14 @@ class AuthManager {
this.signupFormContainer = document.getElementById('signup-form-container');
this.signupForm = document.getElementById('signup-form');
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.signupEmail = document.getElementById('signup-email');
this.signupPassword = document.getElementById('signup-password');
this.signupError = document.getElementById('signup-error');
this.showSignupLink = document.getElementById('show-signup');
this.signupInfo = null; // populated by fetchSignupInfo()
this.showLoginLink = document.getElementById('show-login');
this.showForgotLink = document.getElementById('show-forgot');
this.forgotFormContainer = document.getElementById('forgot-form-container');
@ -4815,6 +4901,9 @@ class AuthManager {
// Check URL for reset token or invite code on page load
this.checkResetToken();
this.checkInviteCode();
// Fetch signup availability info (metered open signups)
this.fetchSignupInfo();
}
showModal(form = 'login') {
@ -4979,6 +5068,44 @@ 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) {
e.preventDefault();
this.clearErrors();

View File

@ -43,10 +43,14 @@ class CardAnimations {
const discardRect = this.getDiscardRect();
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 cardWidth = deckRect.width;
const cardHeight = deckRect.height;
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
// 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 {
@ -463,6 +467,9 @@ class CardAnimations {
}
});
// 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: () => {} });
} catch (e) {
console.error('Initial flip animation error:', e);
@ -786,7 +793,11 @@ class CardAnimations {
});
};
// Delay first shake, then repeat at interval
// Two-phase timing: wait initialDelay, then shake on an interval.
// Edge case: if stopTurnPulse() is called between the timeout firing and
// the interval being stored on the entry, the interval would leak. That's
// why we re-check activeAnimations.has(id) after the timeout fires — if
// stop was called during the delay, we bail before creating the interval.
const timeout = setTimeout(() => {
if (!this.activeAnimations.has(id)) return;
doShake();
@ -1094,7 +1105,7 @@ class CardAnimations {
// heldRect: position of the held card (or null to use default holding position)
// options: { rotation, wasHandFaceDown, onComplete }
animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) {
const { rotation = 0, wasHandFaceDown = false, onComplete } = options;
const { rotation = 0, wasHandFaceDown = false, onComplete, onStart } = options;
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
const discardRect = this.getDiscardRect();
@ -1114,27 +1125,27 @@ class CardAnimations {
return;
}
// Wait for any in-progress draw animation to complete
// Check if there's an active draw animation by looking for overlay cards
// Collision detection: if a draw animation is still in flight (its overlay cards
// are still in the DOM), we can't start the swap yet — both animations touch the
// 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"]');
if (existingDrawCards.length > 0) {
// Draw animation still in progress - wait a bit and retry
setTimeout(() => {
// Clean up the draw animation overlay
existingDrawCards.forEach(el => {
delete el.dataset.animating;
el.remove();
});
// Now run the swap animation
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart);
}, 350);
return;
}
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart);
}
_runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete) {
_runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart) {
// Create the two traveling cards
const travelingHand = this.createCardFromData(handCardData, handRect, rotation);
const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0);
@ -1143,6 +1154,9 @@ class CardAnimations {
document.body.appendChild(travelingHand);
document.body.appendChild(travelingHeld);
// Now that overlays cover the originals, hide them
if (onStart) onStart();
this.playSound('card');
// If hand card was face-down, flip it first
@ -1208,6 +1222,9 @@ class CardAnimations {
],
width: discardRect.width,
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],
duration: T.arc,
easing: this.getEasing('arc'),

View File

@ -100,12 +100,14 @@ 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) {
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
return null;
}
// Get deck colors from game state (set by app.js)
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
return `deck-${colorName}`;
@ -126,7 +128,10 @@ class CardManager {
cardEl.style.width = `${rect.width}px`;
cardEl.style.height = `${rect.height}px`;
// On mobile, scale font proportional to card width so rank/suit fit
// 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')) {
cardEl.style.fontSize = `${rect.width * 0.35}px`;
} else {
@ -235,7 +240,9 @@ class CardManager {
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');
this.positionCard(cardEl, discardRect);
await this.delay(duration + 50);

View File

@ -893,8 +893,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div id="signup-form-container" class="hidden">
<h3>Sign Up</h3>
<form id="signup-form">
<div class="form-group">
<input type="text" id="signup-invite-code" placeholder="Invite Code" required>
<div class="form-group" id="invite-code-group">
<input type="text" id="signup-invite-code" placeholder="Invite Code">
<small id="invite-code-hint" class="form-hint"></small>
</div>
<div class="form-group">
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">

View File

@ -3605,6 +3605,13 @@ input::placeholder {
width: 100%;
}
.form-hint {
display: block;
margin-top: 4px;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.45);
}
.auth-switch {
text-align: center;
margin-top: 15px;

View File

@ -29,7 +29,7 @@ services:
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
- SENTRY_DSN=${SENTRY_DSN:-}
- ENVIRONMENT=${ENVIRONMENT:-production}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- LOG_LEVEL=${LOG_LEVEL:-WARNING}
- LOG_LEVEL_GAME=${LOG_LEVEL_GAME:-}
- LOG_LEVEL_AI=${LOG_LEVEL_AI:-}
- LOG_LEVEL_HANDLERS=${LOG_LEVEL_HANDLERS:-}
@ -39,6 +39,8 @@ services:
- BASE_URL=${BASE_URL:-https://golf.example.com}
- RATE_LIMIT_ENABLED=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_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
- MATCHMAKING_ENABLED=true

View File

@ -29,6 +29,8 @@ services:
- 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

View File

@ -55,17 +55,15 @@ CPU_TIMING = {
"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 = {
# Obviously good cards (Jokers, Kings, 2s, Aces) - easy take
"easy_good": (0.15, 0.3),
# Obviously bad cards (10s, Jacks, Queens) - easy pass
"easy_bad": (0.15, 0.3),
# Medium difficulty (3, 4, 8, 9)
"medium": (0.15, 0.3),
# Hardest decisions (5, 6, 7 - middle of range)
"hard": (0.15, 0.3),
# No discard available - quick decision
"no_card": (0.15, 0.3),
}
@ -800,7 +798,9 @@ class GolfAI:
ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)")
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:
for i, card in enumerate(player.cards):
pair_pos = (i + 3) % 6 if i < 3 else i - 3
@ -1031,7 +1031,11 @@ class GolfAI:
if not creates_negative_pair:
expected_hidden = EXPECTED_HIDDEN_VALUE
point_gain = expected_hidden - drawn_value
discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0
# Personality discount: swap_threshold ranges 0-8, so this maps to 0.5-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 0.0
@ -1252,8 +1256,6 @@ class GolfAI:
"""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.
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
face_down_positions = hidden_positions(player)
@ -1361,7 +1363,11 @@ class GolfAI:
if not face_down or random.random() >= 0.5:
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:
last_pos = face_down[0]
projected = drawn_value
@ -1965,7 +1971,8 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga
async def process_cpu_turn(
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None,
reveal_callback=None,
) -> None:
"""Process a complete turn for a CPU player.
@ -2083,6 +2090,13 @@ async def process_cpu_turn(
if swap_pos is not None:
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)
_log_cpu_action(logger, game_id, cpu_player, game,
action="swap", card=drawn, position=swap_pos,

View File

@ -142,11 +142,18 @@ class ServerConfig:
MAX_PLAYERS_PER_ROOM: int = 6
ROOM_TIMEOUT_MINUTES: int = 60
ROOM_CODE_LENGTH: int = 4
ROOM_IDLE_TIMEOUT_SECONDS: int = 300 # 5 minutes of inactivity
# Security (for future auth system)
SECRET_KEY: str = ""
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_USERNAME: str = ""
BOOTSTRAP_ADMIN_PASSWORD: str = ""
@ -192,8 +199,11 @@ class ServerConfig:
MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6),
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
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", ""),
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_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),

View File

@ -358,6 +358,13 @@ class Player:
jack_pairs = 0 # Track paired Jacks for Wolfpack bonus
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):
top_idx = col
bottom_idx = col + 3
@ -775,9 +782,17 @@ class Game:
for i, player in enumerate(self.players):
if player.id == player_id:
removed = self.players.pop(i)
# Adjust dealer_idx if needed after removal
if self.players and self.dealer_idx >= len(self.players):
self.dealer_idx = 0
if self.players:
# Adjust dealer_idx if needed after removal
if self.dealer_idx >= len(self.players):
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)
return removed
return None
@ -800,6 +815,8 @@ class Game:
def current_player(self) -> Optional[Player]:
"""Get the player whose turn it currently is."""
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 None
@ -932,7 +949,8 @@ class Game:
if self.current_round > 1:
self.dealer_idx = (self.dealer_idx + 1) % len(self.players)
# First player is to the left of dealer (next in order)
# "Left of dealer goes first" — standard card game convention.
# In our circular list, "left" is the next index.
self.current_player_index = (self.dealer_idx + 1) % len(self.players)
# Emit round_started event with deck seed and all dealt cards
@ -1415,6 +1433,9 @@ class Game:
Args:
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:
self.finisher_id = player.id
self.phase = GamePhase.FINAL_TURN
@ -1431,7 +1452,8 @@ class Game:
Advance to the next player's turn.
In FINAL_TURN phase, tracks which players have had their final turn
and ends the round when everyone has played.
and ends the round when everyone has played. Depends on _check_end_turn()
having already added the current player to players_with_final_turn.
"""
if self.phase == GamePhase.FINAL_TURN:
next_index = (self.current_player_index + 1) % len(self.players)
@ -1474,6 +1496,10 @@ class Game:
player.calculate_score(self.options)
# --- 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
if self.options.blackjack:
@ -1597,6 +1623,10 @@ class Game:
"""
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 = []
for player in self.players:
reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER)

View File

@ -69,6 +69,7 @@ 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")
room = room_manager.create_room()
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
room.touch()
ctx.current_room = room
await ctx.websocket.send_json({
@ -114,6 +115,7 @@ async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager,
return
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
room.touch()
ctx.current_room = room
await ctx.websocket.send_json({
@ -189,6 +191,7 @@ 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:
if not ctx.current_room:
return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host:
@ -235,6 +238,7 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
ctx.current_room.touch()
positions = data.get("positions", [])
async with ctx.current_room.game_lock:
@ -250,6 +254,7 @@ async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_g
async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
if not ctx.current_room:
return
ctx.current_room.touch()
source = data.get("source", "deck")
async with ctx.current_room.game_lock:
@ -277,6 +282,7 @@ 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:
if not ctx.current_room:
return
ctx.current_room.touch()
position = data.get("position", 0)
async with ctx.current_room.game_lock:
@ -284,6 +290,18 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
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
# 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)
if discarded:
@ -303,6 +321,7 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
ctx.current_room.touch()
async with ctx.current_room.game_lock:
drawn_card = ctx.current_room.game.drawn_card
@ -349,6 +368,7 @@ 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:
if not ctx.current_room:
return
ctx.current_room.touch()
position = data.get("position", 0)
async with ctx.current_room.game_lock:
@ -370,6 +390,7 @@ async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
ctx.current_room.touch()
async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id)
@ -386,6 +407,7 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game
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:
return
ctx.current_room.touch()
position = data.get("position", 0)
async with ctx.current_room.game_lock:
@ -406,6 +428,7 @@ async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
ctx.current_room.touch()
async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id)
@ -424,6 +447,7 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host:
@ -467,6 +491,7 @@ 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:
if not ctx.current_room:
return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host:

View File

@ -64,6 +64,7 @@ _matchmaking_service = None
_replay_service = None
_spectator_manager = None
_leaderboard_refresh_task = None
_room_cleanup_task = None
_redis_client = None
_rate_limiter = None
_shutdown_event = asyncio.Event()
@ -83,8 +84,74 @@ async def _periodic_leaderboard_refresh():
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():
"""Initialize Redis client and rate limiter."""
"""Initialize Redis client, rate limiter, and signup limiter."""
global _redis_client, _rate_limiter
try:
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
@ -95,6 +162,17 @@ async def _init_redis():
from services.ratelimit import get_rate_limiter
_rate_limiter = await get_rate_limiter(_redis_client)
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:
logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
_redis_client = None
@ -243,6 +321,14 @@ async def _shutdown_services():
reset_all_profiles()
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:
_leaderboard_refresh_task.cancel()
try:
@ -301,6 +387,26 @@ async def lifespan(app: FastAPI):
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})")
yield
@ -325,7 +431,7 @@ async def _close_all_websockets():
app = FastAPI(
title="Golf Card Game",
debug=config.DEBUG,
version="3.1.6",
version="3.2.0",
lifespan=lifespan,
)
@ -509,6 +615,8 @@ async def reset_cpu_profiles():
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
@ -549,6 +657,10 @@ async def websocket_endpoint(websocket: WebSocket):
else:
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(
websocket=websocket,
connection_id=connection_id,
@ -652,7 +764,7 @@ async def broadcast_game_state(room: Room):
# Check for round over
if room.game.phase == GamePhase.ROUND_OVER:
scores = [
{"name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
{"id": p.id, "name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
for p in room.game.players
]
# Build rankings
@ -661,6 +773,7 @@ async def broadcast_game_state(room: Room):
await player.websocket.send_json({
"type": "round_over",
"scores": scores,
"finisher_id": room.game.finisher_id,
"round": room.game.current_round,
"total_rounds": room.game.num_rounds,
"rankings": {
@ -750,14 +863,32 @@ async def _run_cpu_chain(room: Room):
if not room_player or not room_player.is_cpu:
return
# Brief pause before CPU starts - animations are faster now
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)
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
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):
@ -774,7 +905,8 @@ async def handle_player_leave(room: Room, player_id: str):
room_code = room.code
room_player = room.remove_player(player_id)
# If no human players left, clean up the room entirely
# Check both is_empty() AND human_player_count() — CPU players keep rooms
# technically non-empty, but a room with only CPUs is an abandoned room.
if room.is_empty() or room.human_player_count() == 0:
# Remove all remaining CPU players to release their profiles
for cpu in list(room.get_cpu_players()):
@ -811,7 +943,18 @@ if os.path.exists(client_path):
return FileResponse(os.path.join(client_path, "index.html"))
# Mount static files for everything else (JS, CSS, SVG, etc.)
app.mount("/", StaticFiles(directory=client_path), name="static")
# Wrap StaticFiles to reject WebSocket requests gracefully instead of
# 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():

View File

@ -81,11 +81,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
# Generate client key
client_key = self.limiter.get_client_key(request, user_id)
# Check rate limit
# Check rate limit (fail closed for auth endpoints)
endpoint_key = self._get_endpoint_key(path)
full_key = f"{endpoint_key}:{client_key}"
allowed, info = await self.limiter.is_allowed(full_key, limit, window)
is_auth_endpoint = path.startswith("/api/auth")
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
if allowed:

View File

@ -14,6 +14,7 @@ A Room contains:
import asyncio
import random
import string
import time
from dataclasses import dataclass, field
from typing import Optional
@ -70,6 +71,11 @@ class Room:
game_log_id: Optional[str] = None
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(
self,
@ -92,6 +98,9 @@ class Room:
Returns:
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
room_player = RoomPlayer(
id=player_id,
@ -167,7 +176,9 @@ class Room:
if room_player.is_cpu:
release_profile(room_player.name, self.code)
# Assign new host if needed
# Assign new host if needed. next(iter(...)) gives us the first value in
# 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:
next_host = next(iter(self.players.values()))
next_host.is_host = True

View File

@ -5,6 +5,7 @@ Provides endpoints for user registration, login, password management,
and session handling.
"""
import hashlib
import logging
from typing import Optional
@ -15,6 +16,7 @@ from config import config
from models.user import User
from services.auth_service import AuthService
from services.admin_service import AdminService
from services.ratelimit import SignupLimiter
logger = logging.getLogger(__name__)
@ -115,6 +117,7 @@ class SessionResponse(BaseModel):
# These will be set by main.py during startup
_auth_service: Optional[AuthService] = None
_admin_service: Optional[AdminService] = None
_signup_limiter: Optional[SignupLimiter] = None
def set_auth_service(service: AuthService) -> None:
@ -129,6 +132,12 @@ def set_admin_service_for_auth(service: AdminService) -> None:
_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:
"""Dependency to get auth service."""
if _auth_service is None:
@ -211,15 +220,51 @@ async def register(
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Register a new user account."""
# Validate invite code when invite-only mode is enabled
if config.INVITE_ONLY:
if not request_body.invite_code:
raise HTTPException(status_code=400, detail="Invite code required")
has_invite = bool(request_body.invite_code)
is_open_signup = not has_invite
client_ip = get_client_ip(request)
ip_hash = hashlib.sha256(client_ip.encode()).hexdigest()[:16] if client_ip else "unknown"
# --- 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:
raise HTTPException(status_code=503, detail="Admin service not initialized")
if not await _admin_service.validate_invite_code(request_body.invite_code):
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
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(
username=request_body.username,
password=request_body.password,
@ -229,12 +274,19 @@ async def register(
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
# Consume the invite code after successful registration
if config.INVITE_ONLY and request_body.invite_code:
# --- Post-registration bookkeeping ---
# Consume invite code if used
if has_invite and _admin_service:
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:
# Return user info but note they need to verify
return {
"user": _user_to_response(result.user),
"token": "",
@ -247,7 +299,7 @@ async def register(
username=request_body.username,
password=request_body.password,
device_info=get_device_info(request),
ip_address=get_client_ip(request),
ip_address=client_ip,
)
if not login_result.success:
@ -260,6 +312,32 @@ 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")
async def verify_email(
request_body: VerifyEmailRequest,

View File

@ -91,9 +91,42 @@ class RateLimiter:
except redis.RedisError as e:
# 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}")
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(
self,
request: Request | WebSocket,
@ -197,8 +230,110 @@ class ConnectionMessageLimiter:
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
_rate_limiter: Optional[RateLimiter] = None
_signup_limiter: Optional[SignupLimiter] = None
async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
@ -217,7 +352,16 @@ async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
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():
"""Close the global rate limiter."""
global _rate_limiter
global _rate_limiter, _signup_limiter
_rate_limiter = None
_signup_limiter = None

20
tui_client/pyproject.toml Normal file
View File

@ -0,0 +1,20 @@
[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"]

View File

@ -0,0 +1 @@
"""TUI client for the Golf card game."""

View File

@ -0,0 +1,59 @@
"""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()

View File

@ -0,0 +1,130 @@
"""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()

View File

@ -0,0 +1,196 @@
"""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)})

View File

@ -0,0 +1,41 @@
"""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")

View File

@ -0,0 +1,156 @@
"""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"]),
)

View File

@ -0,0 +1 @@
"""Screen modules for the TUI client."""

View File

@ -0,0 +1,41 @@
"""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)

View File

@ -0,0 +1,184 @@
"""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

View File

@ -0,0 +1,810 @@
"""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

View File

@ -0,0 +1,561 @@
"""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)

View File

@ -0,0 +1,74 @@
"""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())

View File

@ -0,0 +1,452 @@
/* 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;
}

View File

@ -0,0 +1 @@
"""Widget modules for the TUI client."""

View File

@ -0,0 +1,179 @@
"""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)

View File

@ -0,0 +1,222 @@
"""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))

View File

@ -0,0 +1,131 @@
"""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))

View File

@ -0,0 +1,117 @@
"""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

View File

@ -0,0 +1,88 @@
"""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")

View File

@ -0,0 +1,83 @@
"""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)