Compare commits

...

48 Commits
v3.1.5 ... 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
adlee-was-taken
3ca52eb7d1 Bump version to 3.1.6, update docs
Update version across pyproject.toml, FastAPI app, HTML footers,
and V3.17 doc. Mark kicked-ball bug as resolved in docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:59:54 -05:00
adlee-was-taken
3c63af91f2 Bump mobile logo-golfer gap from 12px to 15px
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:56:43 -05:00
adlee-was-taken
5fcf8bab60 Fix logo-golfer spacing: source order bug, tighten landscape, widen mobile
Base .golfer-container rule was after the mobile @media override,
clobbering it. Moved base rule before the media query. Landscape
gets -2px (snug), mobile gets 12px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:55:28 -05:00
adlee-was-taken
8bc8595b39 Adjust logo-golfer spacing: tighter landscape, more room on mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:54:08 -05:00
adlee-was-taken
7c58543ec8 Tighten landscape logo-golfer gap, alternate suit colors on ball logo
Reduce golfer-container margin from 10px to 4px in landscape (2-row)
mode while keeping 10px on mobile. Swap bottom suits to checkerboard
pattern: club/diamond on top, heart/spade on bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:52:25 -05:00
adlee-was-taken
4b00094140 Add spacing between logo ball and golfer container
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:48:44 -05:00
adlee-was-taken
65d6598a51 Fix kicked ball launching from golfer's back foot at narrow viewports
Wrap golfer+ball in a positioned container so the ball is absolutely
anchored to the golfer's front foot, independent of inline flow/viewport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:47:06 -05:00
adlee-was-taken
baa471307e Tune lobby header: 2x2 suit grid, mobile spacing, tighter row gap
- Rearrange golf ball SVG suits from single row to 2x2 grid
- Add even spacing between logo, golfer, and title in mobile view
- Remove row-gap between logo row and title row in landscape view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:35:25 -05:00
adlee-was-taken
26778e4b02 Fix lobby header: use inline-grid for logo/title layout
The 749px media query was triggering at mid-range widths, collapsing
the two-row logo+title into a single line. Fix by:
- Using inline-grid on h1 for bulletproof two-row layout
- Lowering single-line breakpoint from 749px to 500px
- Widening lobby container to 550px for title to fit naturally
- Constraining game controls to 400px max-width

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:22:34 -05:00
adlee-was-taken
cce2d661a2 Fix logo-row centering at mid-range widths (750-1120px)
Change .logo-row from inline-block to block so the golf ball logo
always left-aligns flush with the G, regardless of viewport width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:26:52 -05:00
adlee-was-taken
1b748470a0 Use width:fit-content on h1 for bulletproof logo-title alignment
The h1 shrinks to its widest child (GolfCards.club), centers via
margin auto, and text-align left aligns both lines within it.
No breakpoint-dependent transforms needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:14:56 -05:00
adlee-was-taken
d32ae83ce2 Nudge mobile header line 18px left via text-indent
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:07:58 -05:00
adlee-was-taken
e542cadedf Bump mobile single-line breakpoint to 749px to cover all phones
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:06:39 -05:00
adlee-was-taken
cd2d7535e3 Replace translateX hack with text-align left on h1 for logo alignment
Logo and title naturally left-align within the centered lobby box.
Mobile (<480px) gets text-align center + inline display for single line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:02:17 -05:00
adlee-was-taken
4dff1da875 Only trigger single-line mode at <=449px, shift everywhere else
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:59:51 -05:00
adlee-was-taken
8f21a40a6a Make logo-row inline on mobile for single-line header layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:58:28 -05:00
adlee-was-taken
0ae999aca6 Revert flex approach, use default translateX with max-width reset for mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:57:49 -05:00
adlee-was-taken
a87cd7f4b0 Use inline-flex column on h1 to left-align logo row with title
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:56:38 -05:00
adlee-was-taken
eb072dbfb4 Set logo-row shift to 750px breakpoint (mobile ends ~750px)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:54:10 -05:00
adlee-was-taken
4c16147ace Set logo-row shift breakpoint to 900px to match actual layout break
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:51:40 -05:00
adlee-was-taken
cac1e26bac Split the difference: logo-row shift at 600px breakpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:49:21 -05:00
adlee-was-taken
31dcb70fc8 Bump logo-row shift breakpoint to 768px so mobile stays centered
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:47:31 -05:00
adlee-was-taken
15339d390f Use min-width breakpoint for logo shift, tighten logo-title gap
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:45:38 -05:00
adlee-was-taken
c523b144f5 Tighten logo-golfer gap and shift row further left on landscape
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:40:52 -05:00
adlee-was-taken
0f3ae992f9 Wrap logo+golfer in .logo-row and translateX left on landscape
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:36:00 -05:00
adlee-was-taken
ce6b276c11 Increase logo left shift to -3.5rem to align with GolfCards text
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:33:16 -05:00
adlee-was-taken
231e666407 Fix logo shift direction: move left on landscape, not right
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:31:38 -05:00
adlee-was-taken
7842de3a96 Shift logo+golfer group right on landscape via margin-left
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:29:47 -05:00
adlee-was-taken
aab41c5413 Restore logo-golfer-ball order in header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:23:49 -05:00
adlee-was-taken
625320992e Move golfer emoji left of logo, make .club inline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:22:53 -05:00
adlee-was-taken
61713f28c8 Style GolfCards title with .club on second line
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:13:40 -05:00
adlee-was-taken
0eac6d443c Rename lobby title from Golf to GolfCards.Club
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:11:39 -05:00
45 changed files with 4757 additions and 113 deletions

View File

@ -75,6 +75,15 @@ SECRET_KEY=
# Enable invite-only mode (requires invitation to register) # Enable invite-only mode (requires invitation to register)
INVITE_ONLY=true INVITE_ONLY=true
# Metered open signups (public beta)
# 0 = disabled (invite-only enforced), -1 = unlimited, N = max open signups per day
# When set > 0, users can register without an invite code up to the daily limit.
# Invite codes always work regardless of this limit.
DAILY_OPEN_SIGNUPS=0
# Max signups per IP address per day (0 = unlimited)
DAILY_SIGNUPS_PER_IP=3
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true) # Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
# Remove these after first login! # Remove these after first login!
# BOOTSTRAP_ADMIN_USERNAME=admin # BOOTSTRAP_ADMIN_USERNAME=admin

24
.gitignore vendored
View File

@ -136,7 +136,31 @@ celerybeat.pid
# Environments # Environments
.env .env
.env.*
!.env.example
.envrc .envrc
# Private keys and certificates
*.pem
*.key
*.p12
*.pfx
*.jks
*.keystore
# Service credentials
credentials.json
service-account.json
*-credentials.json
# SSH keys
id_rsa
id_ecdsa
id_ed25519
# Other sensitive files
*.secrets
.htpasswd
.venv .venv
env/ env/
venv/ venv/

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) { async enqueue(movements, onComplete) {
if (!movements || movements.length === 0) { if (!movements || movements.length === 0) {
if (onComplete) onComplete(); if (onComplete) onComplete();
return; return;
} }
// Add completion callback to last movement // Attach callback to last movement only
const movementsWithCallback = movements.map((m, i) => ({ const movementsWithCallback = movements.map((m, i) => ({
...m, ...m,
onComplete: i === movements.length - 1 ? onComplete : null onComplete: i === movements.length - 1 ? onComplete : null
@ -185,7 +188,9 @@ class AnimationQueue {
await this.delay(this.timing.flipDuration); await this.delay(this.timing.flipDuration);
} }
// Step 2: Quick crossfade swap // Step 2: Quick crossfade swap.
// 150ms is short enough to feel instant but long enough for the eye to
// register the transition. Shorter looks like a glitch, longer looks laggy.
handCard.classList.add('fade-out'); handCard.classList.add('fade-out');
heldCard.classList.add('fade-out'); heldCard.classList.add('fade-out');
await this.delay(150); await this.delay(150);

View File

@ -30,7 +30,14 @@ class GolfGame {
this.soundEnabled = true; this.soundEnabled = true;
this.audioCtx = null; 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.swapAnimationInProgress = false;
this.swapAnimationCardEl = null; this.swapAnimationCardEl = null;
this.swapAnimationFront = null; this.swapAnimationFront = null;
@ -44,19 +51,19 @@ class GolfGame {
// Animation lock - prevent overlapping animations on same elements // Animation lock - prevent overlapping animations on same elements
this.animatingPositions = new Set(); this.animatingPositions = new Set();
// 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 } 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; 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; this.localDiscardAnimating = false;
// Track opponent discard animation in progress (prevent renderGame from updating discard) // Blocks discard update: opponent discarding without swap
this.opponentDiscardAnimating = false; 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; this.dealAnimationInProgress = false;
// Track round winners for visual highlight // Track round winners for visual highlight
@ -74,6 +81,7 @@ class GolfGame {
this.initCardTooltips(); this.initCardTooltips();
this.bindEvents(); this.bindEvents();
this.initMobileDetection(); this.initMobileDetection();
this.initDesktopScorecard();
this.checkUrlParams(); this.checkUrlParams();
} }
@ -104,9 +112,11 @@ class GolfGame {
this.isMobile = e.matches; this.isMobile = e.matches;
document.body.classList.toggle('mobile-portrait', e.matches); document.body.classList.toggle('mobile-portrait', e.matches);
setAppHeight(); setAppHeight();
// Close any open drawers on layout change // Close any open drawers/overlays on layout change
if (!e.matches) { if (!e.matches) {
this.closeDrawers(); this.closeDrawers();
} else {
this.closeDesktopScorecard();
} }
}; };
mql.addEventListener('change', update); mql.addEventListener('change', update);
@ -147,6 +157,31 @@ class GolfGame {
if (bottomBar) bottomBar.classList.remove('hidden'); if (bottomBar) bottomBar.classList.remove('hidden');
} }
initDesktopScorecard() {
if (!this.desktopScorecardBtn) return;
this.desktopScorecardBtn.addEventListener('click', () => {
const isOpen = this.desktopScorecardOverlay.classList.contains('open');
if (isOpen) {
this.closeDesktopScorecard();
} else {
this.desktopScorecardOverlay.classList.add('open');
this.desktopScorecardBtn.classList.add('active');
this.desktopScorecardBackdrop.classList.add('visible');
}
});
this.desktopScorecardBackdrop.addEventListener('click', () => {
this.closeDesktopScorecard();
});
}
closeDesktopScorecard() {
if (this.desktopScorecardOverlay) this.desktopScorecardOverlay.classList.remove('open');
if (this.desktopScorecardBtn) this.desktopScorecardBtn.classList.remove('active');
if (this.desktopScorecardBackdrop) this.desktopScorecardBackdrop.classList.remove('visible');
}
initAudio() { initAudio() {
// Initialize audio context on first user interaction // Initialize audio context on first user interaction
const initCtx = () => { const initCtx = () => {
@ -537,6 +572,13 @@ class GolfGame {
this.gameUsername = document.getElementById('game-username'); this.gameUsername = document.getElementById('game-username');
this.gameLogoutBtn = document.getElementById('game-logout-btn'); this.gameLogoutBtn = document.getElementById('game-logout-btn');
this.authBar = document.getElementById('auth-bar'); this.authBar = document.getElementById('auth-bar');
// Desktop scorecard overlay elements
this.desktopScorecardBtn = document.getElementById('desktop-scorecard-btn');
this.desktopScorecardOverlay = document.getElementById('desktop-scorecard-overlay');
this.desktopScorecardBackdrop = document.getElementById('desktop-scorecard-backdrop');
this.desktopStandingsList = document.getElementById('desktop-standings-list');
this.desktopScoreTable = document.getElementById('desktop-score-table')?.querySelector('tbody');
} }
bindEvents() { bindEvents() {
@ -818,7 +860,11 @@ class GolfGame {
hasDrawn: newState.has_drawn_card hasDrawn: newState.has_drawn_card
}); });
// V3_03: Intercept round_over transition to defer card reveals // V3_03: Intercept round_over transition to defer card reveals.
// The problem: the last turn's swap animation flips a card, and then
// the round-end reveal animation would flip it again. We snapshot the
// old state, patch it to mark the swap position as already face-up,
// and use that as the "before" for the reveal animation.
const roundJustEnded = oldState?.phase !== 'round_over' && const roundJustEnded = oldState?.phase !== 'round_over' &&
newState.phase === 'round_over'; newState.phase === 'round_over';
@ -834,7 +880,8 @@ class GolfGame {
} }
// Build preRevealState from oldState, but mark swap position as // 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)); const preReveal = JSON.parse(JSON.stringify(oldState));
if (this.opponentSwapAnimation) { if (this.opponentSwapAnimation) {
const { playerId, position } = this.opponentSwapAnimation; const { playerId, position } = this.opponentSwapAnimation;
@ -1332,14 +1379,16 @@ class GolfGame {
this.heldCardFloating.classList.add('hidden'); this.heldCardFloating.classList.add('hidden');
this.heldCardFloating.style.cssText = ''; this.heldCardFloating.style.cssText = '';
// Pre-emptively skip the flip animation - the server may broadcast the new state // Three-part race guard. All three are needed, and they protect different things:
// before our animation completes, and we don't want renderGame() to trigger // 1. skipNextDiscardFlip: prevents the CSS flip-in animation from firing
// the flip-in animation (which starts with opacity: 0, causing a flash) // (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; this.skipNextDiscardFlip = true;
// Also update lastDiscardKey so renderGame() won't see a "change"
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`; this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
// Block renderGame from updating discard during animation (prevents race condition)
this.localDiscardAnimating = true; this.localDiscardAnimating = true;
// Animate held card to discard using anime.js // Animate held card to discard using anime.js
@ -1450,12 +1499,8 @@ class GolfGame {
this.swapAnimationCardEl = handCardEl; this.swapAnimationCardEl = handCardEl;
this.swapAnimationHandCardEl = handCardEl; this.swapAnimationHandCardEl = handCardEl;
// Hide originals and UI during animation // Hide discard button during animation (held card hidden later by onStart)
handCardEl.classList.add('swap-out');
this.discardBtn.classList.add('hidden'); this.discardBtn.classList.add('hidden');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
// Store drawn card data before clearing // Store drawn card data before clearing
const drawnCardData = this.drawnCard; const drawnCardData = this.drawnCard;
@ -1478,6 +1523,12 @@ class GolfGame {
{ {
rotation: 0, rotation: 0,
wasHandFaceDown: false, wasHandFaceDown: false,
onStart: () => {
handCardEl.classList.add('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
},
onComplete: () => { onComplete: () => {
handCardEl.classList.remove('swap-out'); handCardEl.classList.remove('swap-out');
if (this.heldCardFloating) { if (this.heldCardFloating) {
@ -1541,6 +1592,12 @@ class GolfGame {
{ {
rotation: 0, rotation: 0,
wasHandFaceDown: true, wasHandFaceDown: true,
onStart: () => {
if (handCardEl) handCardEl.classList.add('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
},
onComplete: () => { onComplete: () => {
if (handCardEl) handCardEl.classList.remove('swap-out'); if (handCardEl) handCardEl.classList.remove('swap-out');
if (this.heldCardFloating) { if (this.heldCardFloating) {
@ -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) { triggerAnimationsForStateChange(oldState, newState) {
if (!oldState) return; if (!oldState) return;
@ -2522,18 +2585,18 @@ class GolfGame {
} }
// STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances) // STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances)
// Skip if we just detected a draw - 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) { if (discardChanged && wasOtherPlayer && !justDetectedDraw) {
// Check if the previous player actually SWAPPED (has a new face-up card) // Figure out if the previous player SWAPPED (a card in their hand changed)
// vs just discarding the drawn card (no hand change) // 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 oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
const newPlayer = newState.players.find(p => p.id === previousPlayerId); const newPlayer = newState.players.find(p => p.id === previousPlayerId);
if (oldPlayer && newPlayer) { if (oldPlayer && newPlayer) {
// Find the position that changed
// Could be: face-down -> face-up (new reveal)
// Or: different card at same position (replaced visible card)
// Or: card identity became known (null -> value, indicates swap)
let swappedPosition = -1; let swappedPosition = -1;
let wasFaceUp = false; // Track if old card was already face-up let wasFaceUp = false; // Track if old card was already face-up
@ -2867,9 +2930,6 @@ class GolfGame {
return; return;
} }
// Hide the source card during animation
sourceCardEl.classList.add('swap-out');
// Use unified swap animation // Use unified swap animation
if (window.cardAnimations) { if (window.cardAnimations) {
const heldRect = window.cardAnimations.getHoldingRect(); const heldRect = window.cardAnimations.getHoldingRect();
@ -2882,6 +2942,9 @@ class GolfGame {
{ {
rotation: sourceRotation, rotation: sourceRotation,
wasHandFaceDown: !wasFaceUp, wasHandFaceDown: !wasFaceUp,
onStart: () => {
sourceCardEl.classList.add('swap-out');
},
onComplete: () => { onComplete: () => {
if (sourceCardEl) sourceCardEl.classList.remove('swap-out'); if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
@ -3993,7 +4056,11 @@ class GolfGame {
// Not holding - show normal discard pile // Not holding - show normal discard pile
this.discard.classList.remove('picked-up'); this.discard.classList.remove('picked-up');
// 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' : const skipReason = this.localDiscardAnimating ? 'localDiscardAnimating' :
this.opponentSwapAnimation ? 'opponentSwapAnimation' : this.opponentSwapAnimation ? 'opponentSwapAnimation' :
this.opponentDiscardAnimating ? 'opponentDiscardAnimating' : this.opponentDiscardAnimating ? 'opponentDiscardAnimating' :
@ -4017,7 +4084,9 @@ class GolfGame {
const discardCard = this.gameState.discard_top; const discardCard = this.gameState.discard_top;
const cardKey = `${discardCard.rank}-${discardCard.suit}`; const cardKey = `${discardCard.rank}-${discardCard.suit}`;
// Only animate discard flip during active gameplay, not at round/game end // Only animate discard flip during active gameplay, not at round/game end.
// lastDiscardKey is pre-set by discardDrawn() to prevent a false "change"
// detection when the server confirms what we already animated locally.
const isActivePlay = this.gameState.phase !== 'round_over' && const isActivePlay = this.gameState.phase !== 'round_over' &&
this.gameState.phase !== 'game_over'; this.gameState.phase !== 'game_over';
const shouldAnimate = isActivePlay && this.lastDiscardKey && const shouldAnimate = isActivePlay && this.lastDiscardKey &&
@ -4321,6 +4390,11 @@ class GolfGame {
`; `;
this.scoreTable.appendChild(tr); this.scoreTable.appendChild(tr);
}); });
// Mirror to desktop overlay
if (this.desktopScoreTable) {
this.desktopScoreTable.innerHTML = this.scoreTable.innerHTML;
}
} }
updateStandings() { updateStandings() {
@ -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>`; return `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won} wins</span></div>`;
}).join(''); }).join('');
this.standingsList.innerHTML = ` const standingsContent = `
<div class="standings-section"> <div class="standings-section">
<div class="standings-title">By Score</div> <div class="standings-title">By Score</div>
${pointsHtml} ${pointsHtml}
@ -4368,6 +4442,10 @@ class GolfGame {
${holesHtml} ${holesHtml}
</div> </div>
`; `;
this.standingsList.innerHTML = standingsContent;
if (this.desktopStandingsList) {
this.desktopStandingsList.innerHTML = standingsContent;
}
} }
renderCard(card, clickable, selected) { renderCard(card, clickable, selected) {
@ -4447,6 +4525,11 @@ class GolfGame {
this.scoreTable.appendChild(tr); this.scoreTable.appendChild(tr);
}); });
// Mirror to desktop overlay
if (this.desktopScoreTable) {
this.desktopScoreTable.innerHTML = this.scoreTable.innerHTML;
}
// Show rankings announcement only for final results // Show rankings announcement only for final results
const existingAnnouncement = document.getElementById('rankings-announcement'); const existingAnnouncement = document.getElementById('rankings-announcement');
if (existingAnnouncement) existingAnnouncement.remove(); if (existingAnnouncement) existingAnnouncement.remove();
@ -4762,11 +4845,14 @@ class AuthManager {
this.signupFormContainer = document.getElementById('signup-form-container'); this.signupFormContainer = document.getElementById('signup-form-container');
this.signupForm = document.getElementById('signup-form'); this.signupForm = document.getElementById('signup-form');
this.signupInviteCode = document.getElementById('signup-invite-code'); this.signupInviteCode = document.getElementById('signup-invite-code');
this.inviteCodeGroup = document.getElementById('invite-code-group');
this.inviteCodeHint = document.getElementById('invite-code-hint');
this.signupUsername = document.getElementById('signup-username'); this.signupUsername = document.getElementById('signup-username');
this.signupEmail = document.getElementById('signup-email'); this.signupEmail = document.getElementById('signup-email');
this.signupPassword = document.getElementById('signup-password'); this.signupPassword = document.getElementById('signup-password');
this.signupError = document.getElementById('signup-error'); this.signupError = document.getElementById('signup-error');
this.showSignupLink = document.getElementById('show-signup'); this.showSignupLink = document.getElementById('show-signup');
this.signupInfo = null; // populated by fetchSignupInfo()
this.showLoginLink = document.getElementById('show-login'); this.showLoginLink = document.getElementById('show-login');
this.showForgotLink = document.getElementById('show-forgot'); this.showForgotLink = document.getElementById('show-forgot');
this.forgotFormContainer = document.getElementById('forgot-form-container'); this.forgotFormContainer = document.getElementById('forgot-form-container');
@ -4815,6 +4901,9 @@ class AuthManager {
// Check URL for reset token or invite code on page load // Check URL for reset token or invite code on page load
this.checkResetToken(); this.checkResetToken();
this.checkInviteCode(); this.checkInviteCode();
// Fetch signup availability info (metered open signups)
this.fetchSignupInfo();
} }
showModal(form = 'login') { showModal(form = 'login') {
@ -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) { async handleForgotPassword(e) {
e.preventDefault(); e.preventDefault();
this.clearErrors(); this.clearErrors();

View File

@ -43,10 +43,14 @@ class CardAnimations {
const discardRect = this.getDiscardRect(); const discardRect = this.getDiscardRect();
if (!deckRect || !discardRect) return null; if (!deckRect || !discardRect) return null;
// Center the held card between deck and discard pile
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4; const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width; const cardWidth = deckRect.width;
const cardHeight = deckRect.height; const cardHeight = deckRect.height;
const isMobilePortrait = document.body.classList.contains('mobile-portrait'); const 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); const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
return { 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: () => {} }); this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} });
} catch (e) { } catch (e) {
console.error('Initial flip animation error:', e); console.error('Initial flip animation error:', e);
@ -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(() => { const timeout = setTimeout(() => {
if (!this.activeAnimations.has(id)) return; if (!this.activeAnimations.has(id)) return;
doShake(); doShake();
@ -1094,7 +1105,7 @@ class CardAnimations {
// heldRect: position of the held card (or null to use default holding position) // heldRect: position of the held card (or null to use default holding position)
// options: { rotation, wasHandFaceDown, onComplete } // options: { rotation, wasHandFaceDown, onComplete }
animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) { animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) {
const { rotation = 0, wasHandFaceDown = false, onComplete } = options; const { rotation = 0, wasHandFaceDown = false, onComplete, onStart } = options;
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 }; const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
const discardRect = this.getDiscardRect(); const discardRect = this.getDiscardRect();
@ -1114,27 +1125,27 @@ class CardAnimations {
return; return;
} }
// Wait for any in-progress draw animation to complete // Collision detection: if a draw animation is still in flight (its overlay cards
// Check if there's an active draw animation by looking for 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"]'); const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]');
if (existingDrawCards.length > 0) { if (existingDrawCards.length > 0) {
// Draw animation still in progress - wait a bit and retry
setTimeout(() => { setTimeout(() => {
// Clean up the draw animation overlay
existingDrawCards.forEach(el => { existingDrawCards.forEach(el => {
delete el.dataset.animating; delete el.dataset.animating;
el.remove(); el.remove();
}); });
// Now run the swap animation this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart);
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
}, 350); }, 350);
return; 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 // Create the two traveling cards
const travelingHand = this.createCardFromData(handCardData, handRect, rotation); const travelingHand = this.createCardFromData(handCardData, handRect, rotation);
const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0); const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0);
@ -1143,6 +1154,9 @@ class CardAnimations {
document.body.appendChild(travelingHand); document.body.appendChild(travelingHand);
document.body.appendChild(travelingHeld); document.body.appendChild(travelingHeld);
// Now that overlays cover the originals, hide them
if (onStart) onStart();
this.playSound('card'); this.playSound('card');
// If hand card was face-down, flip it first // If hand card was face-down, flip it first
@ -1208,6 +1222,9 @@ class CardAnimations {
], ],
width: discardRect.width, width: discardRect.width,
height: discardRect.height, height: discardRect.height,
// Counter-rotate from the card's grid tilt back to 0. The -3 intermediate
// value adds a slight overshoot that makes the arc feel physical.
// Do not "simplify" this to [rotation, 0]. It will look robotic.
rotate: [rotation, rotation - 3, 0], rotate: [rotation, rotation - 3, 0],
duration: T.arc, duration: T.arc,
easing: this.getEasing('arc'), easing: this.getEasing('arc'),

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) { getDeckColorClass(cardData) {
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) { if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
return null; return null;
} }
// Get deck colors from game state (set by app.js)
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold']; const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red'; const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
return `deck-${colorName}`; return `deck-${colorName}`;
@ -126,7 +128,10 @@ class CardManager {
cardEl.style.width = `${rect.width}px`; cardEl.style.width = `${rect.width}px`;
cardEl.style.height = `${rect.height}px`; cardEl.style.height = `${rect.height}px`;
// On mobile, scale font proportional to card width so rank/suit fit // On mobile, scale font proportional to card width so rank/suit fit.
// This must stay in sync with the CSS .card font-size on desktop — if CSS
// sets a fixed size and we set an inline style, the inline wins. Clearing
// fontSize on desktop lets the CSS rule take over.
if (document.body.classList.contains('mobile-portrait')) { if (document.body.classList.contains('mobile-portrait')) {
cardEl.style.fontSize = `${rect.width * 0.35}px`; cardEl.style.fontSize = `${rect.width * 0.35}px`;
} else { } else {
@ -235,7 +240,9 @@ class CardManager {
await this.delay(flipDuration); await this.delay(flipDuration);
} }
// Step 2: Move card to discard // Step 2: Move card to discard.
// The +50ms buffer accounts for CSS transition timing jitter — without it,
// we occasionally remove the 'moving' class before the transition finishes.
cardEl.classList.add('moving'); cardEl.classList.add('moving');
this.positionCard(cardEl, discardRect); this.positionCard(cardEl, discardRect);
await this.delay(duration + 50); await this.delay(duration + 50);

View File

@ -59,9 +59,9 @@
<!-- Outer edge highlight --> <!-- Outer edge highlight -->
<circle cx="50" cy="44" r="46" fill="none" stroke="#ffffff" stroke-width="0.5" opacity="0.5"/> <circle cx="50" cy="44" r="46" fill="none" stroke="#ffffff" stroke-width="0.5" opacity="0.5"/>
<!-- Card suits - single row, larger --> <!-- Card suits - 2x2 grid -->
<text x="22" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">&#9827;</text> <text x="36" y="40" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#1a1a1a" text-anchor="middle">&#9827;</text>
<text x="41" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">&#9830;</text> <text x="64" y="40" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#cc0000" text-anchor="middle">&#9830;</text>
<text x="59" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">&#9824;</text> <text x="36" y="64" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#cc0000" text-anchor="middle">&#9829;</text>
<text x="77" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">&#9829;</text> <text x="64" y="64" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#1a1a1a" text-anchor="middle">&#9824;</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -16,7 +16,7 @@
<!-- Lobby Screen --> <!-- Lobby Screen -->
<div id="lobby-screen" class="screen active"> <div id="lobby-screen" class="screen active">
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span> <span class="golf-title">Golf</span></h1> <h1><span class="logo-row"><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-container"><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span></span></span> <span class="golf-title">GolfCards<span class="golf-title-tld">.club</span></span></h1>
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p> <p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
<div class="alpha-banner">Beta Testing - Bear with us while, stuff.</div> <div class="alpha-banner">Beta Testing - Bear with us while, stuff.</div>
@ -54,7 +54,7 @@
<p id="lobby-error" class="error"></p> <p id="lobby-error" class="error"></p>
<footer class="app-footer">v3.1.5 &copy; Aaron D. Lee</footer> <footer class="app-footer">v3.1.6 &copy; Aaron D. Lee</footer>
</div> </div>
<!-- Matchmaking Screen --> <!-- Matchmaking Screen -->
@ -287,7 +287,7 @@
<p id="waiting-message" class="info">Waiting for host to start the game...</p> <p id="waiting-message" class="info">Waiting for host to start the game...</p>
</div> </div>
<footer class="app-footer">v3.1.5 &copy; Aaron D. Lee</footer> <footer class="app-footer">v3.1.6 &copy; Aaron D. Lee</footer>
</div> </div>
<!-- Game Screen --> <!-- Game Screen -->
@ -893,8 +893,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div id="signup-form-container" class="hidden"> <div id="signup-form-container" class="hidden">
<h3>Sign Up</h3> <h3>Sign Up</h3>
<form id="signup-form"> <form id="signup-form">
<div class="form-group"> <div class="form-group" id="invite-code-group">
<input type="text" id="signup-invite-code" placeholder="Invite Code" required> <input type="text" id="signup-invite-code" placeholder="Invite Code">
<small id="invite-code-hint" class="form-hint"></small>
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20"> <input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">

View File

@ -42,7 +42,7 @@ body {
/* Lobby Screen */ /* Lobby Screen */
#lobby-screen { #lobby-screen {
max-width: 400px; max-width: 550px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
text-align: center; text-align: center;
@ -59,7 +59,8 @@ body {
/* Golf title - golf ball with dimples and shine */ /* Golf title - golf ball with dimples and shine */
.golf-title { .golf-title {
font-size: 1.3em; display: block;
font-size: 1.05em;
font-weight: 800; font-weight: 800;
letter-spacing: 0.02em; letter-spacing: 0.02em;
/* Shiny gradient like a golf ball surface */ /* Shiny gradient like a golf ball surface */
@ -86,15 +87,72 @@ body {
filter: drop-shadow(1px 1px 1px rgba(0,0,0,0.15)); filter: drop-shadow(1px 1px 1px rgba(0,0,0,0.15));
} }
.golf-title-tld {
font-size: 0.45em;
font-weight: 600;
letter-spacing: 0.15em;
}
/* Golf ball logo with card suits */ /* Golf ball logo with card suits */
.golfball-logo { .golfball-logo {
width: 1.1em; width: 1.1em;
height: 1.1em; height: 1.1em;
vertical-align: middle; vertical-align: middle;
margin-right: 18px; margin-right: 8px;
filter: drop-shadow(1px 2px 2px rgba(0,0,0,0.25)); filter: drop-shadow(1px 2px 2px rgba(0,0,0,0.25));
} }
#lobby-screen h1 {
display: inline-grid;
grid-template-columns: auto;
justify-items: start;
text-align: left;
row-gap: 0;
}
.logo-row {
margin-bottom: 0;
}
/* Golfer + ball container */
.golfer-container {
position: relative;
display: inline-block;
margin-left: -2px;
}
#lobby-game-controls,
#auth-prompt {
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
@media (max-width: 500px) {
#lobby-screen h1 {
display: block;
text-align: center;
text-indent: -18px;
}
.logo-row {
display: inline;
margin-bottom: 0;
}
.golf-title {
display: inline;
}
.golfball-logo {
margin-right: 6px;
}
.golfer-swing {
margin-left: 0;
margin-right: 10px;
}
.golfer-container {
margin-left: 15px;
}
}
/* Golfer swing animation */ /* Golfer swing animation */
.golfer-swing { .golfer-swing {
display: inline-block; display: inline-block;
@ -133,44 +191,46 @@ body {
.kicked-ball { .kicked-ball {
display: inline-block; display: inline-block;
font-size: 0.2em; font-size: 0.2em;
position: relative; position: absolute;
right: -8px;
bottom: 30%;
opacity: 0; opacity: 0;
animation: ball-kicked 0.7s linear forwards; animation: ball-kicked 0.7s linear forwards;
animation-delay: 0.72s; animation-delay: 0.72s;
} }
/* Trajectory: y = 0.0124x² - 1.42x (parabola with peak at x=57) */ /* Trajectory: parabolic arc from golfer's front foot, up and to the right */
@keyframes ball-kicked { @keyframes ball-kicked {
0% { 0% {
transform: translate(-12px, 8px) scale(1); transform: translate(0, 0) scale(1);
opacity: 1; opacity: 1;
} }
15% { 15% {
transform: translate(8px, -16px) scale(1); transform: translate(20px, -24px) scale(1);
opacity: 1; opacity: 1;
} }
30% { 30% {
transform: translate(28px, -31px) scale(1); transform: translate(40px, -39px) scale(1);
opacity: 1; opacity: 1;
} }
45% { 45% {
transform: translate(48px, -38px) scale(0.95); transform: translate(60px, -46px) scale(0.95);
opacity: 1; opacity: 1;
} }
55% { 55% {
transform: translate(63px, -38px) scale(0.9); transform: translate(75px, -46px) scale(0.9);
opacity: 1; opacity: 1;
} }
70% { 70% {
transform: translate(83px, -27px) scale(0.85); transform: translate(95px, -35px) scale(0.85);
opacity: 0.9; opacity: 0.9;
} }
85% { 85% {
transform: translate(103px, -6px) scale(0.75); transform: translate(115px, -14px) scale(0.75);
opacity: 0.6; opacity: 0.6;
} }
100% { 100% {
transform: translate(118px, 25px) scale(0.65); transform: translate(130px, 17px) scale(0.65);
opacity: 0; opacity: 0;
} }
} }
@ -3545,6 +3605,13 @@ input::placeholder {
width: 100%; width: 100%;
} }
.form-hint {
display: block;
margin-top: 4px;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.45);
}
.auth-switch { .auth-switch {
text-align: center; text-align: center;
margin-top: 15px; margin-top: 15px;

View File

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

View File

@ -29,6 +29,8 @@ services:
- BASE_URL=${BASE_URL:-https://staging.golfcards.club} - BASE_URL=${BASE_URL:-https://staging.golfcards.club}
- RATE_LIMIT_ENABLED=false - RATE_LIMIT_ENABLED=false
- INVITE_ONLY=true - INVITE_ONLY=true
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-} - BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-} - BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
- MATCHMAKING_ENABLED=true - MATCHMAKING_ENABLED=true

View File

@ -0,0 +1,77 @@
# BUG: Kicked ball animation starts from golfer's back foot
## Problem
The `⚪` kicked ball animation (`.kicked-ball`) appears to launch from the golfer's **back foot** (left side) instead of the **front foot** (right side). The golfer faces right in both landscape (two-row) and mobile (single-line) views due to `scaleX(-1)`.
## What we want
The ball should appear at the golfer's front foot (right side) and arc up and to the right — matching the "good" landscape behavior seen at wide desktop widths (~1100px+).
## Good reference
- Video: `good.mp4` (landscape wide view)
- Extracted frames: `/tmp/golf-frames-good/`
- Frame 025: Ball clearly appears to the RIGHT of the golfer, arcing up-right
## Bad behavior
- Videos: `Screencast_20260224_005555.mp4`, `Screencast_20260224_013326.mp4`
- The ball appears to the LEFT of the golfer (between the golf ball logo and golfer emoji)
- Happens at the user's phone viewport width (two-row layout, inline-grid)
## Root cause analysis
### The scaleX(-1) offset problem
The golfer emoji (`.golfer-swing`) has `transform: scaleX(-1)` which flips it visually. This means:
- The golfer's **layout box** occupies the same inline flow position
- But the **visual** left/right is flipped — the front foot (visually on the right) is at the LEFT edge of the layout box
- The `.kicked-ball` span comes right after `.golfer-swing` in inline flow, so its natural position is at the **right edge** of the golfer's layout box
- But due to `scaleX(-1)`, the right edge of the layout box is the golfer's **visual back** (left side)
- So `translate(0, 0)` places the ball at the golfer's back, not front
### CSS translate values tested
| Start X | Result |
|---------|--------|
| `-30px` (original) | Ball appears way behind golfer (further left) |
| `+20px` | Ball still appears to LEFT of golfer, but slightly closer |
| `+80px` | Not confirmed (staging 404 during test) |
### Key finding: The kicked-ball's natural position needs ~60-80px positive X offset to reach the golfer's visual front foot
The golfer emoji is roughly 30-40px wide at this viewport. Since `scaleX(-1)` flips the visual, the ball needs to translate **past the entire emoji width** to reach the visual front.
### Media query issues encountered
1. First attempt: Added `ball-kicked-mobile` keyframes with `@media (max-width: 500px)` override
2. **CSS source order bug**: The mobile override at line 144 was being overridden by the base `.kicked-ball` rule at line 216 (later = higher priority at equal specificity)
3. Moved override after base rule — still didn't work
4. Added `!important` — still didn't work
5. Raised breakpoint from 500px to 768px, then 1200px — still no visible change
6. **Breakthrough**: Added `outline: 3px solid red; background: yellow` debug styles to base `.kicked-ball` — these DID appear, confirming CSS was loading
7. Changed base `ball-kicked` keyframes from `-30px` to `+20px` — ball DID move, confirming the base keyframes are what's being used
8. The mobile override keyframes may never have been applied (unclear if `ball-kicked-mobile` was actually used)
### What the Chrome extension Claude analysis said
> "The breakpoint is 500px, but the viewport is above 500px. At 700px+, ball-kicked-mobile never kicks in — it still uses the desktop ball-kicked animation. But the layout at this width has already shifted to a more centered layout which changes where .kicked-ball is positioned relative to the golfer."
## Suggested fix approach
1. **Don't use separate mobile keyframes** — just fix the base `ball-kicked` to work at all viewport widths
2. The starting X needs to be **much larger positive** (60-80px) to account for `scaleX(-1)` placing the natural position at the golfer's visual back
3. Alternatively, restructure the HTML: move `.kicked-ball` BEFORE `.golfer-swing` in the DOM, so its natural inline position is at the golfer's visual front (since scaleX(-1) flips left/right)
4. Or use `position: absolute` on `.kicked-ball` and position it relative to the golfer container explicitly
## Files involved
- `client/style.css``.kicked-ball`, `@keyframes ball-kicked`, `.golfer-swing`
- `client/index.html` — line 19: `<span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span>`
## Resolution (v3.1.6)
**Fixed** by wrapping `.golfer-swing` + `.kicked-ball` in a `.golfer-container` span with `position: relative`, and changing `.kicked-ball` from `position: relative` to `position: absolute; right: -8px; bottom: 30%`. This anchors the ball to the golfer's front foot regardless of viewport width or inline flow layout.
Also fixed a **CSS source order bug** where the base `.golfer-container` rule was defined after the `@media (max-width: 500px)` override, clobbering the mobile margin-left value.

View File

@ -1,6 +1,6 @@
# V3.17: Mobile Portrait Layout # V3.17: Mobile Portrait Layout
**Version:** 3.1.1 **Version:** 3.1.6
**Commits:** `4fcdf13`, `fb3bd53` **Commits:** `4fcdf13`, `fb3bd53`
## Overview ## Overview

View File

@ -1,6 +1,6 @@
[project] [project]
name = "golfgame" name = "golfgame"
version = "3.1.1" version = "3.1.6"
description = "6-Card Golf card game with AI opponents" description = "6-Card Golf card game with AI opponents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@ -55,17 +55,15 @@ CPU_TIMING = {
"post_action_pause": (0.5, 0.7), "post_action_pause": (0.5, 0.7),
} }
# Thinking time ranges by card difficulty (seconds) # Thinking time ranges by card difficulty (seconds).
# Yes, these are all identical. That's intentional — the categories exist so we
# CAN tune them independently later, but right now a uniform 0.15-0.3s feels
# natural enough. The structure is the point, not the current values.
THINKING_TIME = { THINKING_TIME = {
# Obviously good cards (Jokers, Kings, 2s, Aces) - easy take
"easy_good": (0.15, 0.3), "easy_good": (0.15, 0.3),
# Obviously bad cards (10s, Jacks, Queens) - easy pass
"easy_bad": (0.15, 0.3), "easy_bad": (0.15, 0.3),
# Medium difficulty (3, 4, 8, 9)
"medium": (0.15, 0.3), "medium": (0.15, 0.3),
# Hardest decisions (5, 6, 7 - middle of range)
"hard": (0.15, 0.3), "hard": (0.15, 0.3),
# No discard available - quick decision
"no_card": (0.15, 0.3), "no_card": (0.15, 0.3),
} }
@ -800,7 +798,9 @@ class GolfAI:
ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)") ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)")
return True return True
# Take card if it could make a column pair (but NOT for negative value cards) # Take card if it could make a column pair (but NOT for negative value cards).
# Why exclude negatives: a Joker (-2) paired in a column scores 0, which is
# worse than keeping it unpaired at -2. Same logic for 2s with default values.
if discard_value > 0: if discard_value > 0:
for i, card in enumerate(player.cards): for i, card in enumerate(player.cards):
pair_pos = (i + 3) % 6 if i < 3 else i - 3 pair_pos = (i + 3) % 6 if i < 3 else i - 3
@ -1031,7 +1031,11 @@ class GolfAI:
if not creates_negative_pair: if not creates_negative_pair:
expected_hidden = EXPECTED_HIDDEN_VALUE expected_hidden = EXPECTED_HIDDEN_VALUE
point_gain = expected_hidden - drawn_value point_gain = expected_hidden - drawn_value
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 point_gain * discount
return 0.0 return 0.0
@ -1252,8 +1256,6 @@ class GolfAI:
"""If player has exactly 1 face-down card, decide the best go-out swap. """If player has exactly 1 face-down card, decide the best go-out swap.
Returns position to swap into, or None to fall through to normal scoring. Returns position to swap into, or None to fall through to normal scoring.
Uses a sentinel value of -1 (converted to None by caller) is not needed -
we return None to indicate "no early decision, continue normal flow".
""" """
options = game.options options = game.options
face_down_positions = hidden_positions(player) face_down_positions = hidden_positions(player)
@ -1361,7 +1363,11 @@ class GolfAI:
if not face_down or random.random() >= 0.5: if not face_down or random.random() >= 0.5:
return None return None
# SAFETY: Don't randomly go out with a bad score # SAFETY: Don't randomly go out with a bad score.
# This duplicates some logic from project_score() on purpose — project_score()
# is designed for strategic decisions with weighted estimates, but here we need
# a hard pass/fail check with exact pair math. Close enough isn't good enough
# when the downside is accidentally ending the round at 30 points.
if len(face_down) == 1: if len(face_down) == 1:
last_pos = face_down[0] last_pos = face_down[0]
projected = drawn_value projected = drawn_value
@ -1965,7 +1971,8 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga
async def process_cpu_turn( async def process_cpu_turn(
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None,
reveal_callback=None,
) -> None: ) -> None:
"""Process a complete turn for a CPU player. """Process a complete turn for a CPU player.
@ -2083,6 +2090,13 @@ async def process_cpu_turn(
if swap_pos is not None: if swap_pos is not None:
old_card = cpu_player.cards[swap_pos] old_card = cpu_player.cards[swap_pos]
# Reveal the face-down card before swapping
if not old_card.face_up and reveal_callback:
await reveal_callback(
cpu_player.id, swap_pos,
{"rank": old_card.rank.value, "suit": old_card.suit.value},
)
await asyncio.sleep(1.0)
game.swap_card(cpu_player.id, swap_pos) game.swap_card(cpu_player.id, swap_pos)
_log_cpu_action(logger, game_id, cpu_player, game, _log_cpu_action(logger, game_id, cpu_player, game,
action="swap", card=drawn, position=swap_pos, action="swap", card=drawn, position=swap_pos,

View File

@ -142,11 +142,18 @@ class ServerConfig:
MAX_PLAYERS_PER_ROOM: int = 6 MAX_PLAYERS_PER_ROOM: int = 6
ROOM_TIMEOUT_MINUTES: int = 60 ROOM_TIMEOUT_MINUTES: int = 60
ROOM_CODE_LENGTH: int = 4 ROOM_CODE_LENGTH: int = 4
ROOM_IDLE_TIMEOUT_SECONDS: int = 300 # 5 minutes of inactivity
# Security (for future auth system) # Security (for future auth system)
SECRET_KEY: str = "" SECRET_KEY: str = ""
INVITE_ONLY: bool = True INVITE_ONLY: bool = True
# Metered open signups (public beta)
# 0 = disabled (invite-only), -1 = unlimited, N = max per day
DAILY_OPEN_SIGNUPS: int = 0
# Max signups per IP per day (0 = unlimited)
DAILY_SIGNUPS_PER_IP: int = 3
# Bootstrap admin (for first-time setup when INVITE_ONLY=true) # Bootstrap admin (for first-time setup when INVITE_ONLY=true)
BOOTSTRAP_ADMIN_USERNAME: str = "" BOOTSTRAP_ADMIN_USERNAME: str = ""
BOOTSTRAP_ADMIN_PASSWORD: str = "" BOOTSTRAP_ADMIN_PASSWORD: str = ""
@ -192,8 +199,11 @@ class ServerConfig:
MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6), MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6),
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60), ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4), ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
ROOM_IDLE_TIMEOUT_SECONDS=get_env_int("ROOM_IDLE_TIMEOUT_SECONDS", 300),
SECRET_KEY=get_env("SECRET_KEY", ""), SECRET_KEY=get_env("SECRET_KEY", ""),
INVITE_ONLY=get_env_bool("INVITE_ONLY", True), INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
DAILY_OPEN_SIGNUPS=get_env_int("DAILY_OPEN_SIGNUPS", 0),
DAILY_SIGNUPS_PER_IP=get_env_int("DAILY_SIGNUPS_PER_IP", 3),
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""), BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""), BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True), MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),

View File

@ -358,6 +358,13 @@ class Player:
jack_pairs = 0 # Track paired Jacks for Wolfpack bonus jack_pairs = 0 # Track paired Jacks for Wolfpack bonus
paired_ranks: list[Rank] = [] # Track all paired ranks for four-of-a-kind paired_ranks: list[Rank] = [] # Track all paired ranks for four-of-a-kind
# Evaluation order matters here. We check special-case pairs BEFORE the
# default "pairs cancel to 0" rule, because house rules can override that:
# 1. Eagle Eye joker pairs -> -4 (better than 0, exit early)
# 2. Negative pairs keep value -> sum of negatives (worse than 0, exit early)
# 3. Normal pairs -> 0 (skip both cards)
# 4. Non-matching -> sum both values
# Bonuses (wolfpack, four-of-a-kind) are applied after all columns are scored.
for col in range(3): for col in range(3):
top_idx = col top_idx = col
bottom_idx = col + 3 bottom_idx = col + 3
@ -775,9 +782,17 @@ class Game:
for i, player in enumerate(self.players): for i, player in enumerate(self.players):
if player.id == player_id: if player.id == player_id:
removed = self.players.pop(i) removed = self.players.pop(i)
if self.players:
# Adjust dealer_idx if needed after removal # Adjust dealer_idx if needed after removal
if self.players and self.dealer_idx >= len(self.players): if self.dealer_idx >= len(self.players):
self.dealer_idx = 0 self.dealer_idx = 0
# Adjust current_player_index after removal
if i < self.current_player_index:
# Removed player was before current: shift back
self.current_player_index -= 1
elif self.current_player_index >= len(self.players):
# Removed player was at/after current and index is now OOB
self.current_player_index = 0
self._emit("player_left", player_id=player_id, reason=reason) self._emit("player_left", player_id=player_id, reason=reason)
return removed return removed
return None return None
@ -800,6 +815,8 @@ class Game:
def current_player(self) -> Optional[Player]: def current_player(self) -> Optional[Player]:
"""Get the player whose turn it currently is.""" """Get the player whose turn it currently is."""
if self.players: if self.players:
if self.current_player_index >= len(self.players):
self.current_player_index = self.current_player_index % len(self.players)
return self.players[self.current_player_index] return self.players[self.current_player_index]
return None return None
@ -932,7 +949,8 @@ class Game:
if self.current_round > 1: if self.current_round > 1:
self.dealer_idx = (self.dealer_idx + 1) % len(self.players) self.dealer_idx = (self.dealer_idx + 1) % len(self.players)
# 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) self.current_player_index = (self.dealer_idx + 1) % len(self.players)
# Emit round_started event with deck seed and all dealt cards # Emit round_started event with deck seed and all dealt cards
@ -1415,6 +1433,9 @@ class Game:
Args: Args:
player: The player whose turn just ended. player: The player whose turn just ended.
""" """
# This method and _next_turn() are tightly coupled. _check_end_turn populates
# players_with_final_turn BEFORE calling _next_turn(), which reads it to decide
# whether the round is over. Reordering these calls will break end-of-round logic.
if player.all_face_up() and self.finisher_id is None: if player.all_face_up() and self.finisher_id is None:
self.finisher_id = player.id self.finisher_id = player.id
self.phase = GamePhase.FINAL_TURN self.phase = GamePhase.FINAL_TURN
@ -1431,7 +1452,8 @@ class Game:
Advance to the next player's turn. Advance to the next player's turn.
In FINAL_TURN phase, tracks which players have had their final turn In FINAL_TURN phase, tracks which players have had their final turn
and ends the round when everyone has played. 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: if self.phase == GamePhase.FINAL_TURN:
next_index = (self.current_player_index + 1) % len(self.players) next_index = (self.current_player_index + 1) % len(self.players)
@ -1474,6 +1496,10 @@ class Game:
player.calculate_score(self.options) player.calculate_score(self.options)
# --- Apply House Rule Bonuses/Penalties --- # --- Apply House Rule Bonuses/Penalties ---
# Order matters. Blackjack converts 21->0 first, so knock penalty checks
# against the post-blackjack score. Knock penalty before knock bonus so they
# can stack (you get penalized AND rewarded, net +5). Underdog before tied shame
# so the -3 bonus can create new ties that then get punished. It's mean by design.
# Blackjack: exact score of 21 becomes 0 # Blackjack: exact score of 21 becomes 0
if self.options.blackjack: if self.options.blackjack:
@ -1597,6 +1623,10 @@ class Game:
""" """
current = self.current_player() current = self.current_player()
# Card visibility has three cases:
# 1. Round/game over: all cards revealed to everyone (reveal=True)
# 2. Your own cards: always revealed to you (is_self=True)
# 3. Opponent cards mid-game: only face-up cards shown, hidden cards are redacted
players_data = [] players_data = []
for player in self.players: for player in self.players:
reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER) reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER)

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") player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
room = room_manager.create_room() room = room_manager.create_room()
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id) room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
room.touch()
ctx.current_room = room ctx.current_room = room
await ctx.websocket.send_json({ await ctx.websocket.send_json({
@ -114,6 +115,7 @@ async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager,
return return
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id) room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
room.touch()
ctx.current_room = room ctx.current_room = room
await ctx.websocket.send_json({ await ctx.websocket.send_json({
@ -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: async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id) room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host: if not room_player or not room_player.is_host:
@ -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: async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
positions = data.get("positions", []) positions = data.get("positions", [])
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@ -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: async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
source = data.get("source", "deck") source = data.get("source", "deck")
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@ -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: async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
position = data.get("position", 0) position = data.get("position", 0)
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@ -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) player = ctx.current_room.game.get_player(ctx.player_id)
old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None
# Capture old card info BEFORE the swap mutates the player's hand.
# game.swap_card() overwrites player.cards[position] in place, so if we
# read it after, we'd get the new card. The client needs the old card data
# to animate the outgoing card correctly.
old_was_face_down = old_card and not old_card.face_up if old_card else False
old_card_data = None
if old_card and old_was_face_down:
old_card_data = {
"rank": old_card.rank.value if old_card.rank else None,
"suit": old_card.suit.value if old_card.suit else None,
}
discarded = ctx.current_room.game.swap_card(ctx.player_id, position) discarded = ctx.current_room.game.swap_card(ctx.player_id, position)
if discarded: if discarded:
@ -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: async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
drawn_card = ctx.current_room.game.drawn_card drawn_card = ctx.current_room.game.drawn_card
@ -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: async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
position = data.get("position", 0) position = data.get("position", 0)
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@ -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: async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id) player = ctx.current_room.game.get_player(ctx.player_id)
@ -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: async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
position = data.get("position", 0) position = data.get("position", 0)
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@ -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: async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id) player = ctx.current_room.game.get_player(ctx.player_id)
@ -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: async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id) room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host: if not room_player or not room_player.is_host:
@ -467,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: async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id) room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host: if not room_player or not room_player.is_host:

View File

@ -64,6 +64,7 @@ _matchmaking_service = None
_replay_service = None _replay_service = None
_spectator_manager = None _spectator_manager = None
_leaderboard_refresh_task = None _leaderboard_refresh_task = None
_room_cleanup_task = None
_redis_client = None _redis_client = None
_rate_limiter = None _rate_limiter = None
_shutdown_event = asyncio.Event() _shutdown_event = asyncio.Event()
@ -83,8 +84,74 @@ async def _periodic_leaderboard_refresh():
logger.error(f"Leaderboard refresh failed: {e}") logger.error(f"Leaderboard refresh failed: {e}")
async def _periodic_room_cleanup():
"""Periodic task to clean up rooms idle for longer than ROOM_IDLE_TIMEOUT_SECONDS."""
import time
while True:
try:
await asyncio.sleep(60)
now = time.time()
timeout = config.ROOM_IDLE_TIMEOUT_SECONDS
stale_rooms = [
room for room in room_manager.rooms.values()
if now - room.last_activity > timeout
]
for room in stale_rooms:
logger.info(
f"Cleaning up stale room {room.code} "
f"(idle {int(now - room.last_activity)}s, "
f"{len(room.players)} players)"
)
# Cancel CPU turn task
if room.cpu_turn_task:
room.cpu_turn_task.cancel()
try:
await room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
room.cpu_turn_task = None
# Notify and close human WebSocket connections
for player in list(room.players.values()):
if player.websocket and not player.is_cpu:
try:
await player.websocket.send_json({
"type": "room_expired",
"message": "Room closed due to inactivity",
})
await player.websocket.close(code=4002, reason="Room expired")
except Exception:
pass
# Mark game as abandoned in DB
if room.game_log_id:
try:
async with _user_store.pool.acquire() as conn:
await conn.execute(
"UPDATE games_v2 SET status = 'abandoned', completed_at = NOW() WHERE id = $1 AND status = 'active'",
room.game_log_id,
)
logger.info(f"Marked game {room.game_log_id} as abandoned in DB")
except Exception as e:
logger.error(f"Failed to mark game {room.game_log_id} as abandoned: {e}")
# Clean up players and profiles
room_code = room.code
for cpu in list(room.get_cpu_players()):
room.remove_player(cpu.id)
cleanup_room_profiles(room_code)
room_manager.remove_room(room_code)
if stale_rooms:
logger.info(f"Cleaned up {len(stale_rooms)} stale room(s)")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Room cleanup failed: {e}")
async def _init_redis(): async def _init_redis():
"""Initialize Redis client and rate limiter.""" """Initialize Redis client, rate limiter, and signup limiter."""
global _redis_client, _rate_limiter global _redis_client, _rate_limiter
try: try:
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False) _redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
@ -95,6 +162,17 @@ async def _init_redis():
from services.ratelimit import get_rate_limiter from services.ratelimit import get_rate_limiter
_rate_limiter = await get_rate_limiter(_redis_client) _rate_limiter = await get_rate_limiter(_redis_client)
logger.info("Rate limiter initialized") logger.info("Rate limiter initialized")
# Initialize signup limiter for metered open signups
if config.DAILY_OPEN_SIGNUPS != 0 or config.DAILY_SIGNUPS_PER_IP > 0:
from services.ratelimit import get_signup_limiter
signup_limiter = await get_signup_limiter(_redis_client)
from routers.auth import set_signup_limiter
set_signup_limiter(signup_limiter)
logger.info(
f"Signup limiter initialized "
f"(daily={config.DAILY_OPEN_SIGNUPS}, per_ip={config.DAILY_SIGNUPS_PER_IP})"
)
except Exception as e: except Exception as e:
logger.warning(f"Redis connection failed: {e} - rate limiting disabled") logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
_redis_client = None _redis_client = None
@ -243,6 +321,14 @@ async def _shutdown_services():
reset_all_profiles() reset_all_profiles()
logger.info("All rooms and CPU profiles cleaned up") logger.info("All rooms and CPU profiles cleaned up")
if _room_cleanup_task:
_room_cleanup_task.cancel()
try:
await _room_cleanup_task
except asyncio.CancelledError:
pass
logger.info("Room cleanup task stopped")
if _leaderboard_refresh_task: if _leaderboard_refresh_task:
_leaderboard_refresh_task.cancel() _leaderboard_refresh_task.cancel()
try: try:
@ -301,6 +387,26 @@ async def lifespan(app: FastAPI):
room_manager=room_manager, room_manager=room_manager,
) )
# Mark any orphaned active games as abandoned (in-memory state lost on restart)
if _user_store:
try:
async with _user_store.pool.acquire() as conn:
result = await conn.execute(
"UPDATE games_v2 SET status = 'abandoned', completed_at = NOW() WHERE status = 'active'"
)
# PostgreSQL returns command tags like "UPDATE 3" — the last word is
# the affected row count. This is a documented protocol behavior.
count = int(result.split()[-1]) if result else 0
if count > 0:
logger.info(f"Marked {count} orphaned active game(s) as abandoned on startup")
except Exception as e:
logger.error(f"Failed to clean up orphaned games on startup: {e}")
# Start periodic room cleanup
global _room_cleanup_task
_room_cleanup_task = asyncio.create_task(_periodic_room_cleanup())
logger.info(f"Room cleanup task started (timeout={config.ROOM_IDLE_TIMEOUT_SECONDS}s)")
logger.info(f"Golf server started (environment={config.ENVIRONMENT})") logger.info(f"Golf server started (environment={config.ENVIRONMENT})")
yield yield
@ -325,7 +431,7 @@ async def _close_all_websockets():
app = FastAPI( app = FastAPI(
title="Golf Card Game", title="Golf Card Game",
debug=config.DEBUG, debug=config.DEBUG,
version="3.1.1", version="3.2.0",
lifespan=lifespan, lifespan=lifespan,
) )
@ -509,6 +615,8 @@ async def reset_cpu_profiles():
return {"status": "ok", "message": "All CPU profiles reset"} return {"status": "ok", "message": "All CPU profiles reset"}
# Per-user game limit. Prevents a single account from creating dozens of rooms
# and exhausting server memory. 4 is generous — most people play 1 at a time.
MAX_CONCURRENT_GAMES = 4 MAX_CONCURRENT_GAMES = 4
@ -549,6 +657,10 @@ async def websocket_endpoint(websocket: WebSocket):
else: else:
logger.debug(f"WebSocket connected anonymously as {connection_id}") logger.debug(f"WebSocket connected anonymously as {connection_id}")
# player_id = connection_id by design. Originally these were separate concepts
# (connection vs game identity), but in practice a player IS their connection.
# Reconnection creates a new connection_id, and the room layer handles the
# identity mapping. Keeping both fields lets handlers be explicit about intent.
ctx = ConnectionContext( ctx = ConnectionContext(
websocket=websocket, websocket=websocket,
connection_id=connection_id, connection_id=connection_id,
@ -652,7 +764,7 @@ async def broadcast_game_state(room: Room):
# Check for round over # Check for round over
if room.game.phase == GamePhase.ROUND_OVER: if room.game.phase == GamePhase.ROUND_OVER:
scores = [ scores = [
{"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 for p in room.game.players
] ]
# Build rankings # Build rankings
@ -661,6 +773,7 @@ async def broadcast_game_state(room: Room):
await player.websocket.send_json({ await player.websocket.send_json({
"type": "round_over", "type": "round_over",
"scores": scores, "scores": scores,
"finisher_id": room.game.finisher_id,
"round": room.game.current_round, "round": room.game.current_round,
"total_rounds": room.game.num_rounds, "total_rounds": room.game.num_rounds,
"rankings": { "rankings": {
@ -750,14 +863,32 @@ async def _run_cpu_chain(room: Room):
if not room_player or not room_player.is_cpu: if not room_player or not room_player.is_cpu:
return 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) await asyncio.sleep(0.25)
# Run CPU turn # Run CPU turn
async def broadcast_cb(): async def broadcast_cb():
await broadcast_game_state(room) 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): 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_code = room.code
room_player = room.remove_player(player_id) 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: if room.is_empty() or room.human_player_count() == 0:
# Remove all remaining CPU players to release their profiles # Remove all remaining CPU players to release their profiles
for cpu in list(room.get_cpu_players()): for cpu in list(room.get_cpu_players()):
@ -811,7 +943,18 @@ if os.path.exists(client_path):
return FileResponse(os.path.join(client_path, "index.html")) return FileResponse(os.path.join(client_path, "index.html"))
# Mount static files for everything else (JS, CSS, SVG, etc.) # Mount static files for everything else (JS, CSS, SVG, etc.)
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(): def run():

View File

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

View File

@ -14,6 +14,7 @@ A Room contains:
import asyncio import asyncio
import random import random
import string import string
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
@ -70,6 +71,11 @@ class Room:
game_log_id: Optional[str] = None game_log_id: Optional[str] = None
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock) game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
cpu_turn_task: Optional[asyncio.Task] = None cpu_turn_task: Optional[asyncio.Task] = None
last_activity: float = field(default_factory=time.time)
def touch(self) -> None:
"""Update last_activity timestamp to mark room as active."""
self.last_activity = time.time()
def add_player( def add_player(
self, self,
@ -92,6 +98,9 @@ class Room:
Returns: Returns:
The created RoomPlayer object. The created RoomPlayer object.
""" """
# First player in becomes host. On reconnection, the player gets a new
# connection_id, so they rejoin as a "new" player — host status may shift
# if the original host disconnected and someone else was promoted.
is_host = len(self.players) == 0 is_host = len(self.players) == 0
room_player = RoomPlayer( room_player = RoomPlayer(
id=player_id, id=player_id,
@ -167,7 +176,9 @@ class Room:
if room_player.is_cpu: if room_player.is_cpu:
release_profile(room_player.name, self.code) release_profile(room_player.name, self.code)
# Assign new host if needed # 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: if room_player.is_host and self.players:
next_host = next(iter(self.players.values())) next_host = next(iter(self.players.values()))
next_host.is_host = True next_host.is_host = True

View File

@ -5,6 +5,7 @@ Provides endpoints for user registration, login, password management,
and session handling. and session handling.
""" """
import hashlib
import logging import logging
from typing import Optional from typing import Optional
@ -15,6 +16,7 @@ from config import config
from models.user import User from models.user import User
from services.auth_service import AuthService from services.auth_service import AuthService
from services.admin_service import AdminService from services.admin_service import AdminService
from services.ratelimit import SignupLimiter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -115,6 +117,7 @@ class SessionResponse(BaseModel):
# These will be set by main.py during startup # These will be set by main.py during startup
_auth_service: Optional[AuthService] = None _auth_service: Optional[AuthService] = None
_admin_service: Optional[AdminService] = None _admin_service: Optional[AdminService] = None
_signup_limiter: Optional[SignupLimiter] = None
def set_auth_service(service: AuthService) -> None: def set_auth_service(service: AuthService) -> None:
@ -129,6 +132,12 @@ def set_admin_service_for_auth(service: AdminService) -> None:
_admin_service = service _admin_service = service
def set_signup_limiter(limiter: SignupLimiter) -> None:
"""Set the signup limiter instance (called from main.py)."""
global _signup_limiter
_signup_limiter = limiter
def get_auth_service_dep() -> AuthService: def get_auth_service_dep() -> AuthService:
"""Dependency to get auth service.""" """Dependency to get auth service."""
if _auth_service is None: if _auth_service is None:
@ -211,15 +220,51 @@ async def register(
auth_service: AuthService = Depends(get_auth_service_dep), auth_service: AuthService = Depends(get_auth_service_dep),
): ):
"""Register a new user account.""" """Register a new user account."""
# Validate invite code when invite-only mode is enabled has_invite = bool(request_body.invite_code)
if config.INVITE_ONLY: is_open_signup = not has_invite
if not request_body.invite_code: client_ip = get_client_ip(request)
raise HTTPException(status_code=400, detail="Invite code required") 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: if not _admin_service:
raise HTTPException(status_code=503, detail="Admin service not initialized") raise HTTPException(status_code=503, detail="Admin service not initialized")
if not await _admin_service.validate_invite_code(request_body.invite_code): if not await _admin_service.validate_invite_code(request_body.invite_code):
raise HTTPException(status_code=400, detail="Invalid or expired invite code") raise HTTPException(status_code=400, detail="Invalid or expired invite code")
else:
# No invite code — check if open signups are allowed
if config.INVITE_ONLY and config.DAILY_OPEN_SIGNUPS == 0:
raise HTTPException(status_code=400, detail="Invite code required")
# Check daily open signup limit
if config.DAILY_OPEN_SIGNUPS != 0 and _signup_limiter:
daily_allowed, daily_remaining = await _signup_limiter.check_daily_limit(
config.DAILY_OPEN_SIGNUPS
)
if not daily_allowed:
raise HTTPException(
status_code=429,
detail="Daily signup limit reached. Please try again tomorrow or use an invite code.",
)
elif config.DAILY_OPEN_SIGNUPS != 0 and not _signup_limiter:
# Signup limiter requires Redis — fail closed
raise HTTPException(
status_code=503,
detail="Registration temporarily unavailable. Please try again later.",
)
# --- Create the account ---
result = await auth_service.register( result = await auth_service.register(
username=request_body.username, username=request_body.username,
password=request_body.password, password=request_body.password,
@ -229,12 +274,19 @@ async def register(
if not result.success: if not result.success:
raise HTTPException(status_code=400, detail=result.error) raise HTTPException(status_code=400, detail=result.error)
# Consume the invite code after successful registration # --- Post-registration bookkeeping ---
if config.INVITE_ONLY and request_body.invite_code: # Consume invite code if used
if has_invite and _admin_service:
await _admin_service.use_invite_code(request_body.invite_code) await _admin_service.use_invite_code(request_body.invite_code)
# Increment signup counters
if _signup_limiter:
if is_open_signup and config.DAILY_OPEN_SIGNUPS != 0:
await _signup_limiter.increment_daily()
if config.DAILY_SIGNUPS_PER_IP > 0:
await _signup_limiter.increment_ip(ip_hash)
if result.requires_verification: if result.requires_verification:
# Return user info but note they need to verify
return { return {
"user": _user_to_response(result.user), "user": _user_to_response(result.user),
"token": "", "token": "",
@ -247,7 +299,7 @@ async def register(
username=request_body.username, username=request_body.username,
password=request_body.password, password=request_body.password,
device_info=get_device_info(request), device_info=get_device_info(request),
ip_address=get_client_ip(request), ip_address=client_ip,
) )
if not login_result.success: 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") @router.post("/verify-email")
async def verify_email( async def verify_email(
request_body: VerifyEmailRequest, request_body: VerifyEmailRequest,

View File

@ -91,9 +91,42 @@ class RateLimiter:
except redis.RedisError as e: except redis.RedisError as e:
# If Redis is unavailable, fail open (allow request) # If Redis is unavailable, fail open (allow request)
# For auth-critical paths, callers should use fail_closed=True
logger.error(f"Rate limiter Redis error: {e}") logger.error(f"Rate limiter Redis error: {e}")
return True, {"remaining": limit, "reset": window_seconds, "limit": limit} return True, {"remaining": limit, "reset": window_seconds, "limit": limit}
async def is_allowed_strict(
self,
key: str,
limit: int,
window_seconds: int,
) -> tuple[bool, dict]:
"""
Like is_allowed but fails closed (denies) when Redis is unavailable.
Use for security-critical paths like auth endpoints.
"""
now = int(time.time())
window_key = f"ratelimit:{key}:{now // window_seconds}"
try:
async with self.redis.pipeline(transaction=True) as pipe:
pipe.incr(window_key)
pipe.expire(window_key, window_seconds + 1)
results = await pipe.execute()
current_count = results[0]
remaining = max(0, limit - current_count)
reset = window_seconds - (now % window_seconds)
return current_count <= limit, {
"remaining": remaining,
"reset": reset,
"limit": limit,
}
except redis.RedisError as e:
logger.error(f"Rate limiter Redis error (fail-closed): {e}")
return False, {"remaining": 0, "reset": window_seconds, "limit": limit}
def get_client_key( def get_client_key(
self, self,
request: Request | WebSocket, request: Request | WebSocket,
@ -197,8 +230,110 @@ class ConnectionMessageLimiter:
self.timestamps = [] self.timestamps = []
class SignupLimiter:
"""
Daily signup metering for public beta.
Tracks two counters in Redis:
- Global daily open signups (no invite code)
- Per-IP daily signups (with or without invite code)
Keys auto-expire after 24 hours.
"""
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
def _today_key(self, prefix: str) -> str:
"""Generate a Redis key scoped to today's date (UTC)."""
from datetime import datetime, timezone
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return f"signup:{prefix}:{today}"
async def check_daily_limit(self, daily_limit: int) -> tuple[bool, int]:
"""
Check if global daily open signup limit allows another registration.
Args:
daily_limit: Max open signups per day. -1 = unlimited, 0 = disabled.
Returns:
Tuple of (allowed, remaining). remaining is -1 when unlimited.
"""
if daily_limit == 0:
return False, 0
if daily_limit < 0:
return True, -1
key = self._today_key("daily_open")
try:
count = await self.redis.get(key)
current = int(count) if count else 0
remaining = max(0, daily_limit - current)
return current < daily_limit, remaining
except redis.RedisError as e:
logger.error(f"Signup limiter Redis error (daily check): {e}")
return False, 0 # Fail closed
async def check_ip_limit(self, ip_hash: str, ip_limit: int) -> tuple[bool, int]:
"""
Check if per-IP daily signup limit allows another registration.
Args:
ip_hash: Hashed client IP.
ip_limit: Max signups per IP per day. 0 = unlimited.
Returns:
Tuple of (allowed, remaining).
"""
if ip_limit <= 0:
return True, -1
key = self._today_key(f"ip:{ip_hash}")
try:
count = await self.redis.get(key)
current = int(count) if count else 0
remaining = max(0, ip_limit - current)
return current < ip_limit, remaining
except redis.RedisError as e:
logger.error(f"Signup limiter Redis error (IP check): {e}")
return False, 0 # Fail closed
async def increment_daily(self) -> None:
"""Increment the global daily open signup counter."""
key = self._today_key("daily_open")
try:
async with self.redis.pipeline(transaction=True) as pipe:
pipe.incr(key)
pipe.expire(key, 86400 + 60) # 24h + 1min buffer
await pipe.execute()
except redis.RedisError as e:
logger.error(f"Signup limiter Redis error (daily incr): {e}")
async def increment_ip(self, ip_hash: str) -> None:
"""Increment the per-IP daily signup counter."""
key = self._today_key(f"ip:{ip_hash}")
try:
async with self.redis.pipeline(transaction=True) as pipe:
pipe.incr(key)
pipe.expire(key, 86400 + 60)
await pipe.execute()
except redis.RedisError as e:
logger.error(f"Signup limiter Redis error (IP incr): {e}")
async def get_daily_count(self) -> int:
"""Get current daily open signup count."""
key = self._today_key("daily_open")
try:
count = await self.redis.get(key)
return int(count) if count else 0
except redis.RedisError:
return 0
# Global rate limiter instance # Global rate limiter instance
_rate_limiter: Optional[RateLimiter] = None _rate_limiter: Optional[RateLimiter] = None
_signup_limiter: Optional[SignupLimiter] = None
async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter: async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
@ -217,7 +352,16 @@ async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
return _rate_limiter return _rate_limiter
async def get_signup_limiter(redis_client: redis.Redis) -> SignupLimiter:
"""Get or create the global signup limiter instance."""
global _signup_limiter
if _signup_limiter is None:
_signup_limiter = SignupLimiter(redis_client)
return _signup_limiter
def close_rate_limiter(): def close_rate_limiter():
"""Close the global rate limiter.""" """Close the global rate limiter."""
global _rate_limiter global _rate_limiter, _signup_limiter
_rate_limiter = None _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)