Compare commits

...

134 Commits
v3.1.2 ... 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
adlee-was-taken
dc936d7e1c Add v3.1.5 footer with copyright to lobby and waiting room
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:08:39 -05:00
adlee-was-taken
1cdf1cf281 Tune round-end pause and reduce deck shake frequency
Cut lastPlayPause to 2s and increase shake interval by 80% (3s→5.4s)
so the draw/discard nudge feels less nagging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:06:17 -05:00
adlee-was-taken
17f7d8ce7a Fix draw-swap animation race and smarter CPU go-out decisions
Increase post_draw_settle timing (1.1s→1.3s) and swap retry delay
(100ms→350ms) to prevent draw animation from being cut short by the
arriving swap animation. Also make CPU go-out decisions consider
opponent scores and avoid swapping high cards (8+) into hidden slots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:57:59 -05:00
adlee-was-taken
9a5bc888cb Compact scoresheet modal to reduce scrolling with 4 players
Tighten padding, gaps, card sizes, and margins across all scoresheet
elements so the full modal fits without scrolling on most viewports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:41:03 -05:00
adlee-was-taken
3dcad3dfdf Fix round-end reveal timing: pause after last play, handle deferred state
Two issues fixed:

1. renderGame() was called before the lastPlayPause delay, causing the
   board to jump to final card positions while the swap animation was
   still visually playing. Moved renderGame() to after the wait+pause.

2. When the local player makes the final play, their swap animation
   defers the round_over game_state to pendingGameState. The deferred
   state bypassed the round-end intercept, so preRevealState was never
   set — causing the scoresheet to appear immediately without the
   reveal animation. Now completeSwapAnimation checks for round_over
   transitions and sets preRevealState. Also added a wait loop in
   runRoundEndReveal for robustness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:35:02 -05:00
adlee-was-taken
b129aa4f29 Fix opponent draw-from-discard animation showing wrong card
Force discard pile DOM update before draw animation starts to prevent
stale card display when previous swap animation blocked renderGame.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:26:24 -05:00
adlee-was-taken
86697dd454 Compact mobile lobby layout with inline CPU controls
- Move CPU +/- buttons inline into Players header row with "CPU:" label
- Tighten vertical spacing for mobile stacked layout (≤700px)
- Fit Decks/Holes/Card Backs settings in single row on mobile
- Reduce room code banner and auth bar edge margins
- Consistent spacing across all stacked viewport widths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:15:37 -05:00
adlee-was-taken
77cbefc30c Improve initial card flip animation appearance
Style the flip overlay's front face to match player hand cards (gradient
background, proper border/shadow) instead of using generic card-front
styles. Hide the underlying card during the animation so the green table
shows through the flip rather than a white card peeking behind it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:29:09 -05:00
adlee-was-taken
e2c7a55dac Fix held card displacement in landscape and tooltip crash
The turn pulse shake was targeting .discard-stack, which is an ancestor of
#held-card-floating. A CSS transform on any ancestor breaks position:fixed,
causing the held card to render far from the deck area. Now target #discard
directly instead.

Also fix duplicate getCardPointValue methods — the 3-arg scoring version
shadowed the 1-arg tooltip version, leaving cardValues undefined on hover.

Add staging deploy script (rsync working tree, no git pull needed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:16:20 -05:00
adlee-was-taken
8d5b2ee655 Fix AI knock decisions and improve round-end animations
Fix dumb AI knocks (e.g. Maya knocking on 13 points) by adding opponent
threat checks and a hard cap of 10 to should_knock_early(). Remove dead
should_go_out_early() call whose return value was never used. Retune knock
chance tiers to be more conservative at higher projected scores.

On the client side, fix round-end reveal sequencing so the last player's
swap/discard animation plays before the reveal sequence starts, and prevent
re-renders from clobbering swap animations during reveals. Also make the
turn-pulse shake configurable via timing-config and target only cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:07:57 -05:00
adlee-was-taken
06b15f002d Add internal/ to .gitignore for local deployment docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:42:31 -05:00
adlee-was-taken
76f80f3f44 Add docker-compose.staging.yml for 512MB staging droplet
Mirrors production config with reduced memory limits (128M app, 96M
postgres, 32M redis, 48M traefik) and staging defaults. Rate limiting
disabled for easier testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:39:17 -05:00
adlee-was-taken
0a9993a82f Pass per-module log level env vars through docker-compose.prod.yml
LOG_LEVEL and ENVIRONMENT were hardcoded, overriding .env values.
Now uses ${VAR:-default} so .env settings are respected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:15:10 -05:00
adlee-was-taken
e463d929e3 Add per-module log level overrides for staging/production
Support LOG_LEVEL_{MODULE} env vars (GAME, AI, HANDLERS, ROOM, AUTH,
STORES) to override the global log level for specific modules. Active
overrides are logged at startup. Includes staging/production presets
in .env.example files and a V3.18 stub doc for PostgreSQL storage
efficiency investigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:12:11 -05:00
adlee-was-taken
1b923838e0 Fix typo: Bare -> Bear
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:35:50 -05:00
adlee-was-taken
4503198021 Update banner text to beta testing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:34:32 -05:00
adlee-was-taken
cb49fd545b Add gradient backgrounds to all status messages, match final-turn badge size
- Default gradient on base .status-message (dark green)
- Add round-over/game-over (gold) and reveal (purple) gradient styles
- Tag action prompts (swap, flip, discard) as your-turn type
- Match final-turn badge font size and padding to status message on mobile
- Hide status message when empty

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:31:20 -05:00
adlee-was-taken
cb311ec0da Move status message to left side of header on mobile
Align header-col-center to flex-start on mobile so the status and
final-turn badges sit flush left. Match final-turn-badge border-radius
and padding to status-message for consistent shape.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:26:31 -05:00
adlee-was-taken
873bdfc75a Left-align status message on mobile portrait
Mute button in header-right throws off visual centering of the status
text. Left-aligning looks intentional rather than off-center.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:22:52 -05:00
adlee-was-taken
bd41afbca8 Fix mobile scroll on rules screen
overflow:hidden on body.mobile-portrait was blocking scroll on all
screens. Scope it to only when the game screen is active using :has().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:18:18 -05:00
adlee-was-taken
21985b7e9b Route all lobby transitions through showLobby() for animation cleanup
game_ended, queue_left, and cancelMatchmaking were calling
showScreen('lobby') directly, bypassing the cancelAll() cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:12:39 -05:00
adlee-was-taken
56305424ff Thorough animation cleanup when leaving game
- Tag deal container with class so cleanup() can find and remove it
- Remove .traveling-card and .deal-anim-container overlays in cleanup()
- Restore opacity/visibility on cards hidden mid-animation
- Reset all animation flags (dealAnimationInProgress, etc.) in showLobby()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:09:37 -05:00
adlee-was-taken
0bfe9d5f9f Cancel animations on game leave to prevent overlay flash on lobby
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:06:11 -05:00
adlee-was-taken
a0bb28d5eb Fix opponent swap animation instant shrink on mobile portrait
Let overlay card start at deck size and smoothly scale down to opponent
card size during the arc, instead of instantly shrinking before animating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:00:20 -05:00
adlee-was-taken
55006d6ff4 Fix bottom bar width: add align-self: stretch to override parent center
Parent #game-screen has align-items: center which shrink-wraps flex
children. Adding align-self: stretch makes the bottom bar span the
full screen width so space-between can distribute items properly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:43:40 -05:00
adlee-was-taken
adcc59b6fc Spread bottom bar items with space-between
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:40:57 -05:00
adlee-was-taken
7e0c006f5e Revert bottom bar to original working state
Remove all flush-edge styling (negative margins, half-pills, border
removal). Restore original padding, justify-content, and pill shapes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:38:18 -05:00
adlee-was-taken
02f9b3c44d Fix layout: restore 12px padding, use negative margins for flush edges
Zero padding was breaking game layout. Keep 12px padding for layout
stability and use margin-left: -12px / margin-right: -12px on the
edge items to push them flush against screen edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:36:18 -05:00
adlee-was-taken
9f75cdb0dc Pin Hole and End Game flush to screen edges with half-pill shape
Zero horizontal padding on bottom bar, remove border on flush side,
use half-rounded pill shape so they sit against the screen edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:34:27 -05:00
adlee-was-taken
519d08a2a6 Fix layout: move rules drawer out of game-layout, restore bottom bar padding
Reverts flush-edge pill styling and restores horizontal padding to prevent
clipping. Rules drawer is now a sibling of bottom-bar, not inside game-layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:32:32 -05:00
adlee-was-taken
9419cb562e Move rules drawer inside game-layout to fix layout breakage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:28:36 -05:00
adlee-was-taken
17c8e574ab Pin Hole and End Game buttons flush to screen edges on mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:26:18 -05:00
adlee-was-taken
94edb685a7 Move dealer chip to bottom-left of player panel on mobile, pin bottom bar edges
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:22:54 -05:00
adlee-was-taken
6b7d6c459e Remove redundant Scores button, rename Standings to Scorecard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:19:47 -05:00
adlee-was-taken
1de282afc2 Change mobile rules pill default text from "S" to "RULES"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:17:41 -05:00
adlee-was-taken
9b0a8295eb Add mobile rules indicator pill and drawer
Shows "S" (standard) or "!" (house rules) in the mobile bottom bar.
Tapping opens a drawer with the full active rules list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:14:09 -05:00
adlee-was-taken
28a0f90374 Restore dealer chip to 38px and shift further out
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:05:54 -05:00
adlee-was-taken
0df451aa99 Enlarge local dealer chip to 34px and nudge further out
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:03:37 -05:00
adlee-was-taken
8d7b024525 Adjust local player dealer chip size and position
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:00:46 -05:00
adlee-was-taken
9c08b4735a Shrink local player dealer chip in desktop mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:58:53 -05:00
adlee-was-taken
49916e6a6c Remove top padding above game header in desktop mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:53:41 -05:00
adlee-was-taken
e0641de449 Move knocker OUT badge to bottom-right on mobile portrait
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:49:43 -05:00
adlee-was-taken
e2a90c0f34 Fix knocker highlight not showing on opponents
markKnocker() was called before opponent areas were rebuilt by
innerHTML='', so the is-knocker class and OUT badge were immediately
destroyed. Move markKnocker to after opponent areas are created.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:37:33 -05:00
adlee-was-taken
86f5222746 Enhance knocker highlight with glowing box-shadow animation
Makes the red border on the knocker's area more visible, especially
for opponents on mobile where the area is small.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:26:09 -05:00
adlee-was-taken
60997e8ad4 Compact final results for mobile, delay turn shake hint
Final results modal: keep BY POINTS and BY HOLES side-by-side on
mobile, compact spacing, buttons side-by-side, bottom padding for
mobile bar overlay.

Turn shake: delay 5s before first shake, 300ms every 2s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:21:45 -05:00
adlee-was-taken
3e133b17c0 Delay turn shake hint by 5s, reduce to 300ms every 2s
Less aggressive draw hint: waits 5 seconds before first shake,
then shakes for 300ms every 2 seconds with slightly less movement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:13:40 -05:00
adlee-was-taken
9866fb8e92 Move discard button below held card on mobile portrait
Position the button centered beneath the held card instead of to the
right side. Reset writing-mode to horizontal and add width:auto.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:39:33 -05:00
adlee-was-taken
4a5cfb68f1 Set held card offset to 0.48 on mobile portrait
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:35:58 -05:00
adlee-was-taken
ebb00f613c Lower held card offset to 0.55 on mobile portrait
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:34:00 -05:00
adlee-was-taken
98aa0823ed Set held card mobile portrait offset back to 0.65
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:33:36 -05:00
adlee-was-taken
4a3d62e26e Nudge held card up slightly to clear DRAW/DISCARD labels
Increase mobile portrait overlap offset from 0.65 to 0.8.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:32:46 -05:00
adlee-was-taken
d958258066 Lower held card position to just above the labels on mobile portrait
Reduce overlap offset from 1.15 to 0.65 so the held card sits at the
DRAW/DISCARD label level rather than up in the opponents area.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:30:33 -05:00
adlee-was-taken
26bc151458 Move held card to gap above deck area on mobile portrait
Position the held card a full card height above the deck (1.15x offset)
so it sits in the space between opponents and the draw/discard piles.
All three position calculations (app.js x2, card-animations.js) are
synced so draw animations land at the correct held position.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:27:45 -05:00
adlee-was-taken
0d5c0c613d Add DRAW and DISCARD labels above deck and discard piles
Wrap each pile in a .pile-wrapper with a small label above it.
Fix direct child selectors that broke with the new wrapper nesting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:23:38 -05:00
adlee-was-taken
e9692de6c6 Add top padding to table-center on mobile portrait for held card clearance
Increase top padding from 5px to 20px so the deck/discard sit lower,
giving the held card more breathing room from the opponents row above.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:20:21 -05:00
adlee-was-taken
3414bfad1a Sync held card position across all animation paths for mobile portrait
Update getHoldingRect() in card-animations.js and the second held card
positioning path in app.js to use the same reduced overlap offset on
mobile portrait. All three places that compute the held position now
use 0.15 on mobile-portrait vs 0.35 on desktop/landscape.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:12:46 -05:00
adlee-was-taken
ecad259db2 Lower held card position and add opponent row padding on mobile
Reduce held card overlap offset from 0.35 to 0.15 on mobile portrait
so it doesn't cover the second row of opponents. Increase bottom
padding on opponents row from 6px to 12px for more breathing room.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:10:11 -05:00
adlee-was-taken
932e9ca4ef Enhance Your Turn status gradient to be more visible
Widen the green gradient range to match the visual pop of the
opponent turn purple gradient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:03:40 -05:00
adlee-was-taken
10825e8b82 Add opponent-turn class to status message in renderGame
The status was set without a type class in renderGame(), overriding
the styled version from updateStatusFromGameState() on every state
update. Now the purple background shows consistently for opponent turns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:00:38 -05:00
adlee-was-taken
53abde53ac Anchor back buttons to top-left corner of header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:48:16 -05:00
adlee-was-taken
d7ba3154a1 Scope container margin-top to mobile-portrait only
Remove margin-top from base rules/leaderboard/matchmaking styles so
desktop and landscape layouts are unaffected. The 50px top margin is
now only applied via the mobile-portrait override.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:45:36 -05:00
adlee-was-taken
197595fc4d Fix mobile-portrait override resetting container margin-top to 0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:44:35 -05:00
adlee-was-taken
e38d8c1561 Add margin-top to matchmaking screen to clear auth bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:42:39 -05:00
adlee-was-taken
afb4869b21 Add margin-top to rules and leaderboard containers to clear auth bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:42:10 -05:00
adlee-was-taken
c6769f9257 Fix back button width and add border to leaderboard header
Override width:100% from .btn base class with width:auto on both
back buttons. Add padding-bottom and border-bottom to leaderboard
header to match rules page styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:39:51 -05:00
adlee-was-taken
8657a0501f Move Back button into header on Rules and Leaderboard pages
Position the button absolutely on the left side of the header,
vertically centered with the title. Remove mobile fixed-position
override that placed it in the top bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:37:13 -05:00
adlee-was-taken
730ba9c462 Fix portrait back buttons: fixed top-left, push containers down
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:33:25 -05:00
adlee-was-taken
1ba80606a7 Add top padding to rules/leaderboard screens in portrait mode
Prevents auth bar from overlapping back buttons. Back buttons
align to start instead of stretching full width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:28:22 -05:00
adlee-was-taken
3261e6ee26 Make CPU turn chain fire-and-forget so end game is instant
The CPU turn chain was awaited inline inside game_lock, blocking the
WebSocket message loop. The end_game message couldn't be processed
until all CPU turns finished. Now check_and_run_cpu_turn launches a
background task and returns immediately, keeping the message loop
responsive. The end_game and leave handlers cancel the task on demand.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:26:58 -05:00
adlee-was-taken
de3495635b Cancel CPU turns immediately when host ends game
Convert CPU turn chain to a cancellable asyncio.Task tracked on Room,
so ending the game or leaving no longer blocks waiting for CPU sleeps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:21:22 -05:00
adlee-was-taken
4c23f2b4a9 Increase mobile portrait opponent row gap to 9px
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:16:32 -05:00
adlee-was-taken
7b071afdfb Apply flush header with gradient to desktop/landscape view too
Remove game-screen padding and replace solid dark header background
with subtle dark-to-transparent gradient matching mobile treatment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:12:15 -05:00
adlee-was-taken
c7fb85d281 Remove desktop 10px padding from game-screen on mobile
The desktop #game-screen.active had padding:10px that was never
overridden in the mobile portrait styles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:11:02 -05:00
adlee-was-taken
118912dd13 Add subtle dark gradient to mobile header for status bar visibility
Replaces background:none with a dark-to-transparent gradient so the
status message and mute button are visible against the green felt.
Reverts mute button circle in favor of the gradient approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:08:57 -05:00
adlee-was-taken
0e594a5e28 Add dark circle background behind mute button on mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:06:08 -05:00
adlee-was-taken
a6ec72d72c Remove dark background from mobile header for flush appearance
The desktop game-header background was still showing on mobile,
creating a visible dark band with padding around the status bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:05:18 -05:00
adlee-was-taken
e2f353d4ab Make mobile header flush with page edges and add spacing below
Remove left/right/top padding from notification bar so it spans edge
to edge, and increase bottom margin from 4px to 8px for more breathing
room before the opponents row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:01:36 -05:00
adlee-was-taken
e601eb04c9 Add alpha notice banner to lobby screen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:52:22 -05:00
adlee-was-taken
6c771810f7 Distribute space evenly between draw pile and player hand on mobile
Replace margin:auto on table-center with space-evenly on player-row
so the draw pile and player cards are equally spaced vertically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:50:55 -05:00
adlee-was-taken
dbad7037d1 Fix dealer chip and status bar clipping on mobile edges
Increase horizontal padding on game-table (4px to 10px) and header
(8px to 12px) to prevent edge clipping. Change opponents-row overflow
to visible so dealer chips aren't cut off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:50:05 -05:00
adlee-was-taken
21362ba125 Fix pair chime not playing for local player's own swaps
The swap animation defers state updates to pendingGameState, which
bypassed checkForNewPairs entirely. Now pair detection runs when the
deferred state is applied after the swap animation completes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:47:43 -05:00
adlee-was-taken
2dcdaf2b49 Remove turns remaining counter from FINAL TURN badge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:46:03 -05:00
adlee-was-taken
1fa13bbe3b Play pair sound before element check and add pair detection debug log
Sound was gated behind element check which may fail during swap
animation when card DOM elements are replaced by overlays.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:41:17 -05:00
adlee-was-taken
a76fd8da32 Hide bottom bar during scoresheet modal and compact mobile layout
Bottom bar is hidden when the hole results modal opens and restored
when dismissed. Also adds mobile-specific compact styles for the
scoresheet: smaller cards, tighter spacing, reduced padding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:34:16 -05:00
adlee-was-taken
634d101f2c Play pair chime sound for all players including local player
firePairCelebration was only doing the visual animation but not playing
the pair sound. The sound was only played during score tallying.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:31:00 -05:00
adlee-was-taken
28c9882b17 Add www.golfcards.club cert and redirect to bare domain
Traefik gets a separate cert for www subdomain and uses
redirectregex middleware to 301 redirect to bare domain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:48:31 -05:00
50 changed files with 5902 additions and 326 deletions

View File

@ -20,6 +20,24 @@ DEBUG=false
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL # Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Per-module log level overrides (optional)
# These override LOG_LEVEL for specific modules.
# LOG_LEVEL_GAME=DEBUG # Core game logic
# LOG_LEVEL_AI=DEBUG # AI decisions (very verbose at DEBUG)
# LOG_LEVEL_HANDLERS=DEBUG # WebSocket message handlers
# LOG_LEVEL_ROOM=DEBUG # Room/lobby management
# LOG_LEVEL_AUTH=DEBUG # Auth stack (auth, routers.auth, services.auth_service)
# LOG_LEVEL_STORES=DEBUG # Database/Redis operations
# --- Preset examples ---
# Staging (debug game logic, quiet everything else):
# LOG_LEVEL=INFO
# LOG_LEVEL_GAME=DEBUG
# LOG_LEVEL_AI=DEBUG
#
# Production (minimal logging):
# LOG_LEVEL=WARNING
# Environment name (development, staging, production) # Environment name (development, staging, production)
ENVIRONMENT=development ENVIRONMENT=development
@ -57,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

27
.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/
@ -201,6 +225,9 @@ pyvenv.cfg
# Personal notes # Personal notes
lookfah.md lookfah.md
# Internal docs (deployment info, credentials references, etc.)
internal/
# Ruff stuff: # Ruff stuff:
.ruff_cache/ .ruff_cache/

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 = () => {
@ -379,7 +414,7 @@ class GolfGame {
// Only show tooltips on your turn // Only show tooltips on your turn
if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return; if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return;
const value = this.getCardPointValue(cardData); const value = this.getCardPointValueForTooltip(cardData);
const special = this.getCardSpecialNote(cardData); const special = this.getCardSpecialNote(cardData);
let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`; let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`;
@ -409,14 +444,15 @@ class GolfGame {
if (this.tooltip) this.tooltip.classList.add('hidden'); if (this.tooltip) this.tooltip.classList.add('hidden');
} }
getCardPointValue(cardData) { getCardPointValueForTooltip(cardData) {
const values = this.gameState?.card_values || this.getDefaultCardValues(); const values = this.gameState?.card_values || this.getDefaultCardValues();
return values[cardData.rank] ?? 0; const rules = this.gameState?.scoring_rules || {};
return this.getCardPointValue(cardData, values, rules);
} }
getCardSpecialNote(cardData) { getCardSpecialNote(cardData) {
const rank = cardData.rank; const rank = cardData.rank;
const value = this.getCardPointValue(cardData); const value = this.getCardPointValueForTooltip(cardData);
if (value < 0) return 'Negative - keep it!'; if (value < 0) return 'Negative - keep it!';
if (rank === 'K' && value === 0) return 'Safe card'; if (rank === 'K' && value === 0) return 'Safe card';
if (rank === 'K' && value === -2) return 'Super King!'; if (rank === 'K' && value === -2) return 'Super King!';
@ -536,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() {
@ -817,16 +860,39 @@ class GolfGame {
hasDrawn: newState.has_drawn_card hasDrawn: newState.has_drawn_card
}); });
// V3_03: Intercept round_over transition to defer card reveals // V3_03: Intercept round_over transition to defer card reveals.
// The problem: the last turn's swap animation flips a card, and then
// the round-end reveal animation would flip it again. We snapshot the
// old state, patch it to mark the swap position as already face-up,
// and use that as the "before" for the reveal animation.
const roundJustEnded = oldState?.phase !== 'round_over' && const roundJustEnded = oldState?.phase !== 'round_over' &&
newState.phase === 'round_over'; newState.phase === 'round_over';
if (roundJustEnded && oldState) { if (roundJustEnded && oldState) {
// Save pre-reveal state for the reveal animation // Update state first so animations can read new card data
this.preRevealState = JSON.parse(JSON.stringify(oldState));
this.postRevealState = newState;
// Update state but DON'T render yet - reveal animation will handle it
this.gameState = newState; this.gameState = newState;
// Fire animations for the last turn (swap/discard) before deferring
try {
this.triggerAnimationsForStateChange(oldState, newState);
} catch (e) {
console.error('Animation error on round end:', e);
}
// Build preRevealState from oldState, but mark swap position as
// already handled so reveal animation doesn't double-flip it.
// Without this patch, the card visually flips twice in a row.
const preReveal = JSON.parse(JSON.stringify(oldState));
if (this.opponentSwapAnimation) {
const { playerId, position } = this.opponentSwapAnimation;
const player = preReveal.players.find(p => p.id === playerId);
if (player?.cards[position]) {
player.cards[position].face_up = true;
}
}
this.preRevealState = preReveal;
this.postRevealState = newState;
break; break;
} }
@ -923,16 +989,16 @@ class GolfGame {
this.displayHeldCard(data.card, true); this.displayHeldCard(data.card, true);
this.renderGame(); this.renderGame();
} }
this.showToast('Swap with a card or discard', '', 3000); this.showToast('Swap with a card or discard', 'your-turn', 3000);
break; break;
case 'can_flip': case 'can_flip':
this.waitingForFlip = true; this.waitingForFlip = true;
this.flipIsOptional = data.optional || false; this.flipIsOptional = data.optional || false;
if (this.flipIsOptional) { if (this.flipIsOptional) {
this.showToast('Flip a card or skip', '', 3000); this.showToast('Flip a card or skip', 'your-turn', 3000);
} else { } else {
this.showToast('Flip a face-down card', '', 3000); this.showToast('Flip a face-down card', 'your-turn', 3000);
} }
this.renderGame(); this.renderGame();
break; break;
@ -950,7 +1016,7 @@ class GolfGame {
// Host ended the game or player was kicked // Host ended the game or player was kicked
this._intentionalClose = true; this._intentionalClose = true;
if (this.ws) this.ws.close(); if (this.ws) this.ws.close();
this.showScreen('lobby'); this.showLobby();
if (data.reason) { if (data.reason) {
this.showError(data.reason); this.showError(data.reason);
} }
@ -975,7 +1041,7 @@ class GolfGame {
case 'queue_left': case 'queue_left':
this.stopMatchmakingTimer(); this.stopMatchmakingTimer();
this.showScreen('lobby'); this.showLobby();
break; break;
case 'error': case 'error':
@ -995,7 +1061,7 @@ class GolfGame {
cancelMatchmaking() { cancelMatchmaking() {
this.send({ type: 'queue_leave' }); this.send({ type: 'queue_leave' });
this.stopMatchmakingTimer(); this.stopMatchmakingTimer();
this.showScreen('lobby'); this.showLobby();
} }
startMatchmakingTimer() { startMatchmakingTimer() {
@ -1313,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
@ -1431,11 +1499,8 @@ class GolfGame {
this.swapAnimationCardEl = handCardEl; this.swapAnimationCardEl = handCardEl;
this.swapAnimationHandCardEl = handCardEl; this.swapAnimationHandCardEl = handCardEl;
// Hide originals during animation // Hide discard button during animation (held card hidden later by onStart)
handCardEl.classList.add('swap-out'); 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;
@ -1458,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) {
@ -1521,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) {
@ -1573,9 +1650,27 @@ class GolfGame {
this.heldCardFloating.classList.add('hidden'); this.heldCardFloating.classList.add('hidden');
if (this.pendingGameState) { if (this.pendingGameState) {
this.gameState = this.pendingGameState; const oldState = this.gameState;
const newState = this.pendingGameState;
this.pendingGameState = null; this.pendingGameState = null;
this.renderGame();
// Check if the deferred state is a round_over transition
const roundJustEnded = oldState?.phase !== 'round_over' &&
newState.phase === 'round_over';
if (roundJustEnded && oldState) {
// Same intercept as the game_state handler: store pre/post
// reveal states so runRoundEndReveal can animate the reveal
this.gameState = newState;
const preReveal = JSON.parse(JSON.stringify(oldState));
this.preRevealState = preReveal;
this.postRevealState = newState;
// Don't renderGame - let the reveal sequence handle it
} else {
this.gameState = newState;
this.checkForNewPairs(oldState, newState);
this.renderGame();
}
} }
} }
@ -1789,6 +1884,10 @@ class GolfGame {
document.body.appendChild(modal); document.body.appendChild(modal);
this.setStatus('Hole complete'); this.setStatus('Hole complete');
// Hide bottom bar so it doesn't overlay the modal
const bottomBar = document.getElementById('mobile-bottom-bar');
if (bottomBar) bottomBar.classList.add('hidden');
// Bind next button // Bind next button
const nextBtn = document.getElementById('ss-next-btn'); const nextBtn = document.getElementById('ss-next-btn');
nextBtn.addEventListener('click', () => { nextBtn.addEventListener('click', () => {
@ -1918,6 +2017,10 @@ class GolfGame {
this.clearScoresheetCountdown(); this.clearScoresheetCountdown();
const modal = document.getElementById('scoresheet-modal'); const modal = document.getElementById('scoresheet-modal');
if (modal) modal.remove(); if (modal) modal.remove();
// Restore bottom bar
const bottomBar = document.getElementById('mobile-bottom-bar');
if (bottomBar) bottomBar.classList.remove('hidden');
} }
// --- V3_02: Dealing Animation --- // --- V3_02: Dealing Animation ---
@ -2053,6 +2156,16 @@ class GolfGame {
async runRoundEndReveal(scores, rankings) { async runRoundEndReveal(scores, rankings) {
const T = window.TIMING?.reveal || {}; const T = window.TIMING?.reveal || {};
// preRevealState may not be set yet if the game_state was deferred
// (e.g., local swap animation was in progress). Wait briefly for it.
if (!this.preRevealState) {
const waitStart = Date.now();
while (!this.preRevealState && Date.now() - waitStart < 3000) {
await this.delay(100);
}
}
const oldState = this.preRevealState; const oldState = this.preRevealState;
const newState = this.postRevealState || this.gameState; const newState = this.postRevealState || this.gameState;
@ -2062,22 +2175,35 @@ class GolfGame {
return; return;
} }
// First, render the game with the OLD state (pre-reveal) so cards show face-down // Compute what needs revealing (before renderGame changes the DOM)
this.gameState = newState;
// But render with pre-reveal card visuals
this.revealAnimationInProgress = true;
// Render game to show current layout (opponents, etc)
this.renderGame();
// Compute what needs revealing
const revealsByPlayer = this.getCardsToReveal(oldState, newState); const revealsByPlayer = this.getCardsToReveal(oldState, newState);
// Get reveal order: knocker first, then clockwise // Get reveal order: knocker first, then clockwise
const knockerId = newState.finisher_id; const knockerId = newState.finisher_id;
const revealOrder = this.getRevealOrder(newState.players, knockerId); const revealOrder = this.getRevealOrder(newState.players, knockerId);
// Initial pause // Wait for the last player's animation (swap/discard/draw) to finish
// so the final play is visible before the reveal sequence starts
const maxWait = 3000;
const start = Date.now();
while (Date.now() - start < maxWait) {
if (!this.isDrawAnimating && !this.opponentSwapAnimation &&
!this.opponentDiscardAnimating && !this.localDiscardAnimating &&
!this.swapAnimationInProgress) {
break;
}
await this.delay(100);
}
// Extra pause so the final play registers visually before we
// re-render the board (renderGame below resets card positions)
await this.delay(T.lastPlayPause || 2500);
// Now render with pre-reveal state (face-down cards) for the reveal sequence
this.gameState = newState;
this.revealAnimationInProgress = true;
this.renderGame();
this.setStatus('Revealing cards...', 'reveal'); this.setStatus('Revealing cards...', 'reveal');
await this.delay(T.initialPause || 500); await this.delay(T.initialPause || 500);
@ -2345,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;
@ -2404,8 +2536,13 @@ class GolfGame {
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
// Set isDrawAnimating to block renderGame from updating discard pile // Set isDrawAnimating to block renderGame from updating discard pile
this.isDrawAnimating = true; this.isDrawAnimating = true;
// Force discard DOM to show the card being drawn before animation starts
// (previous animation may have blocked renderGame from updating it)
if (oldDiscard) {
this.updateDiscardPileDisplay(oldDiscard);
}
console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true'); console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true');
window.drawAnimations.animateDrawDiscard(drawnCard, () => { window.drawAnimations.animateDrawDiscard(oldDiscard || drawnCard, () => {
console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating'); console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating');
this.isDrawAnimating = false; this.isDrawAnimating = false;
onAnimComplete(); onAnimComplete();
@ -2415,7 +2552,7 @@ class GolfGame {
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
this.isDrawAnimating = true; this.isDrawAnimating = true;
console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true'); console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true, drawnCard:', drawnCard ? `${drawnCard.rank} of ${drawnCard.suit}` : 'NULL', 'discardTop:', newDiscard ? `${newDiscard.rank} of ${newDiscard.suit}` : 'EMPTY');
window.drawAnimations.animateDrawDeck(drawnCard, () => { window.drawAnimations.animateDrawDeck(drawnCard, () => {
console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating'); console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating');
this.isDrawAnimating = false; this.isDrawAnimating = false;
@ -2448,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
@ -2496,6 +2633,7 @@ class GolfGame {
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards); const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
if (swappedPosition >= 0 && wasOtherPlayer) { if (swappedPosition >= 0 && wasOtherPlayer) {
console.log('[DEBUG] Swap detected:', { playerId: previousPlayerId, position: swappedPosition, wasFaceUp, newDiscard: newDiscard?.rank });
// Opponent swapped - animate from the actual position that changed // Opponent swapped - animate from the actual position that changed
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp); this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
// Show CPU swap announcement // Show CPU swap announcement
@ -2599,6 +2737,7 @@ class GolfGame {
} }
firePairCelebration(playerId, pos1, pos2) { firePairCelebration(playerId, pos1, pos2) {
this.playSound('pair');
const elements = this.getCardElements(playerId, pos1, pos2); const elements = this.getCardElements(playerId, pos1, pos2);
if (elements.length < 2) return; if (elements.length < 2) return;
@ -2791,21 +2930,9 @@ 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) {
// For opponent swaps, size the held card to match the opponent card const heldRect = window.cardAnimations.getHoldingRect();
// rather than the deck size (default holding rect uses deck dimensions,
// which looks oversized next to small opponent cards on mobile)
const holdingRect = window.cardAnimations.getHoldingRect();
const heldRect = holdingRect ? {
left: holdingRect.left,
top: holdingRect.top,
width: sourceRect.width,
height: sourceRect.height
} : null;
window.cardAnimations.animateUnifiedSwap( window.cardAnimations.animateUnifiedSwap(
discardCard, // handCardData - card going to discard discardCard, // handCardData - card going to discard
@ -2815,23 +2942,32 @@ class GolfGame {
{ {
rotation: sourceRotation, rotation: sourceRotation,
wasHandFaceDown: !wasFaceUp, wasHandFaceDown: !wasFaceUp,
onStart: () => {
sourceCardEl.classList.add('swap-out');
},
onComplete: () => { onComplete: () => {
sourceCardEl.classList.remove('swap-out'); if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating'); console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating');
this.renderGame(); // Don't re-render during reveal animation - it handles its own rendering
if (!this.revealAnimationInProgress) {
this.renderGame();
}
} }
} }
); );
} else { } else {
// Fallback // Fallback
setTimeout(() => { setTimeout(() => {
sourceCardEl.classList.remove('swap-out'); if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
console.log('[DEBUG] Swap animation fallback complete - clearing flags'); console.log('[DEBUG] Swap animation fallback complete - clearing flags');
this.renderGame(); // Don't re-render during reveal animation - it handles its own rendering
if (!this.revealAnimationInProgress) {
this.renderGame();
}
}, 500); }, 500);
} }
} }
@ -2853,6 +2989,11 @@ class GolfGame {
if (window.cardAnimations) { if (window.cardAnimations) {
window.cardAnimations.animateInitialFlip(cardEl, cardData, () => { window.cardAnimations.animateInitialFlip(cardEl, cardData, () => {
this.animatingPositions.delete(key); this.animatingPositions.delete(key);
// Unhide the current card element (may have been rebuilt by renderGame)
const currentCards = this.playerCards.querySelectorAll('.card');
if (currentCards[position]) {
currentCards[position].style.visibility = '';
}
}); });
} else { } else {
// Fallback if card animations not available // Fallback if card animations not available
@ -2957,7 +3098,7 @@ class GolfGame {
this.hideToast(); this.hideToast();
} else { } else {
const remaining = requiredFlips - uniquePositions.length; const remaining = requiredFlips - uniquePositions.length;
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000); this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, 'your-turn', 5000);
} }
return; return;
} }
@ -3055,6 +3196,14 @@ class GolfGame {
} }
showLobby() { showLobby() {
if (window.cardAnimations) {
window.cardAnimations.cancelAll();
}
this.dealAnimationInProgress = false;
this.isDrawAnimating = false;
this.localDiscardAnimating = false;
this.opponentDiscardAnimating = false;
this.opponentSwapAnimation = false;
this.showScreen(this.lobbyScreen); this.showScreen(this.lobbyScreen);
this.lobbyError.textContent = ''; this.lobbyError.textContent = '';
this.roomCode = null; this.roomCode = null;
@ -3127,6 +3276,24 @@ class GolfGame {
`<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`; `<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`;
} }
this.activeRulesBar.classList.remove('hidden'); this.activeRulesBar.classList.remove('hidden');
// Update mobile rules indicator
const mobileRulesBtn = document.getElementById('mobile-rules-btn');
const mobileRulesIcon = document.getElementById('mobile-rules-icon');
const mobileRulesContent = document.getElementById('mobile-rules-content');
if (mobileRulesBtn && mobileRulesIcon && mobileRulesContent) {
const isHouseRules = rules.length > 0;
mobileRulesIcon.textContent = isHouseRules ? '!' : 'RULES';
mobileRulesBtn.classList.toggle('house-rules', isHouseRules);
if (!isHouseRules) {
mobileRulesContent.innerHTML = '<div class="mobile-rules-content-list"><span class="rule-tag standard">Standard Rules</span></div>';
} else {
const tagHtml = (unrankedTag ? '<span class="rule-tag unranked">Unranked</span>' : '') +
rules.map(renderTag).join('');
mobileRulesContent.innerHTML = `<div class="mobile-rules-content-list">${tagHtml}</div>`;
}
}
} }
// V3_14: Map display names to rule keys // V3_14: Map display names to rule keys
@ -3438,15 +3605,6 @@ class GolfGame {
// Toggle game area class for border pulse // Toggle game area class for border pulse
this.gameScreen.classList.add('final-turn-active'); this.gameScreen.classList.add('final-turn-active');
// Calculate remaining turns
const remaining = this.countRemainingTurns();
// Update badge content
const remainingEl = this.finalTurnBadge.querySelector('.final-turn-remaining');
if (remainingEl) {
remainingEl.textContent = remaining === 1 ? '1 turn left' : `${remaining} turns left`;
}
// Show badge // Show badge
this.finalTurnBadge.classList.remove('hidden'); this.finalTurnBadge.classList.remove('hidden');
@ -3542,7 +3700,9 @@ class GolfGame {
const cardHeight = deckRect.height; const cardHeight = deckRect.height;
// Position card centered, overlapping both piles (lower than before) // Position card centered, overlapping both piles (lower than before)
const overlapOffset = cardHeight * 0.35; // More overlap = lower position // On mobile portrait, place held card fully above the deck/discard area
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
const cardLeft = centerX - cardWidth / 2; const cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset; const cardTop = deckRect.top - overlapOffset;
this.heldCardFloating.style.left = `${cardLeft}px`; this.heldCardFloating.style.left = `${cardLeft}px`;
@ -3554,11 +3714,21 @@ class GolfGame {
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`; this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
} }
// Position discard button attached to right side of held card // Position discard button
const buttonLeft = cardLeft + cardWidth; // Right edge of card (no gap) if (isMobilePortrait) {
const buttonTop = cardTop + cardHeight * 0.3; // Vertically centered on card // Below the held card, centered
this.discardBtn.style.left = `${buttonLeft}px`; const btnRect = this.discardBtn.getBoundingClientRect();
this.discardBtn.style.top = `${buttonTop}px`; const buttonLeft = cardLeft + (cardWidth - (btnRect.width || 70)) / 2;
const buttonTop = cardTop + cardHeight + 4;
this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`;
} else {
// Right side of held card (desktop)
const buttonLeft = cardLeft + cardWidth;
const buttonTop = cardTop + cardHeight * 0.3;
this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`;
}
if (card.rank === '★') { if (card.rank === '★') {
this.heldCardFloating.classList.add('joker'); this.heldCardFloating.classList.add('joker');
@ -3604,7 +3774,8 @@ class GolfGame {
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4; const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width; const cardWidth = deckRect.width;
const cardHeight = deckRect.height; const cardHeight = deckRect.height;
const overlapOffset = cardHeight * 0.35; const isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
const cardLeft = centerX - cardWidth / 2; const cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset; const cardTop = deckRect.top - overlapOffset;
@ -3776,14 +3947,19 @@ class GolfGame {
if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds; if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds;
// Show/hide final turn badge with enhanced urgency // Show/hide final turn badge with enhanced urgency
// Note: markKnocker() is deferred until after opponent areas are rebuilt below
const isFinalTurn = this.gameState.phase === 'final_turn'; const isFinalTurn = this.gameState.phase === 'final_turn';
if (isFinalTurn) { if (isFinalTurn) {
this.updateFinalTurnDisplay(); this.gameScreen.classList.add('final-turn-active');
this.finalTurnBadge.classList.remove('hidden');
if (!this.finalTurnAnnounced) {
this.playSound('alert');
this.finalTurnAnnounced = true;
}
} else { } else {
this.finalTurnBadge.classList.add('hidden'); this.finalTurnBadge.classList.add('hidden');
this.gameScreen.classList.remove('final-turn-active'); this.gameScreen.classList.remove('final-turn-active');
this.finalTurnAnnounced = false; this.finalTurnAnnounced = false;
this.clearKnockerMark();
} }
// Toggle not-my-turn class to disable hover effects when it's not player's turn // Toggle not-my-turn class to disable hover effects when it's not player's turn
@ -3805,7 +3981,7 @@ class GolfGame {
: this.gameState.current_player_id; : this.gameState.current_player_id;
const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId); const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId);
if (displayedPlayer && displayedPlayerId !== this.playerId) { if (displayedPlayer && displayedPlayerId !== this.playerId) {
this.setStatus(`${displayedPlayer.name}'s turn`); this.setStatus(`${displayedPlayer.name}'s turn`, 'opponent-turn');
} }
// Update player header (name + score like opponents) // Update player header (name + score like opponents)
@ -3880,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' :
@ -3904,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 &&
@ -4092,7 +4274,13 @@ class GolfGame {
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index)); cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
// V3_13: Bind tooltip events for face-up cards // V3_13: Bind tooltip events for face-up cards
this.bindCardTooltipEvents(cardEl.firstChild, displayCard); this.bindCardTooltipEvents(cardEl.firstChild, displayCard);
this.playerCards.appendChild(cardEl.firstChild); const appendedCard = cardEl.firstChild;
this.playerCards.appendChild(appendedCard);
// Hide card if flip animation overlay is active on this position
if (this.animatingPositions.has(`local-${index}`)) {
appendedCard.style.visibility = 'hidden';
}
}); });
} }
@ -4149,6 +4337,13 @@ class GolfGame {
// Update scoreboard panel // Update scoreboard panel
this.updateScorePanel(); this.updateScorePanel();
// Mark knocker AFTER opponent areas are rebuilt (otherwise innerHTML='' wipes it)
if (this.gameState.phase === 'final_turn') {
this.markKnocker(this.gameState.finisher_id);
} else {
this.clearKnockerMark();
}
// Initialize anime.js hover listeners on newly created cards // Initialize anime.js hover listeners on newly created cards
if (window.cardAnimations) { if (window.cardAnimations) {
window.cardAnimations.initHoverListeners(this.playerCards); window.cardAnimations.initHoverListeners(this.playerCards);
@ -4195,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() {
@ -4232,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}
@ -4242,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) {
@ -4321,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();
@ -4636,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');
@ -4689,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') {
@ -4853,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,15 @@ 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 overlapOffset = cardHeight * 0.35; const isMobilePortrait = document.body.classList.contains('mobile-portrait');
// Overlap percentages: how much the held card peeks above the deck/discard row.
// 48% on mobile (tighter vertical space, needs more overlap to fit),
// 35% on desktop (more breathing room). Tuned by eye, not by math.
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
return { return {
left: centerX - cardWidth / 2, left: centerX - cardWidth / 2,
@ -155,12 +160,20 @@ class CardAnimations {
} }
this.activeAnimations.clear(); this.activeAnimations.clear();
// Remove all animation card elements (including those marked as animating) // Remove all animation overlay elements
document.querySelectorAll('.draw-anim-card').forEach(el => { document.querySelectorAll('.draw-anim-card, .traveling-card, .deal-anim-container').forEach(el => {
delete el.dataset.animating; delete el.dataset.animating;
el.remove(); el.remove();
}); });
// Restore visibility on any cards hidden during animations
document.querySelectorAll('.card[style*="opacity: 0"], .card[style*="opacity:0"]').forEach(el => {
el.style.opacity = '';
});
document.querySelectorAll('.card[style*="visibility: hidden"], .card[style*="visibility:hidden"]').forEach(el => {
el.style.visibility = '';
});
// Restore discard pile visibility if it was hidden during animation // Restore discard pile visibility if it was hidden during animation
const discardPile = document.getElementById('discard'); const discardPile = document.getElementById('discard');
if (discardPile && discardPile.style.opacity === '0') { if (discardPile && discardPile.style.opacity === '0') {
@ -211,6 +224,7 @@ class CardAnimations {
} }
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) { _animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
console.log('[DEBUG] _animateDrawDeckCard called with cardData:', cardData ? `${cardData.rank} of ${cardData.suit}` : 'NULL');
const deckColor = this.getDeckColor(); const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(deckRect, true, deckColor); const animCard = this.createAnimCard(deckRect, true, deckColor);
animCard.dataset.animating = 'true'; // Mark as actively animating animCard.dataset.animating = 'true'; // Mark as actively animating
@ -219,6 +233,9 @@ class CardAnimations {
if (cardData) { if (cardData) {
this.setCardContent(animCard, cardData); this.setCardContent(animCard, cardData);
// Debug: verify what was actually set on the front face
const front = animCard.querySelector('.draw-anim-front');
console.log('[DEBUG] Draw anim card front content:', front?.innerHTML);
} }
this.playSound('draw-deck'); this.playSound('draw-deck');
@ -407,6 +424,7 @@ class CardAnimations {
} }
// Animate initial flip at game start - smooth flip only, no lift // Animate initial flip at game start - smooth flip only, no lift
// Uses overlay sized to match the source card exactly
animateInitialFlip(cardElement, cardData, onComplete) { animateInitialFlip(cardElement, cardData, onComplete) {
if (!cardElement) { if (!cardElement) {
if (onComplete) onComplete(); if (onComplete) onComplete();
@ -420,8 +438,16 @@ class CardAnimations {
const animCard = this.createAnimCard(rect, true, deckColor); const animCard = this.createAnimCard(rect, true, deckColor);
this.setCardContent(animCard, cardData); this.setCardContent(animCard, cardData);
// Hide original card during animation // Match the front face styling to player hand cards (not deck/discard cards)
cardElement.style.opacity = '0'; const front = animCard.querySelector('.draw-anim-front');
if (front) {
front.style.background = 'linear-gradient(145deg, #fff 0%, #f5f5f5 100%)';
front.style.border = '2px solid #ddd';
front.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.3)';
}
// Hide original card during animation (overlay covers it)
cardElement.style.visibility = 'hidden';
const inner = animCard.querySelector('.draw-anim-inner'); const inner = animCard.querySelector('.draw-anim-inner');
const duration = window.TIMING?.card?.flip || 320; const duration = window.TIMING?.card?.flip || 320;
@ -436,16 +462,19 @@ class CardAnimations {
begin: () => this.playSound('flip'), begin: () => this.playSound('flip'),
complete: () => { complete: () => {
animCard.remove(); animCard.remove();
cardElement.style.opacity = '1'; cardElement.style.visibility = '';
if (onComplete) onComplete(); if (onComplete) onComplete();
} }
}); });
// Register a no-op entry so cancelAll() can find and stop this animation.
// The actual anime.js instance doesn't need to be tracked (fire-and-forget),
// but we need SOMETHING in the map or cleanup won't know we're animating.
this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} }); this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} });
} catch (e) { } catch (e) {
console.error('Initial flip animation error:', e); console.error('Initial flip animation error:', e);
animCard.remove(); animCard.remove();
cardElement.style.opacity = '1'; cardElement.style.visibility = '';
if (onComplete) onComplete(); if (onComplete) onComplete();
} }
} }
@ -750,28 +779,40 @@ class CardAnimations {
const id = 'turnPulse'; const id = 'turnPulse';
this.stopTurnPulse(element); this.stopTurnPulse(element);
// Quick shake animation // Quick shake animation - target cards only, not labels
const T = window.TIMING?.turnPulse || {};
const cards = element.querySelectorAll(':scope > .pile-wrapper > .card, :scope > .pile-wrapper > .discard-stack > #discard');
const doShake = () => { const doShake = () => {
if (!this.activeAnimations.has(id)) return; if (!this.activeAnimations.has(id)) return;
anime({ anime({
targets: element, targets: cards.length ? cards : element,
translateX: [0, -8, 8, -6, 4, 0], translateX: [0, -6, 6, -4, 3, 0],
duration: 400, duration: T.duration || 300,
easing: 'easeInOutQuad' easing: 'easeInOutQuad'
}); });
}; };
// Do initial shake, then repeat every 3 seconds // Two-phase timing: wait initialDelay, then shake on an interval.
doShake(); // Edge case: if stopTurnPulse() is called between the timeout firing and
const interval = setInterval(doShake, 3000); // the interval being stored on the entry, the interval would leak. That's
this.activeAnimations.set(id, { interval }); // why we re-check activeAnimations.has(id) after the timeout fires — if
// stop was called during the delay, we bail before creating the interval.
const timeout = setTimeout(() => {
if (!this.activeAnimations.has(id)) return;
doShake();
const interval = setInterval(doShake, T.interval || 3000);
const entry = this.activeAnimations.get(id);
if (entry) entry.interval = interval;
}, T.initialDelay || 5000);
this.activeAnimations.set(id, { timeout });
} }
stopTurnPulse(element) { stopTurnPulse(element) {
const id = 'turnPulse'; const id = 'turnPulse';
const existing = this.activeAnimations.get(id); const existing = this.activeAnimations.get(id);
if (existing) { if (existing) {
if (existing.timeout) clearTimeout(existing.timeout);
if (existing.interval) clearInterval(existing.interval); if (existing.interval) clearInterval(existing.interval);
if (existing.pause) existing.pause(); if (existing.pause) existing.pause();
this.activeAnimations.delete(id); this.activeAnimations.delete(id);
@ -1064,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();
@ -1084,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);
}, 100);
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);
@ -1113,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
@ -1178,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'),
@ -1515,6 +1562,7 @@ class CardAnimations {
// Create container for animation cards // Create container for animation cards
const container = document.createElement('div'); const container = document.createElement('div');
container.className = 'deal-anim-container';
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;'; container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
document.body.appendChild(container); document.body.appendChild(container);

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,9 +16,11 @@
<!-- 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>
<!-- Auth prompt for unauthenticated users --> <!-- Auth prompt for unauthenticated users -->
<div id="auth-prompt" class="auth-prompt"> <div id="auth-prompt" class="auth-prompt">
<p>Log in or sign up to play.</p> <p>Log in or sign up to play.</p>
@ -51,6 +53,8 @@
</div> </div>
<p id="lobby-error" class="error"></p> <p id="lobby-error" class="error"></p>
<footer class="app-footer">v3.1.6 &copy; Aaron D. Lee</footer>
</div> </div>
<!-- Matchmaking Screen --> <!-- Matchmaking Screen -->
@ -78,15 +82,15 @@
<div class="waiting-layout"> <div class="waiting-layout">
<div class="waiting-left-col"> <div class="waiting-left-col">
<div class="players-list"> <div class="players-list">
<h3>Players</h3> <div class="players-list-header">
<ul id="players-list"></ul> <h3>Players</h3>
</div> <div id="cpu-controls-section" class="cpu-controls hidden">
<div id="cpu-controls-section" class="cpu-controls-section hidden"> <span class="cpu-controls-label">CPU:</span>
<h4>Add CPU Opponents</h4> <button id="remove-cpu-btn" class="cpu-ctrl-btn btn-danger" title="Remove CPU"></button>
<div class="cpu-controls"> <button id="add-cpu-btn" class="cpu-ctrl-btn btn-success" title="Add CPU">+</button>
<button id="remove-cpu-btn" class="btn btn-small btn-danger" title="Remove last CPU"></button> </div>
<button id="add-cpu-btn" class="btn btn-small btn-success" title="Add CPU player">+</button>
</div> </div>
<ul id="players-list"></ul>
</div> </div>
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button> <button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
</div> </div>
@ -282,6 +286,8 @@
<p id="waiting-message" class="info">Waiting for host to start the game...</p> <p id="waiting-message" class="info">Waiting for host to start the game...</p>
</div> </div>
<footer class="app-footer">v3.1.6 &copy; Aaron D. Lee</footer>
</div> </div>
<!-- Game Screen --> <!-- Game Screen -->
@ -303,7 +309,6 @@
<div id="final-turn-badge" class="final-turn-badge hidden"> <div id="final-turn-badge" class="final-turn-badge hidden">
<span class="final-turn-icon"></span> <span class="final-turn-icon"></span>
<span class="final-turn-text">FINAL TURN</span> <span class="final-turn-text">FINAL TURN</span>
<span class="final-turn-remaining"></span>
</div> </div>
</div> </div>
<div class="header-col header-col-right"> <div class="header-col header-col-right">
@ -327,18 +332,24 @@
</div> </div>
<span class="held-label">Holding</span> <span class="held-label">Holding</span>
</div> </div>
<div id="deck" class="card card-back"></div> <div class="pile-wrapper">
<div class="discard-stack"> <span class="pile-label">DRAW</span>
<div id="discard" class="card"> <div id="deck" class="card card-back"></div>
<span id="discard-content"></span> </div>
<div class="pile-wrapper">
<span class="pile-label">DISCARD</span>
<div class="discard-stack">
<div id="discard" class="card">
<span id="discard-content"></span>
</div>
<!-- Floating held card (appears larger over discard when holding) -->
<div id="held-card-floating" class="card card-front held-card-floating hidden">
<span id="held-card-floating-content"></span>
</div>
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
</div> </div>
<!-- Floating held card (appears larger over discard when holding) -->
<div id="held-card-floating" class="card card-front held-card-floating hidden">
<span id="held-card-floating-content"></span>
</div>
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
</div> </div>
</div> </div>
</div> </div>
@ -397,16 +408,23 @@
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
</div> </div>
<!-- Mobile bottom bar (hidden on desktop) --> <!-- Mobile bottom bar (hidden on desktop) -->
<div id="mobile-bottom-bar"> <div id="mobile-bottom-bar">
<div class="mobile-round-info">Hole <span id="mobile-current-round">1</span>/<span id="mobile-total-rounds">9</span></div> <div class="mobile-round-info">Hole <span id="mobile-current-round">1</span>/<span id="mobile-total-rounds">9</span></div>
<button class="mobile-bar-btn" data-drawer="standings-panel">Standings</button> <button class="mobile-bar-btn mobile-rules-btn" id="mobile-rules-btn" data-drawer="rules-drawer"><span id="mobile-rules-icon">RULES</span></button>
<button class="mobile-bar-btn" data-drawer="scoreboard">Scores</button> <button class="mobile-bar-btn" data-drawer="standings-panel">Scorecard</button>
<button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button> <button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button>
</div> </div>
<!-- Mobile rules drawer -->
<div id="rules-drawer" class="side-panel rules-drawer-panel">
<h4>Active Rules</h4>
<div id="mobile-rules-content"></div>
</div>
<!-- Drawer backdrop for mobile --> <!-- Drawer backdrop for mobile -->
<div id="drawer-backdrop" class="drawer-backdrop"></div> <div id="drawer-backdrop" class="drawer-backdrop"></div>
</div> </div>
@ -414,9 +432,8 @@
<!-- Rules Screen --> <!-- Rules Screen -->
<div id="rules-screen" class="screen"> <div id="rules-screen" class="screen">
<div class="rules-container"> <div class="rules-container">
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
<div class="rules-header"> <div class="rules-header">
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
<h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1> <h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1>
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p> <p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
</div> </div>
@ -732,9 +749,8 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<!-- Leaderboard Screen --> <!-- Leaderboard Screen -->
<div id="leaderboard-screen" class="screen"> <div id="leaderboard-screen" class="screen">
<div class="leaderboard-container"> <div class="leaderboard-container">
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">&laquo; Back</button>
<div class="leaderboard-header"> <div class="leaderboard-header">
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">&laquo; Back</button>
<h1>Leaderboard</h1> <h1>Leaderboard</h1>
<p class="leaderboard-subtitle">Top players ranked by performance</p> <p class="leaderboard-subtitle">Top players ranked by performance</p>
</div> </div>
@ -877,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">

File diff suppressed because it is too large Load Diff

View File

@ -77,6 +77,7 @@ const TIMING = {
// V3_03: Round end reveal timing // V3_03: Round end reveal timing
reveal: { reveal: {
lastPlayPause: 2000, // Pause after last play animation before reveals
voluntaryWindow: 2000, // Time for players to flip their own cards voluntaryWindow: 2000, // Time for players to flip their own cards
initialPause: 250, // Pause before auto-reveals start initialPause: 250, // Pause before auto-reveals start
cardStagger: 50, // Between cards in same hand cardStagger: 50, // Between cards in same hand
@ -128,6 +129,13 @@ const TIMING = {
pulseDelay: 200, // Delay before card appears (pulse visible first) pulseDelay: 200, // Delay before card appears (pulse visible first)
}, },
// Turn pulse (deck shake)
turnPulse: {
initialDelay: 5000, // Delay before first shake
interval: 5400, // Time between shakes
duration: 300, // Shake animation duration
},
// V3_17: Knock notification // V3_17: Knock notification
knock: { knock: {
statusDuration: 2500, // How long the knock status message persists statusDuration: 2500, // How long the knock status message persists

View File

@ -28,11 +28,19 @@ services:
- RESEND_API_KEY=${RESEND_API_KEY:-} - RESEND_API_KEY=${RESEND_API_KEY:-}
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>} - EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
- SENTRY_DSN=${SENTRY_DSN:-} - SENTRY_DSN=${SENTRY_DSN:-}
- ENVIRONMENT=production - ENVIRONMENT=${ENVIRONMENT:-production}
- LOG_LEVEL=INFO - LOG_LEVEL=${LOG_LEVEL:-WARNING}
- LOG_LEVEL_GAME=${LOG_LEVEL_GAME:-}
- LOG_LEVEL_AI=${LOG_LEVEL_AI:-}
- LOG_LEVEL_HANDLERS=${LOG_LEVEL_HANDLERS:-}
- LOG_LEVEL_ROOM=${LOG_LEVEL_ROOM:-}
- LOG_LEVEL_AUTH=${LOG_LEVEL_AUTH:-}
- LOG_LEVEL_STORES=${LOG_LEVEL_STORES:-}
- BASE_URL=${BASE_URL:-https://golf.example.com} - BASE_URL=${BASE_URL:-https://golf.example.com}
- RATE_LIMIT_ENABLED=true - RATE_LIMIT_ENABLED=true
- INVITE_ONLY=true - INVITE_ONLY=true
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-} - BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-} - BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
- MATCHMAKING_ENABLED=true - MATCHMAKING_ENABLED=true
@ -61,6 +69,15 @@ services:
- "traefik.http.routers.golf.entrypoints=websecure" - "traefik.http.routers.golf.entrypoints=websecure"
- "traefik.http.routers.golf.tls=true" - "traefik.http.routers.golf.tls=true"
- "traefik.http.routers.golf.tls.certresolver=letsencrypt" - "traefik.http.routers.golf.tls.certresolver=letsencrypt"
# www -> bare domain redirect
- "traefik.http.routers.golf-www.rule=Host(`www.${DOMAIN:-golf.example.com}`)"
- "traefik.http.routers.golf-www.entrypoints=websecure"
- "traefik.http.routers.golf-www.tls=true"
- "traefik.http.routers.golf-www.tls.certresolver=letsencrypt"
- "traefik.http.routers.golf-www.middlewares=www-redirect"
- "traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\\.(.+)"
- "traefik.http.middlewares.www-redirect.redirectregex.replacement=https://$${1}"
- "traefik.http.middlewares.www-redirect.redirectregex.permanent=true"
- "traefik.http.services.golf.loadbalancer.server.port=8000" - "traefik.http.services.golf.loadbalancer.server.port=8000"
# WebSocket sticky sessions # WebSocket sticky sessions
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true" - "traefik.http.services.golf.loadbalancer.sticky.cookie=true"

148
docker-compose.staging.yml Normal file
View File

@ -0,0 +1,148 @@
# Staging Docker Compose for Golf Card Game
#
# Mirrors production but with reduced memory limits for 512MB droplet.
#
# Usage:
# docker compose -f docker-compose.staging.yml up -d --build
services:
app:
build:
context: .
dockerfile: Dockerfile
environment:
- POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
- DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
- REDIS_URL=redis://redis:6379
- SECRET_KEY=${SECRET_KEY}
- RESEND_API_KEY=${RESEND_API_KEY:-}
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
- SENTRY_DSN=${SENTRY_DSN:-}
- ENVIRONMENT=${ENVIRONMENT:-staging}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- LOG_LEVEL_GAME=${LOG_LEVEL_GAME:-}
- LOG_LEVEL_AI=${LOG_LEVEL_AI:-}
- LOG_LEVEL_HANDLERS=${LOG_LEVEL_HANDLERS:-}
- LOG_LEVEL_ROOM=${LOG_LEVEL_ROOM:-}
- LOG_LEVEL_AUTH=${LOG_LEVEL_AUTH:-}
- LOG_LEVEL_STORES=${LOG_LEVEL_STORES:-}
- BASE_URL=${BASE_URL:-https://staging.golfcards.club}
- RATE_LIMIT_ENABLED=false
- INVITE_ONLY=true
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
- MATCHMAKING_ENABLED=true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
deploy:
replicas: 1
restart_policy:
condition: on-failure
max_attempts: 3
resources:
limits:
memory: 128M
reservations:
memory: 48M
networks:
- internal
- web
labels:
- "traefik.enable=true"
- "traefik.docker.network=golfgame_web"
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-staging.golfcards.club}`)"
- "traefik.http.routers.golf.entrypoints=websecure"
- "traefik.http.routers.golf.tls=true"
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
- "traefik.http.services.golf.loadbalancer.server.port=8000"
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"
- "traefik.http.services.golf.loadbalancer.sticky.cookie.name=golf_server"
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: golf
POSTGRES_USER: golf
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U golf -d golf"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
deploy:
resources:
limits:
memory: 96M
reservations:
memory: 48M
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 16mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
deploy:
resources:
limits:
memory: 32M
reservations:
memory: 16M
traefik:
image: traefik:v3.6
environment:
- DOCKER_API_VERSION=1.44
command:
- "--api.dashboard=true"
- "--api.insecure=true"
- "--accesslog=true"
- "--log.level=WARN"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks:
- web
deploy:
resources:
limits:
memory: 48M
volumes:
postgres_data:
redis_data:
letsencrypt:
networks:
internal:
driver: bridge
web:
driver: bridge

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

@ -0,0 +1,57 @@
# V3.18: PostgreSQL Game Data Storage Efficiency
**Status:** Planning
**Priority:** Medium
**Category:** Infrastructure / Performance
## Problem
Per-move game logging stores full `hand_state` and `visible_opponents` JSONB on every move. For a typical 6-player, 9-hole game this generates significant redundant data since most of each player's hand doesn't change between moves.
## Areas to Investigate
### 1. Delta Encoding for Move Data
Store only what changed from the previous move instead of full state snapshots.
- First move of each round stores full state (baseline)
- Subsequent moves store only changed positions (e.g., `{"player_0": {"pos_2": "5H"}}`)
- Replay reconstruction applies deltas sequentially
- Trade-off: simpler queries vs. storage savings
### 2. PostgreSQL TOAST and Compression
- TOAST already compresses large JSONB values automatically
- Measure actual on-disk size vs. logical size for typical game data
- Consider whether explicit compression (e.g., storing gzipped blobs) adds meaningful savings over TOAST
### 3. Retention Policy
- Archive completed games older than N days to a separate table or cold storage
- Configurable retention period via env var (e.g., `GAME_LOG_RETENTION_DAYS`)
- Keep aggregate stats even after pruning raw move data
### 4. Move Logging Toggle
- Env var `GAME_LOGGING_ENABLED=true|false` to disable move-level logging entirely
- Useful for non-analysis environments (dev, load testing)
- Game outcomes and stats would still be recorded
### 5. Batch Inserts
- Buffer moves in memory and flush periodically instead of per-move INSERT
- Reduces database round-trips during active games
- Risk: data loss if server crashes mid-game (acceptable for non-critical move logs)
## Measurements Needed
Before optimizing, measure current impact:
- Average JSONB size per move (bytes)
- Average moves per game
- Total storage per game (moves + overhead)
- Query patterns: how often is per-move data actually read?
## Dependencies
- None (independent infrastructure improvement)

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"

23
scripts/deploy-staging.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
set -e
DROPLET="root@129.212.150.189"
REMOTE_DIR="/opt/golfgame"
echo "Syncing to staging ($DROPLET)..."
rsync -az --delete \
--exclude='.git' \
--exclude='__pycache__' \
--exclude='node_modules' \
--exclude='.env' \
--exclude='internal/' \
server/ "$DROPLET:$REMOTE_DIR/server/"
rsync -az --delete \
--exclude='.git' \
--exclude='__pycache__' \
--exclude='node_modules' \
client/ "$DROPLET:$REMOTE_DIR/client/"
echo "Rebuilding app container..."
ssh $DROPLET "cd $REMOTE_DIR && docker compose -f docker-compose.staging.yml up -d --build app"
echo "Staging deploy complete."

View File

@ -7,6 +7,24 @@ PORT=8000
DEBUG=true DEBUG=true
LOG_LEVEL=DEBUG LOG_LEVEL=DEBUG
# Per-module log level overrides (optional)
# These override LOG_LEVEL for specific modules.
# LOG_LEVEL_GAME=DEBUG # Core game logic
# LOG_LEVEL_AI=DEBUG # AI decisions (very verbose at DEBUG)
# LOG_LEVEL_HANDLERS=DEBUG # WebSocket message handlers
# LOG_LEVEL_ROOM=DEBUG # Room/lobby management
# LOG_LEVEL_AUTH=DEBUG # Auth stack (auth, routers.auth, services.auth_service)
# LOG_LEVEL_STORES=DEBUG # Database/Redis operations
# --- Preset examples ---
# Staging (debug game logic, quiet everything else):
# LOG_LEVEL=INFO
# LOG_LEVEL_GAME=DEBUG
# LOG_LEVEL_AI=DEBUG
#
# Production (minimal logging):
# LOG_LEVEL=WARNING
# Environment (development, staging, production) # Environment (development, staging, production)
# Affects logging format, security headers (HSTS), etc. # Affects logging format, security headers (HSTS), etc.
ENVIRONMENT=development ENVIRONMENT=development

View File

@ -43,8 +43,9 @@ CPU_TIMING = {
# Delay before CPU "looks at" the discard pile # Delay before CPU "looks at" the discard pile
"initial_look": (0.3, 0.5), "initial_look": (0.3, 0.5),
# Brief pause after draw broadcast - let draw animation complete # Brief pause after draw broadcast - let draw animation complete
# Must be >= client draw animation duration (~1s for deck, ~0.4s for discard) # Must be >= client draw animation duration (~1.09s for deck, ~0.4s for discard)
"post_draw_settle": 1.1, # Extra margin prevents swap message from arriving before draw flip completes
"post_draw_settle": 1.3,
# Consideration time after drawing (before swap/discard decision) # Consideration time after drawing (before swap/discard decision)
"post_draw_consider": (0.2, 0.4), "post_draw_consider": (0.2, 0.4),
# Variance multiplier range for chaotic personality players # Variance multiplier range for chaotic personality players
@ -54,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),
} }
@ -799,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
@ -1030,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
@ -1251,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)
@ -1301,12 +1304,28 @@ class GolfAI:
max_acceptable_go_out = 14 + int(profile.aggression * 4) max_acceptable_go_out = 14 + int(profile.aggression * 4)
# Check opponent scores - don't go out if we'd lose badly
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
# Aggressive players tolerate a bigger gap; conservative ones less
opponent_margin = 4 + int(profile.aggression * 4) # 4-8 points
opponent_cap = opponent_min + opponent_margin
# Use the more restrictive of the two thresholds
effective_max = min(max_acceptable_go_out, opponent_cap)
ai_log(f" Go-out safety check: visible_base={visible_score}, " ai_log(f" Go-out safety check: visible_base={visible_score}, "
f"score_if_swap={score_if_swap}, score_if_flip={score_if_flip}, " f"score_if_swap={score_if_swap}, score_if_flip={score_if_flip}, "
f"max_acceptable={max_acceptable_go_out}") f"max_acceptable={max_acceptable_go_out}, opponent_min={opponent_min}, "
f"opponent_cap={opponent_cap}, effective_max={effective_max}")
# High-card safety: don't swap 8+ into hidden position unless it makes a pair
creates_pair = (last_partner.face_up and last_partner.rank == drawn_card.rank)
if drawn_value >= HIGH_CARD_THRESHOLD and not creates_pair:
ai_log(f" >> GO-OUT: high card ({drawn_value}) into hidden, preferring flip")
return None # Fall through to normal scoring (will flip)
# If BOTH options are bad, choose the better one # If BOTH options are bad, choose the better one
if score_if_swap > max_acceptable_go_out and score_if_flip > max_acceptable_go_out: if score_if_swap > effective_max and score_if_flip > effective_max:
if score_if_swap <= score_if_flip: if score_if_swap <= score_if_flip:
ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) " ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) "
f"<= flip ({score_if_flip}), forcing swap") f"<= flip ({score_if_flip}), forcing swap")
@ -1322,7 +1341,7 @@ class GolfAI:
return None return None
# If swap is good, prefer it (known outcome vs unknown flip) # If swap is good, prefer it (known outcome vs unknown flip)
elif score_if_swap <= max_acceptable_go_out and score_if_swap <= score_if_flip: elif score_if_swap <= effective_max and score_if_swap <= score_if_flip:
ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}") ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}")
return last_pos return last_pos
@ -1344,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
@ -1739,9 +1762,23 @@ class GolfAI:
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
projected_score = visible_score + expected_hidden_total projected_score = visible_score + expected_hidden_total
# Hard cap: never knock with projected score > 10
if projected_score > 10:
ai_log(f" Knock rejected: projected score {projected_score:.1f} > 10 hard cap")
return False
# Tighter threshold: range 5 to 9 based on aggression # Tighter threshold: range 5 to 9 based on aggression
max_acceptable = 5 + int(profile.aggression * 4) max_acceptable = 5 + int(profile.aggression * 4)
# Check opponent threat - don't knock if an opponent likely beats us
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
if opponent_min < projected_score:
# Opponent is likely beating us - penalize threshold
threat_margin = projected_score - opponent_min
max_acceptable -= int(threat_margin * 0.75)
ai_log(f" Knock threat penalty: opponent est {opponent_min}, "
f"margin {threat_margin:.1f}, threshold now {max_acceptable}")
# Exception: if all opponents are showing terrible scores, relax threshold # Exception: if all opponents are showing terrible scores, relax threshold
all_opponents_bad = all( all_opponents_bad = all(
sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25 sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25
@ -1752,12 +1789,14 @@ class GolfAI:
if projected_score <= max_acceptable: if projected_score <= max_acceptable:
# Scale knock chance by how good the projected score is # Scale knock chance by how good the projected score is
if projected_score <= 5: if projected_score <= 4:
knock_chance = profile.aggression * 0.3 # Max 30% knock_chance = profile.aggression * 0.35 # Max 35%
elif projected_score <= 7: elif projected_score <= 6:
knock_chance = profile.aggression * 0.15 # Max 15% knock_chance = profile.aggression * 0.15 # Max 15%
else: elif projected_score <= 8:
knock_chance = profile.aggression * 0.05 # Max 5% (very rare) knock_chance = profile.aggression * 0.06 # Max 6%
else: # 9-10
knock_chance = profile.aggression * 0.02 # Max 2% (very rare)
if random.random() < knock_chance: if random.random() < knock_chance:
ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})") ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})")
@ -1932,9 +1971,14 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga
async def process_cpu_turn( async def process_cpu_turn(
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None,
reveal_callback=None,
) -> None: ) -> None:
"""Process a complete turn for a CPU player.""" """Process a complete turn for a CPU player.
May raise asyncio.CancelledError if the game is ended mid-turn.
The caller (check_and_run_cpu_turn) handles cancellation.
"""
import asyncio import asyncio
from services.game_logger import get_logger from services.game_logger import get_logger
@ -1962,10 +2006,8 @@ async def process_cpu_turn(
await asyncio.sleep(thinking_time) await asyncio.sleep(thinking_time)
ai_log(f"{cpu_player.name} done thinking, making decision") ai_log(f"{cpu_player.name} done thinking, making decision")
# Check if we should try to go out early
GolfAI.should_go_out_early(cpu_player, game, profile)
# Check if we should knock early (flip all remaining cards at once) # Check if we should knock early (flip all remaining cards at once)
# (Opponent threat logic consolidated into should_knock_early)
if GolfAI.should_knock_early(game, cpu_player, profile): if GolfAI.should_knock_early(game, cpu_player, profile):
if game.knock_early(cpu_player.id): if game.knock_early(cpu_player.id):
_log_cpu_action(logger, game_id, cpu_player, game, _log_cpu_action(logger, game_id, cpu_player, game,
@ -2048,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)
# Adjust dealer_idx if needed after removal if self.players:
if self.players and self.dealer_idx >= len(self.players): # Adjust dealer_idx if needed after removal
self.dealer_idx = 0 if self.dealer_idx >= len(self.players):
self.dealer_idx = 0
# Adjust current_player_index after removal
if i < self.current_player_index:
# Removed player was before current: shift back
self.current_player_index -= 1
elif self.current_player_index >= len(self.players):
# Removed player was at/after current and index is now OOB
self.current_player_index = 0
self._emit("player_left", player_id=player_id, reason=reason) 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:
@ -229,18 +232,19 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
"game_state": game_state, "game_state": game_state,
}) })
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
positions = data.get("positions", []) positions = data.get("positions", [])
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions): if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -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:
@ -297,12 +315,13 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: 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
@ -329,12 +348,12 @@ async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_s
}) })
else: else:
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
else: else:
logger.debug("Player discarded, waiting 0.5s before CPU turn") logger.debug("Player discarded, waiting 0.5s before CPU turn")
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
logger.debug("Post-discard delay complete, checking for CPU turn") logger.debug("Post-discard delay complete, checking for CPU turn")
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None: async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
@ -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:
@ -364,12 +384,13 @@ async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game
) )
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: 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)
@ -380,12 +401,13 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game
) )
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: 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:
@ -400,12 +422,13 @@ async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast
) )
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: 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)
@ -418,12 +441,13 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga
) )
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: 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:
@ -443,7 +467,7 @@ async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_gam
"game_state": game_state, "game_state": game_state,
}) })
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
else: else:
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
@ -467,12 +491,22 @@ async def handle_leave_game(data: dict, ctx: ConnectionContext, *, handle_player
async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None: async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id) room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host: if not room_player or not room_player.is_host:
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"}) await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
return return
# Cancel any running CPU turn task so the game ends immediately
if ctx.current_room.cpu_turn_task:
ctx.current_room.cpu_turn_task.cancel()
try:
await ctx.current_room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
ctx.current_room.cpu_turn_task = None
await ctx.current_room.broadcast({ await ctx.current_room.broadcast({
"type": "game_ended", "type": "game_ended",
"reason": "Host ended the game", "reason": "Host ended the game",

View File

@ -148,6 +148,39 @@ class DevelopmentFormatter(logging.Formatter):
return output return output
# Per-module log level overrides via env vars.
# Key: env var suffix, Value: list of Python logger names to apply to.
MODULE_LOGGER_MAP = {
"GAME": ["game"],
"AI": ["ai"],
"HANDLERS": ["handlers"],
"ROOM": ["room"],
"AUTH": ["auth", "routers.auth", "services.auth_service"],
"STORES": ["stores"],
}
def _apply_module_overrides() -> dict[str, str]:
"""
Apply per-module log level overrides from LOG_LEVEL_{MODULE} env vars.
Returns:
Dict of module name -> level for any overrides that were applied.
"""
active = {}
for module, logger_names in MODULE_LOGGER_MAP.items():
env_val = os.environ.get(f"LOG_LEVEL_{module}", "").upper()
if not env_val:
continue
level = getattr(logging, env_val, None)
if level is None:
continue
active[module] = env_val
for name in logger_names:
logging.getLogger(name).setLevel(level)
return active
def setup_logging( def setup_logging(
level: str = "INFO", level: str = "INFO",
environment: str = "development", environment: str = "development",
@ -182,12 +215,19 @@ def setup_logging(
logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger("websockets").setLevel(logging.WARNING)
logging.getLogger("asyncio").setLevel(logging.WARNING) logging.getLogger("asyncio").setLevel(logging.WARNING)
# Apply per-module overrides from env vars
overrides = _apply_module_overrides()
# Log startup # Log startup
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info( logger.info(
f"Logging configured: level={level}, environment={environment}", f"Logging configured: level={level}, environment={environment}",
extra={"level": level, "environment": environment}, extra={"level": level, "environment": environment},
) )
if overrides:
logger.info(
f"Per-module log level overrides: {', '.join(f'{m}={l}' for m, l in overrides.items())}",
)
class ContextLogger(logging.LoggerAdapter): class ContextLogger(logging.LoggerAdapter):

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": {
@ -705,8 +818,13 @@ async def broadcast_game_state(room: Room):
}) })
async def check_and_run_cpu_turn(room: Room): def check_and_run_cpu_turn(room: Room):
"""Check if current player is CPU and run their turn.""" """Check if current player is CPU and start their turn as a background task.
The CPU turn chain runs as a fire-and-forget asyncio.Task stored on
room.cpu_turn_task. This allows the WebSocket message loop to remain
responsive so that end_game/leave messages can cancel the task immediately.
"""
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN): if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return return
@ -718,25 +836,77 @@ async def check_and_run_cpu_turn(room: Room):
if not room_player or not room_player.is_cpu: if not room_player or not room_player.is_cpu:
return return
# Brief pause before CPU starts - animations are faster now task = asyncio.create_task(_run_cpu_chain(room))
await asyncio.sleep(0.25) room.cpu_turn_task = task
# Run CPU turn def _on_done(t: asyncio.Task):
async def broadcast_cb(): # Clear the reference when the task finishes (success, cancel, or error)
await broadcast_game_state(room) if room.cpu_turn_task is t:
room.cpu_turn_task = None
if not t.cancelled() and t.exception():
logger.error(f"CPU turn task error in room {room.code}: {t.exception()}")
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id) task.add_done_callback(_on_done)
# Check if next player is also CPU (chain CPU turns)
await check_and_run_cpu_turn(room) async def _run_cpu_chain(room: Room):
"""Run consecutive CPU turns until a human player's turn or game ends."""
while True:
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return
current = room.game.current_player()
if not current:
return
room_player = room.get_player(current.id)
if not room_player or not room_player.is_cpu:
return
room.touch()
# Brief pause before CPU starts. Without this, the CPU's draw message arrives
# before the client has finished processing the previous turn's state update,
# and animations overlap. 0.25s is enough for the client to settle.
await asyncio.sleep(0.25)
# Run CPU turn
async def broadcast_cb():
await broadcast_game_state(room)
async def reveal_cb(player_id, position, card_data):
reveal_msg = {
"type": "card_revealed",
"player_id": player_id,
"position": position,
"card": card_data,
}
for pid, p in room.players.items():
if not p.is_cpu and p.websocket:
try:
await p.websocket.send_json(reveal_msg)
except Exception:
pass
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id, reveal_callback=reveal_cb)
async def handle_player_leave(room: Room, player_id: str): async def handle_player_leave(room: Room, player_id: str):
"""Handle a player leaving a room.""" """Handle a player leaving a room."""
# Cancel any running CPU turn task before cleanup
if room.cpu_turn_task:
room.cpu_turn_task.cancel()
try:
await room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
room.cpu_turn_task = None
room_code = room.code room_code = room.code
room_player = room.remove_player(player_id) room_player = room.remove_player(player_id)
# 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()):
@ -773,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,11 +81,15 @@ 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}"
allowed, info = await self.limiter.is_allowed(full_key, limit, window) is_auth_endpoint = path.startswith("/api/auth")
if is_auth_endpoint:
allowed, info = await self.limiter.is_allowed_strict(full_key, limit, window)
else:
allowed, info = await self.limiter.is_allowed(full_key, limit, window)
# Build response # Build response
if allowed: if allowed:

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
@ -69,6 +70,12 @@ class Room:
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1}) settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
game_log_id: Optional[str] = None game_log_id: Optional[str] = None
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock) game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
cpu_turn_task: Optional[asyncio.Task] = None
last_activity: float = field(default_factory=time.time)
def touch(self) -> None:
"""Update last_activity timestamp to mark room as active."""
self.last_activity = time.time()
def add_player( def add_player(
self, self,
@ -91,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,
@ -166,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)