177 Commits

Author SHA1 Message Date
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
adlee-was-taken
a1d8a127dc Add bottom margin to mobile player area for border breathing room
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:43:34 -05:00
adlee-was-taken
65b4af9831 Hide mobile bottom bar when drawer panels are open
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:38:14 -05:00
adlee-was-taken
8942238f9c Make mobile bottom bar flow in document instead of position fixed
Remove position:fixed from the bottom bar and make it a flex-shrink:0
child of the game screen flex column. This guarantees the game layout
gets exactly the remaining viewport height with no overlap, regardless
of how the browser calculates viewport units.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:35:09 -05:00
adlee-was-taken
7dc27fe882 Use window.innerHeight for mobile viewport height on Chrome Android
Set --app-height CSS custom property from window.innerHeight via JS,
which is the only reliable way to get the actual visible viewport on
Chrome Android. Falls back to 100vh if JS hasn't loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:28:58 -05:00
adlee-was-taken
097f241c6f Fix Chrome Android viewport overflow with position fixed game screen
Use position:fixed with inset:0 for the game screen instead of
height-based sizing. This bypasses the Chrome Android 100vh bug where
vh includes space behind the dynamic URL bar. Also adds
-webkit-fill-available fallback on body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:26:50 -05:00
adlee-was-taken
1c5d6b09e2 Fix Chrome Android player hand overlapping bottom bar
Add 100vh fallback before 100dvh, max-height constraints on every flex
container in the layout chain, and explicit flex-basis 0% to prevent
Chrome from letting flex children grow beyond viewport bounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:22:07 -05:00
adlee-was-taken
889f8ce1cd Fix mobile opponents to fit 3 per row with calc-based flex-basis
Replaces fixed 120px width with calc((100% - 20px) / 3) so 3 opponents
always fit per row regardless of viewport width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:14:43 -05:00
adlee-was-taken
b4e9390f16 Show both KNOCKED and LOW SCORE badges when knocker wins hole
Also fix opponent areas shifting between rows on mobile by giving them
a fixed 120px width so name/score changes don't cause reflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:54:18 -05:00
adlee-was-taken
94e2bdaaa7 Move player dealer chip to top-left corner on mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:33:04 -05:00
adlee-was-taken
d322403764 Shrink and reposition player dealer chip on mobile
Reduces from 38px to 22px and pulls offset from -10px to -4px so it
no longer overlaps the bottom bar buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:24:00 -05:00
adlee-was-taken
9c6ce255bd Fix mobile layout overflow into bottom bar
Add bottom padding to game-table to reserve space for the fixed bottom
bar, and overflow:hidden on player-row so content respects flex bounds.
Also centers draw pile with equal spacing and adds dealer chip clearance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:19:26 -05:00
adlee-was-taken
06d52a9d2c Add top padding to mobile lobby screen to clear auth bar from logo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:10:39 -05:00
adlee-was-taken
76cbd4ae22 Increase mobile bottom bar button fonts by 40% and status message by 20%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:08:20 -05:00
adlee-was-taken
9b04bc85c2 Fix mobile bottom bar pinning by scaling elements individually
Remove transform: scale(0.75) from the bar container which broke the
full-width layout and margin-auto pinning. Instead shrink font sizes
and padding on individual buttons and round info by ~25%.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:06:11 -05:00
adlee-was-taken
2ccbfc8120 Increase mobile portrait status message font to match player names
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:03:19 -05:00
adlee-was-taken
1678077c53 Raise mobile bottom bar z-index and shrink by 25%
Bumps z-index from 500 to 900 so the bottom bar stays above side panel
drawers (600) but below card animation overlays (1000). Scales the bar
to 75% in mobile portrait to reduce visual footprint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:01:46 -05:00
adlee-was-taken
0dbb2d13ed Shrink mobile player/deck cards and widen opponent hand spacing
Reduces card sizes from 72×101 to 64×90 on mobile portrait to prevent
overlap with bottom bar when opponents wrap to 2 rows. Increases
horizontal gap between opponent hands from 4px to 10px for better
readability of player chips.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:58:40 -05:00
adlee-was-taken
82e5226acc Update email from address and add deploy script
- Fix EMAIL_FROM to use contact.golfcards.club subdomain
- Add scripts/deploy.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:36:38 -05:00
adlee-was-taken
b81874f5ba Fix CSP blocking admin panel buttons by removing inline onclick handlers
Replace onclick attributes with data-action/data-id attributes and
use event delegation. CSP script-src 'self' blocks inline handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:56:45 -05:00
adlee-was-taken
797d1e0280 Add copy invite link button and auto-populate invite code from URL
Admin panel gets "Copy Link" button on active invites that copies
a signup URL with ?invite= param. Client auto-opens signup form
with invite code pre-filled when visiting that link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:54:06 -05:00
adlee-was-taken
538ca51ba5 Add forgot/reset password UI and Resend email config
- Forgot password form in auth modal with email input
- Reset password form handles token from email link
- /reset-password route serves index.html for SPA
- EMAIL_FROM env var in docker-compose
- Success/error feedback for both flows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:51:58 -05:00
adlee-was-taken
9339abe19c Pin Hole indicator left and End Game right in mobile bottom bar
- Hole indicator: margin-right auto, pill background, brighter text (0.9 opacity)
- End Game: margin-left auto, pinned to right edge
- Standings/Scores stay centered between them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:43:02 -05:00
adlee-was-taken
ac2d53b404 Move Hole indicator and End Game button to mobile bottom bar
- Add round info and leave button to mobile-bottom-bar HTML
- Hide .round-info and #leave-game-btn from header on mobile
- Style round info as subtle text, leave button as red-tinted pill
- Slim down bottom bar: smaller gaps/padding to fit 4 items
- Sync round numbers and leave text via JS (renderGame + bindEvents)
- Frees up header space, reduces mobile crowding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:40:24 -05:00
adlee-was-taken
7e108a71f9 Max out mobile opponent text sizes for readability
- Card rank/suit: 0.8rem -> 1.05rem with tight line-height 1.05
- Names: 0.75rem -> 0.85rem
- Showing score: 0.7rem -> 0.85rem
- Short screen cards: 0.6rem -> 0.8rem
- Fills the 35x49px cards without overflowing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:28:21 -05:00
adlee-was-taken
7642d120e2 Increase mobile opponent text sizes for readability
- Opponent card rank/suit: 0.65rem -> 0.8rem (25% bump)
- Opponent names (h4): 0.6rem -> 0.75rem
- Opponent showing score: 0.55rem -> 0.7rem
- Short screen card font: 0.5rem -> 0.6rem
- Animation overlay font (JS 12.25px) closely matches CSS (12.8px)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:26:14 -05:00
adlee-was-taken
6ba0639d51 Fix opponent row fitting 3 per row on mobile
- Remove excessive 44px bottom padding on game-table (was eating vertical space)
- Tighten opponents-row: reduce gap 6px->4px, side padding 8px->4px
- Reduce opponent-area side padding 5px->4px
- Allow opponent areas to shrink (remove flex-shrink: 0)
- 3 opponents now fit in ~367px, well within 412px Pixel 10 Pro

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:23:19 -05:00
adlee-was-taken
3b9522fec3 Fix mobile bottom bar: pin to viewport bottom, remove background
- Position fixed bottom:0 so buttons are always at the very bottom
- Remove dark background that was picking up the green felt color
- Add bottom padding to game-table so player cards aren't hidden behind bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:21:32 -05:00
adlee-was-taken
aa2093d6c8 Polish mobile bottom bar buttons and drawer transitions
- Redesign bottom bar buttons as pill-shaped with subtle glass border
- Active state: warm gradient fill with glow shadow
- Tap feedback: scale(0.95) press effect
- Drawer panels: iOS-style cubic-bezier spring curve, drop shadow
- Backdrop transition matches drawer timing
- Darker bottom bar background with softer border for depth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:17:53 -05:00
adlee-was-taken
3227c92d63 Wrap opponent row at 3 per line and bump opponent card size 10%
- Change opponents-row from nowrap to flex-wrap: wrap (max 3+2 layout)
- Opponent cards: 32x45px -> 35x49px, font 0.6 -> 0.65rem
- Short screen: 26x36px -> 29x40px, font 0.45 -> 0.5rem
- 3 opponents at 35px fits 369px, well within 375px iPhone width

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:16:40 -05:00
adlee-was-taken
b7b21d8378 Bump version to 3.1.1, add mobile portrait layout documentation
- Update version from 2.0.1 to 3.1.1 in pyproject.toml and server/main.py
- Add V3_17_MOBILE_PORTRAIT_LAYOUT.md documenting all mobile improvements:
  responsive layout, animation sizing fixes, compact header, bottom drawers
- Add V3_17 entry to V3 master plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:14:06 -05:00
adlee-was-taken
fb3bd53b0a Fix mobile animation card sizing and layout polish
- Fix animation overlay cards rendering at wrong size: base .card CSS
  (clamp 65px min) was overriding the inline dimensions set by JS.
  Add !important to .draw-anim-front/.draw-anim-back width/height: 100%
  so overlays always match their parent container size.
- Size opponent swap held card to match opponent card dimensions instead
  of defaulting to deck size (looked oversized on mobile)
- Shrink dealer chip on mobile (38px -> 20px) to fit opponent areas
- Make header more compact: smaller fonts, tighter gaps, nowrap on badges
- Bump deck/discard to 72x101px to match player card size on mobile
- Add spacing between header/opponents, and between deck area/player cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:11:39 -05:00
adlee-was-taken
4fcdf13f66 Fix mobile portrait layout: lobby overlap, deal animation, card font sizes
- Add renderGame() guard during deal animation to prevent DOM destruction
  mid-animation causing cards to pile up at wrong positions
- Push lobby content below fixed auth-bar (padding 15px -> 50px top)
- Scale player card font-size to 1.5rem/1.3rem for readable text on mobile
- Add full mobile portrait layout: bottom drawers, compact header, responsive
  card grid sizing, safe-area insets, and mobile detection via matchMedia
- Add cardFontSize() helper for consistent proportional font scaling
- Add mobile bottom bar with drawer toggles for standings/scores

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:52:44 -05:00
adlee-was-taken
6673e63241 Enable HTTPS-only with HTTP->HTTPS redirect
SSL cert issued via Let's Encrypt. Remove HTTP fallback router,
enable redirect, reduce Traefik log level to WARN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:12:48 -05:00
adlee-was-taken
62e7d4e1dd Fix End Game showing false 'Connection lost' error
Add _intentionalClose flag to suppress error when server closes
WebSocket after game_ended broadcast. Clean transition to lobby.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:39:43 -05:00
adlee-was-taken
bae5d8da3c Wire authManager into GolfGame instance for WebSocket token auth
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:32:49 -05:00
adlee-was-taken
62e3dc0395 Allow ws:// in production CSP for pre-SSL WebSocket connections
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:30:29 -05:00
adlee-was-taken
bda88d8218 Add gap between login and signup buttons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:27:29 -05:00
adlee-was-taken
b5a8e1fe7b Fix Traefik network resolution - use golfgame_web not internal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:25:41 -05:00
adlee-was-taken
929ab0f320 Enable Traefik debug logging and access logs for troubleshooting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:23:44 -05:00
adlee-was-taken
7026d86081 Link HTTP fallback router to golf service explicitly
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:22:40 -05:00
adlee-was-taken
b2ce6f5cf1 Add HTTP fallback route for pre-DNS testing, disable redirect temporarily
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:20:24 -05:00
adlee-was-taken
d4a39fe234 Upgrade Traefik to v3.6 for Docker Engine v29 API negotiation fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:18:20 -05:00
adlee-was-taken
9966fd9470 Set DOCKER_API_VERSION for Traefik compatibility with Docker Engine v29
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:16:00 -05:00
adlee-was-taken
050294754c Upgrade Traefik v2.10 to v3.3 for Docker Engine v29 compatibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:15:09 -05:00
adlee-was-taken
1856019a95 Fix Dockerfile WORKDIR for server relative imports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:05:29 -05:00
adlee-was-taken
f68d0bc26d v3.1.0: Invite-gated auth, Glicko-2 ratings, matchmaking queue
- Enforce invite codes on registration (INVITE_ONLY=true by default)
- Bootstrap admin account for first-time setup
- Require authentication for WebSocket connections and room creation
- Add Glicko-2 rating system with multiplayer pairwise comparisons
- Add Redis-backed matchmaking queue with expanding rating window
- Auto-start matched games with standard rules after countdown
- Add "Find Game" button and matchmaking UI to client
- Add rating column to leaderboard
- Scale down docker-compose.prod.yml for 512MB droplet

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:02:10 -05:00
adlee-was-taken
c59c1e28e2 Smooth held card transition and scale font with card size
Remove scale(1.15) size jump on held card, keep gold border/glow highlight.
Set animation card font-size proportionally to card width so text matches
across deck, hand, and opponent card sizes. Animate font-size during swaps
so text scales smoothly as cards travel between different-sized positions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:14:27 -05:00
adlee-was-taken
bfa94830a7 Move V2_BUILD_PLAN.md to docs/v2/
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:59:48 -05:00
adlee-was-taken
850b8d6abf Standard-rules-only leaderboard with client unranked indicators
Only standard-rules games now count toward leaderboard stats. Games
with any house rule variant are marked "Unranked" in the active rules
bar, and a notice appears in the lobby when house rules are selected.
Also fixes game_logger duplicate options dicts (now uses dataclasses.asdict,
capturing all options including previously missing ones) and refactors
duplicated achievement-checking logic into shared helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:16:45 -05:00
adlee-was-taken
e1cca98b8b Fix client scoring to respect house rules for column pairs
Client-side scoring (points badge and score tally animation) ignored
house rules that modify pair behavior. Extract shared
calculateColumnScores() helper that mirrors server logic for
eagle_eye, negative_pairs_keep_value, wolfpack, four_of_a_kind,
and one_eyed_jacks rules. Server now sends scoring_rules flags
in game state.

Also fix opponent flip animation card font-size matching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:34:40 -05:00
adlee-was-taken
df61d88ec6 Revise rules page strategic impact descriptions for accuracy
Rename "New Variants" to "Game Variants", fix descriptions that
contradicted game mechanics (impossible card scenarios, misleading
value assessments), and clarify Underdog Bonus catch-up intent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:18:26 -05:00
adlee-was-taken
9fc6b83bba v3.0.0: V3 features, server refactoring, and documentation overhaul
- Extract WebSocket handlers from main.py into handlers.py
- Add V3 feature docs (dealer rotation, dealing animation, round end reveal,
  column pair celebration, final turn urgency, opponent thinking, score tallying,
  card hover/selection, knock early drama, column pair indicator, swap animation
  improvements, draw source distinction, card value tooltips, active rules context,
  discard pile history, realistic card sounds)
- Add V3 refactoring docs (ai.py, main.py/game.py, misc improvements)
- Add installation guide with Docker, systemd, and nginx setup
- Add helper scripts (install.sh, dev-server.sh, docker-build.sh)
- Add animation flow diagrams documentation
- Add test files for handlers, rooms, and V3 features
- Add e2e test specs for V3 features
- Update README with complete project structure and current tech stack
- Update CLAUDE.md with full architecture tree and server layer descriptions
- Update .env.example to reflect PostgreSQL (remove SQLite references)
- Update .gitignore to exclude virtualenv files, .claude/, and .db files
- Remove tracked virtualenv files (bin/, lib64, pyvenv.cfg)
- Remove obsolete game_log.py (SQLite) and games.db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:03:45 -05:00
adlee-was-taken
13ab5b9017 Tune knock-early thresholds and fix failing test suite
Tighten should_knock_early() so AI no longer knocks with projected
scores of 12-14. New range: max_acceptable 5-9 (was 8-18), with
scaled knock_chance by score quality and an exception when all
opponents show 25+ visible points.

Fix 5 pre-existing test failures:
- test_event_replay: use game.current_player() instead of hardcoding
  "p1", since dealer logic makes p2 go first
- game.py: include current_player_idx in round_started event so state
  replay knows the correct starting player
- test_house_rules: rename test_rule_config → run_rule_config so
  pytest doesn't collect it as a test fixture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 09:56:59 -05:00
adlee-was-taken
9bb9d1e397 Refactor ai.py: decompose choose_swap_or_discard and extract utilities
Break the 666-line choose_swap_or_discard into 8 focused sub-functions,
extract named constants for ~15 magic numbers, add column/pair utility
functions (iter_columns, project_score, count_hidden, hidden_positions),
and extract _log_cpu_action helper to reduce logging boilerplate in
process_cpu_turn. No behavior changes - validated with simulate.py 500.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 09:31:37 -05:00
adlee-was-taken
8431cd6fd1 Speed up score animations and fix end-of-round UI
- Cut reveal/tally/celebration timings by ~50% for snappier round end
- Add dealAnimationInProgress flag to suppress flip prompts during deal
- Stop deck/discard pulse animation when round ends
- Update CLAUDE.md with animation race condition documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 08:45:44 -05:00
adlee-was-taken
49b2490c25 Add PostgreSQL game logging system
- Add GameLogger service for move logging to PostgreSQL
- Add moves table to event_store.py for AI decision analysis
- Update main.py to initialize GameLogger in lifespan
- Update game_analyzer.py to query PostgreSQL instead of SQLite
- Add VDD documentation V2_08_GAME_LOGGING.md

Replaces SQLite game_log.py with unified PostgreSQL backend.
See docs/v2/V2_08_GAME_LOGGING.md for architecture and API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 08:42:49 -05:00
adlee-was-taken
7d28e83a49 Update CLAUDE.md with AI safety checks and architecture
- Add AI decision safety checks documentation
- Add simulation testing commands
- Update architecture with services/, stores/, and new files
- Add PostgreSQL to dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 08:42:38 -05:00
adlee-was-taken
4ad508f84f Fix AI swapping high cards into unknown positions
Prevent CPU players from swapping 8+ value cards (8, 9, 10, J, Q) into
face-down positions, which is statistically bad since expected hidden
card value is ~4.5.

Fixes applied:
- Add value threshold (7) to unpredictability random swap path
- Restrict comeback bonus to cards with value < 8
- Reduce speculative wolfpack Jack bonus from 6x to 2x aggression
- Add safety filter to remove hidden positions for 8+ cards
- Fix endgame logic to discard 8+ instead of forcing swap into hidden
- Skip hidden positions in denial candidate list for 8+ cards
- Add swapped_high_into_unknown tracking to SimulationStats

Reduces "swapped 8+ into unknown" dumb moves from ~85 per 200 games
to ~6 per 500 games (0.054% rate, down from ~2%).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 08:28:10 -05:00
adlee-was-taken
9b53e51aa3 Add opponent denial strategy to AI decision making
AI now considers the next player's visible cards before discarding:
- Checks if discarding would give opponent a pair opportunity
- Calculates denial value based on card value and game phase
- May keep a worse card to deny opponent when cost is acceptable
- Denial threshold varies by AI personality (aggression)

Also updates simulation to recognize denial as a valid reason for
swapping good cards, preventing false "swapped good for bad" flags.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:15:39 -05:00
adlee-was-taken
cd05930b69 Add house rule presets and comparison mode to simulation runner
Enable testing AI behavior under different rule sets via CLI:
- --preset flag for named configurations (baseline, eagle_eye, etc.)
- --rules flag for custom comma-separated rules
- --compare flag for side-by-side preset comparison with metrics
- Improved dumb move detection for negative_pairs_keep_value rule

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:51:20 -05:00
adlee-was-taken
c615c8b433 Fix animation race conditions and improve UI feedback
- Fix discard pile "do-si-do" race condition when CPU draws from discard
- Add isDrawAnimating flag for opponent draw animations
- Skip STEP 2 (discard detection) when draw from discard detected
- Fix deal animation using wrong rect (was using whole player area)
- Add player area highlight when it's their turn (green glow)
- Clear opponent animation flags when your_turn message received
- Hide discard pile during draw-from-discard animation
- Add comprehensive debug logging for animation flags

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 00:28:06 -05:00
adlee-was-taken
4664aae8aa Bump version to 2.0.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 23:31:39 -05:00
adlee-was-taken
a5d108f4f2 Add animation system documentation and project context
- client/ANIMATIONS.md: Full documentation of the CardAnimations API, timing config, CSS rules, and common patterns
- CLAUDE.md: Project context for AI assistants with architecture overview and development guidelines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:14:04 -05:00
adlee-was-taken
df422907b0 Speed up animations and reduce CPU turn delays
- Reduce move animation durations by 40% for snappier card movement
- Widen and slow down turn indicator shake for better visibility
- Cut CPU turn delays significantly:
  - Pre-turn pause: 0.6s → 0.25s
  - Initial look: 0.6-0.9s → 0.3-0.5s
  - Post-draw settle: 0.9s → 0.5s
  - Post-draw consider: 0.6-0.9s → 0.3-0.6s
  - Post-action pause: 0.6-0.9s → 0.3-0.5s

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:06:17 -05:00
adlee-was-taken
bc1b1b7725 Migrate animation system to unified anime.js framework
- Replace CSS transitions with anime.js for all card animations
- Create card-animations.js as single source for all animation logic
- Remove draw-animations.js (merged into card-animations.js)
- Strip CSS transitions from card elements to prevent conflicts
- Fix held card appearing before draw animation completes
- Make opponent/CPU animations match local player behavior
- Add subtle shake effect for turn indicator (replaces brightness pulse)
- Speed up flip animations by 30% for snappier feel
- Remove unnecessary pulse effects after draws/swaps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 22:57:53 -05:00
adlee-was-taken
7b64b8c17c Timing and animation changes for a more natural feeling game with CPU opps.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 12:22:54 -05:00
Aaron D. Lee
6950769bc3 Version 2.0.0: Animation fixes, timing improvements, and E2E test suite
Animation fixes:
- Fix held card positioning bug (was appearing at bottom of page)
- Fix discard pile blank/white flash on turn transitions
- Fix blank card at round end by skipping animations during round_over/game_over
- Set card content before triggering flip animation to prevent flash
- Center suit symbol on 10 cards

Timing improvements:
- Reduce post-discard delay from 700ms to 500ms
- Reduce post-swap delay from 1800ms to 1000ms
- Speed up swap flip animation from 1150ms to 550ms
- Reduce CPU initial thinking delay from 150-250ms to 80-150ms
- Pause now happens after swap completes (showing result) instead of before

E2E test suite:
- Add Playwright-based test bot that plays full games
- State parser extracts game state from DOM for validation
- AI brain ports decision logic for automated play
- Freeze detector monitors for UI hangs
- Visual validator checks CSS states
- Full game, stress, and visual test specs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:33:28 -05:00
Aaron D. Lee
724bf87c43 Add CPU profile cleanup on shutdown and debug endpoints
- Reset all CPU profiles on server shutdown to prevent stuck profiles
- Clean up all rooms during shutdown
- Add GET /api/debug/cpu-profiles to check allocation status
- Add POST /api/debug/reset-cpu-profiles for emergency cleanup

This fixes the issue where CPU profiles get "stuck" when connections
drop without clean teardown, preventing new games from adding CPUs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:08:14 -05:00
Aaron D. Lee
15135c404e Add "Put Back" button to cancel accidental discard draws
When you accidentally click the discard pile, you can now put the card
back instead of being forced to swap. The "Put Back" button appears
only when you've drawn from the discard pile.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:02:25 -05:00
Aaron D. Lee
0c8d2b4a9c Add share link button for room invites
- Add 🔗 button next to room code copy button
- Copies full URL with ?room=XXXX parameter
- On page load, pre-fills room code from URL param
- Works with both logged-in users and guests
- Cleans up URL after extracting room code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:59:52 -05:00
Aaron D. Lee
0b0873350c Move room code banner to top-left corner
Fixes overlap between room code and logout button (auth bar) which
are both positioned in the top-right corner.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:28:57 -05:00
Aaron D. Lee
f27020f21b Fix V2 race conditions, auth gaps, serialization bugs, and async stats
Phase 1 - Critical Fixes:
- Add game_lock (asyncio.Lock) to Room class for serializing mutations
- Wrap all game action handlers in lock to prevent race conditions
- Split Card.to_dict into to_dict (full data) and to_client_dict (hidden)
- Fix CardState.from_dict to handle missing rank/suit gracefully
- Fix GameOptions reconstruction in recovery_service (dict -> object)
- Extend state cache TTL from 4h to 24h, add touch_game method

Phase 2 - Security:
- Add optional WebSocket authentication via token query param
- Use authenticated user ID/name when available
- Add auth support to spectator WebSocket endpoint

Phase 3 - Performance:
- Make stats processing async (fire-and-forget) to avoid blocking
  game completion notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:27:30 -05:00
Aaron D. Lee
1dbfb3f14b Home page auth button logic fixed - for better enjoyment of logo animation.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:53:44 -05:00
Aaron D. Lee
ba85a11d1a Refine golf ball logo and add static route
- Adjust dimple size and spacing for balanced appearance
- Enlarge card suit symbols (font-size 32) in single row
- Fine-tune symbol positioning and spacing
- Increase margin between logo and golfer emoji
- Add /golfball-logo.svg static file route

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:51:10 -05:00
Aaron D. Lee
d2e78da7d2 Add golf ball logo with card suits and fix server shutdown hang
- Add SVG golf ball logo with dimples and card suit symbols (♣♦♠♥)
- Place logo to the left of the golfer emoji in header
- Fix server shutdown hanging by removing custom signal handlers
  that intercepted SIGINT/SIGTERM without triggering uvicorn shutdown

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:31:21 -05:00
Aaron D. Lee
546e63ffed Add missing leaderboard.js static file route
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:53:38 -05:00
Aaron D. Lee
93b753dedb Fix _initiate_shutdown NameError by moving function before lifespan
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:42:48 -05:00
Aaron D. Lee
bea85e6b28 Huge v2 uplift, now deployable with real user management and tooling!
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:32:15 -05:00
Aaron D. Lee
c912a56c2d Early Knock house rule and improved error handling.
- Add Early Knock variant: flip all remaining cards (≤2) to go out early
- Update RULES.md with comprehensive documentation for all new variants
- Shorten flip mode dropdown descriptions for cleaner UI
- Add try-catch and optional chaining in startGame() for robustness
- Add WebSocket connection error feedback with reject sound
- AI awareness for Early Knock decisions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 22:23:12 -05:00
Aaron D. Lee
36a71799b5 UI polish: opponent draw flash, compact house rules with suit separators, toast styling.
- Opponent draw highlight: scale + outline flash animation
- House rules reorganized: Gameplay, Jokers, Card Values, Bonuses & Penalties
- Compact inline rule descriptions with alternating suit separators (♣♦♠♥)
- Wolfpack + Four of a Kind combo note when both selected
- Toast notifications now yellow/green with charcoal text
- Brief pause after AI draw for visual feedback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:37:02 -05:00
Aaron D. Lee
33e3f124ed Additional house rules to accomodate more common game variants.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:13:30 -05:00
Aaron D. Lee
23657f6b0c More host UI refinements, fun logo animations, etc. 2026-01-26 17:03:30 -05:00
Aaron D. Lee
c72fe44cfa More host UI refinements, intuitive UI enhancements during gameplay pt 3. 2026-01-26 15:04:23 -05:00
Aaron D. Lee
059edfb3d9 More host UI refinements, intuitive UI enhancements during gameplay pt 2. 2026-01-26 15:02:25 -05:00
Aaron D. Lee
13a490b417 More host UI refinements, intuitive UI enhancements during gameplay. 2026-01-26 15:01:49 -05:00
Aaron D. Lee
67021b2b51 Additional flip on discard variant - endgame and updated rules.md and new rules page. 2026-01-26 01:01:08 -05:00
Aaron D. Lee
e9909fa967 More UI tweaks and fixes. 2026-01-26 00:24:57 -05:00
Aaron D. Lee
20c882e5f1 More additional animation tweaks, round countdown adjustments, etc. 2026-01-26 00:17:18 -05:00
Aaron D. Lee
0f44464c4f Additional animation work and AI strategy enhancements and logging for performance analytics. 2026-01-25 18:49:18 -05:00
Aaron D. Lee
f80bab3b4b Numerous WebUI animations, improvements, AI fixes, opporitunity cost-based decision logic, etc. 2026-01-25 17:37:01 -05:00
Aaron D. Lee
d9073f862c Add documentation and move rules display to header
- Add comprehensive docstrings to game.py, room.py, constants.py
- Document all classes, methods, and module-level items
- Move active rules display into game header as inline column
- Update header to 5-column grid layout
- Update joker mode descriptions (Lucky Swing, Eagle-Eye)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:10:26 -05:00
150 changed files with 60591 additions and 1941 deletions

139
.env.example Normal file
View File

@@ -0,0 +1,139 @@
# =============================================================================
# Golf Game Server Configuration
# =============================================================================
# Copy this file to .env and customize as needed.
# All values shown are defaults.
# -----------------------------------------------------------------------------
# Server Settings
# -----------------------------------------------------------------------------
# Host to bind to (0.0.0.0 for all interfaces)
HOST=0.0.0.0
# Port to listen on
PORT=8000
# Enable debug mode (more verbose logging, auto-reload)
DEBUG=false
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO
# Per-module log level overrides (optional)
# These override LOG_LEVEL for specific modules.
# LOG_LEVEL_GAME=DEBUG # Core game logic
# LOG_LEVEL_AI=DEBUG # AI decisions (very verbose at DEBUG)
# LOG_LEVEL_HANDLERS=DEBUG # WebSocket message handlers
# LOG_LEVEL_ROOM=DEBUG # Room/lobby management
# LOG_LEVEL_AUTH=DEBUG # Auth stack (auth, routers.auth, services.auth_service)
# LOG_LEVEL_STORES=DEBUG # Database/Redis operations
# --- Preset examples ---
# Staging (debug game logic, quiet everything else):
# LOG_LEVEL=INFO
# LOG_LEVEL_GAME=DEBUG
# LOG_LEVEL_AI=DEBUG
#
# Production (minimal logging):
# LOG_LEVEL=WARNING
# Environment name (development, staging, production)
ENVIRONMENT=development
# -----------------------------------------------------------------------------
# Database
# -----------------------------------------------------------------------------
# PostgreSQL connection URL (event sourcing, game logs, stats)
# For development with Docker: postgresql://golf:devpassword@localhost:5432/golf
DATABASE_URL=postgresql://golf:devpassword@localhost:5432/golf
# PostgreSQL URL for auth/stats features (can be same as DATABASE_URL)
POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf
# -----------------------------------------------------------------------------
# Room Settings
# -----------------------------------------------------------------------------
# Maximum players per game room
MAX_PLAYERS_PER_ROOM=6
# Room timeout in minutes (inactive rooms are cleaned up)
ROOM_TIMEOUT_MINUTES=60
# Length of room codes (e.g., 4 = "ABCD")
ROOM_CODE_LENGTH=4
# -----------------------------------------------------------------------------
# Security & Authentication
# -----------------------------------------------------------------------------
# Secret key for JWT tokens (generate with: python -c "import secrets; print(secrets.token_hex(32))")
SECRET_KEY=
# Enable invite-only mode (requires invitation to register)
INVITE_ONLY=true
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
# Remove these after first login!
# BOOTSTRAP_ADMIN_USERNAME=admin
# BOOTSTRAP_ADMIN_PASSWORD=changeme12345
# Comma-separated list of admin email addresses
ADMIN_EMAILS=
# -----------------------------------------------------------------------------
# Game Defaults
# -----------------------------------------------------------------------------
# Default number of rounds (holes) per game
DEFAULT_ROUNDS=9
# Cards to flip at start of each round (0, 1, or 2)
DEFAULT_INITIAL_FLIPS=2
# Enable jokers in deck by default
DEFAULT_USE_JOKERS=false
# Require flipping a card after discarding from deck
DEFAULT_FLIP_ON_DISCARD=false
# -----------------------------------------------------------------------------
# Card Values (Standard 6-Card Golf)
# -----------------------------------------------------------------------------
# Customize point values for cards. Normally you shouldn't change these.
CARD_ACE=1
CARD_TWO=-2
CARD_KING=0
CARD_JOKER=-2
# House rule values
CARD_SUPER_KINGS=-2 # King value when super_kings enabled
CARD_TEN_PENNY=1 # 10 value when ten_penny enabled
CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing enabled
# -----------------------------------------------------------------------------
# Production Features (Optional)
# -----------------------------------------------------------------------------
# Sentry error tracking
# SENTRY_DSN=https://your-sentry-dsn
# Resend API for emails (required for user registration/password reset)
# RESEND_API_KEY=your-api-key
# Enable rate limiting (recommended for production)
# RATE_LIMIT_ENABLED=true
# Redis URL (required for matchmaking and rate limiting)
# REDIS_URL=redis://localhost:6379
# Base URL for email links
# BASE_URL=https://your-domain.com
# Matchmaking (skill-based public games)
MATCHMAKING_ENABLED=true
MATCHMAKING_MIN_PLAYERS=2
MATCHMAKING_MAX_PLAYERS=4

16
.gitignore vendored
View File

@@ -188,6 +188,22 @@ cython_debug/
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Claude Code
.claude/
# Virtualenv in project root
bin/
pyvenv.cfg
# Database files
*.db
# Personal notes
lookfah.md
# Internal docs (deployment info, credentials references, etc.)
internal/
# Ruff stuff:
.ruff_cache/

285
CLAUDE.md Normal file
View File

@@ -0,0 +1,285 @@
# Golf Card Game - Project Context
A real-time multiplayer 6-card Golf card game with CPU opponents and smooth anime.js animations.
## Quick Start
```bash
# Install dependencies
pip install -r server/requirements.txt
# Run the server
python server/main.py
# Visit http://localhost:8000
```
For full installation (Docker, PostgreSQL, Redis, production), see [INSTALL.md](INSTALL.md).
## Architecture
```
golfgame/
├── server/ # Python FastAPI backend
│ ├── main.py # HTTP routes, WebSocket server, lifespan
│ ├── game.py # Core game logic, state machine
│ ├── ai.py # CPU opponent AI with timing/personality
│ ├── handlers.py # WebSocket message handlers
│ ├── room.py # Room/lobby management
│ ├── config.py # Environment configuration (pydantic)
│ ├── constants.py # Card values, game constants
│ ├── auth.py # Authentication (JWT, passwords)
│ ├── logging_config.py # Structured logging setup
│ ├── simulate.py # AI simulation runner with stats
│ ├── game_analyzer.py # Query tools for game analysis
│ ├── score_analysis.py # Score distribution analysis
│ ├── routers/ # FastAPI route modules
│ │ ├── auth.py # Login, signup, verify endpoints
│ │ ├── admin.py # Admin management endpoints
│ │ ├── stats.py # Statistics & leaderboard endpoints
│ │ ├── replay.py # Game replay endpoints
│ │ └── health.py # Health check endpoints
│ ├── services/ # Business logic layer
│ │ ├── auth_service.py # User authentication
│ │ ├── admin_service.py # Admin tools
│ │ ├── stats_service.py # Player statistics & leaderboards
│ │ ├── replay_service.py # Game replay functionality
│ │ ├── game_logger.py # PostgreSQL game move logging
│ │ ├── spectator.py # Spectator mode
│ │ ├── email_service.py # Email notifications (Resend)
│ │ ├── recovery_service.py # Account recovery
│ │ └── ratelimit.py # Rate limiting
│ ├── stores/ # Data persistence layer
│ │ ├── event_store.py # PostgreSQL event sourcing
│ │ ├── user_store.py # User persistence
│ │ ├── state_cache.py # Redis state caching
│ │ └── pubsub.py # Pub/sub messaging
│ ├── models/ # Data models
│ │ ├── events.py # Event types for event sourcing
│ │ ├── game_state.py # Game state representation
│ │ └── user.py # User data model
│ └── middleware/ # Request middleware
│ ├── security.py # CORS, CSP, security headers
│ ├── request_id.py # Request ID tracking
│ └── ratelimit.py # Rate limiting middleware
├── client/ # Vanilla JS frontend
│ ├── index.html # Main game page
│ ├── app.js # Main game controller
│ ├── card-animations.js # Unified anime.js animation system
│ ├── card-manager.js # DOM management for cards
│ ├── animation-queue.js # Animation sequencing
│ ├── timing-config.js # Centralized timing configuration
│ ├── state-differ.js # Diff game state for animations
│ ├── style.css # Styles (NO card transitions)
│ ├── admin.html # Admin panel
│ ├── admin.js # Admin panel interface
│ ├── admin.css # Admin panel styles
│ ├── replay.js # Game replay viewer
│ ├── leaderboard.js # Leaderboard display
│ └── ANIMATIONS.md # Animation system documentation
├── docs/
│ ├── ANIMATION-FLOWS.md # Animation flow diagrams
│ ├── v2/ # V2 architecture docs (event sourcing, auth, etc.)
│ └── v3/ # V3 feature & refactoring docs
├── scripts/ # Helper scripts
│ ├── install.sh # Interactive installer
│ ├── dev-server.sh # Development server launcher
│ └── docker-build.sh # Docker image builder
└── tests/e2e/ # End-to-end tests (Playwright)
```
## Key Technical Decisions
### Animation System
**When to use anime.js vs CSS:**
- **Anime.js (CardAnimations)**: Card movements, flips, swaps, draws - anything involving card elements
- **CSS keyframes/transitions**: Simple UI feedback (button hover, badge entrance, status message fades) - non-card elements
**General rule:** If it moves a card, use anime.js. If it's UI chrome, CSS is fine.
- See `client/ANIMATIONS.md` for full documentation
- See `docs/ANIMATION-FLOWS.md` for flow diagrams
- `CardAnimations` class in `card-animations.js` handles everything
- Timing configured in `timing-config.js`
### State Management
- Server is source of truth
- Client receives full game state on each update
- `state-differ.js` computes diffs to trigger appropriate animations
### Animation Race Condition Flags
Several flags in `app.js` prevent `renderGame()` from updating the discard pile during animations:
| Flag | Purpose |
|------|---------|
| `isDrawAnimating` | Local or opponent draw animation in progress |
| `localDiscardAnimating` | Local player discarding drawn card |
| `opponentDiscardAnimating` | Opponent discarding without swap |
| `opponentSwapAnimation` | Opponent swap animation in progress |
| `dealAnimationInProgress` | Deal animation running (suppresses flip prompts) |
**Critical:** These flags must be cleared in ALL code paths (success, error, fallback). Failure to clear causes UI to freeze.
**Clear flags when:**
- Animation completes (callback)
- New animation starts (clear stale flags)
- `your_turn` message received (safety clear)
- Error/fallback paths
### CPU Players
- AI logic in `server/ai.py`
- Configurable timing delays for natural feel
- Multiple personality types affect decision-making (pair hunters, aggressive, conservative, etc.)
**AI Decision Safety Checks:**
- Never swap high cards (8+) into unknown positions (expected value ~4.5)
- Unpredictability has value threshold (7) to prevent obviously bad random plays
- Comeback bonus only applies to cards < 8
- Denial logic skips hidden positions for 8+ cards
**Testing AI with simulations:**
```bash
# Run 500 games and check dumb move rate
python server/simulate.py 500
# Detailed single game output
python server/simulate.py 1 --detailed
# Compare rule presets
python server/simulate.py 100 --compare
```
### Server Architecture
- **Routers** (`server/routers/`): FastAPI route modules for auth, admin, stats, replay, health
- **Services** (`server/services/`): Business logic layer (auth, admin, stats, replay, email, rate limiting)
- **Stores** (`server/stores/`): Data persistence (PostgreSQL event store, user store, Redis state cache, pub/sub)
- **Models** (`server/models/`): Data models (events, game state, user)
- **Middleware** (`server/middleware/`): Security headers, request ID tracking, rate limiting
- **Handlers** (`server/handlers.py`): WebSocket message dispatch (extracted from main.py)
## Common Development Tasks
### Adjusting Animation Speed
Edit `timing-config.js` - all timings are centralized there.
### Adding New Animations
1. Add method to `CardAnimations` class in `card-animations.js`
2. Use anime.js, not CSS transitions
3. Track in `activeAnimations` Map for cancellation support
4. Add timing config to `timing-config.js` if needed
### Debugging Animations
```javascript
// Check what's animating
console.log(window.cardAnimations.activeAnimations);
// Force cleanup
window.cardAnimations.cancelAll();
// Check timing config
console.log(window.TIMING);
```
### Testing CPU Behavior
Adjust delays in `server/ai.py` `CPU_TIMING` dict.
### Running Tests
```bash
# All server tests
cd server && pytest -v
# AI simulation
python server/simulate.py 500
```
## Important Patterns
### No CSS Transitions on Cards
Cards animate via anime.js only. The following should NOT have `transition` (especially on `transform`):
- `.card`, `.card-inner`
- `.real-card`, `.swap-card`
- `.held-card-floating`
Card hover effects are handled by `CardAnimations.hoverIn()/hoverOut()` methods.
CSS may still use box-shadow transitions for hover glow effects.
### State Differ Logic (triggerAnimationsForStateChange)
The state differ in `app.js` detects what changed between game states:
**STEP 1: Draw Detection**
- Detects when `drawn_card` goes from null to something
- Triggers draw animation (from deck or discard)
- Sets `isDrawAnimating` flag
**STEP 2: Discard/Swap Detection**
- Detects when `discard_top` changes and it was another player's turn
- Triggers swap or discard animation
- **Important:** Skip STEP 2 if STEP 1 detected a draw from discard (the discard change was from REMOVING a card, not adding one)
### Animation Overlays
Complex animations create temporary overlay elements:
1. Create `.draw-anim-card` positioned over source
2. Hide original card (or set `opacity: 0` on discard pile during draw-from-discard)
3. Animate overlay
4. Remove overlay, reveal updated card, restore visibility
### Fire-and-Forget for Opponents
Opponent animations don't block - no callbacks needed:
```javascript
cardAnimations.animateOpponentFlip(cardElement, cardData);
```
### Common Animation Pitfalls
**Card position before append:** Always set `left`/`top` styles BEFORE appending overlay cards to body, otherwise they flash at (0,0).
**Deal animation source:** Use `getDeckRect()` for deal animations, not `getDealerRect()`. The dealer rect returns the whole player area, causing cards to animate at wrong size.
**Element rects during hidden:** `visibility: hidden` still allows `getBoundingClientRect()` to work. `display: none` does not.
## Dependencies
### Server
- FastAPI + uvicorn (web framework & ASGI server)
- websockets (WebSocket support)
- asyncpg (PostgreSQL async driver)
- redis (state caching, pub/sub)
- bcrypt (password hashing)
- resend (email service)
- python-dotenv (environment management)
- sentry-sdk (error tracking, optional)
### Client
- anime.js (animations)
- No other frameworks
### Infrastructure
- PostgreSQL (event sourcing, auth, stats, game logs)
- Redis (state caching, pub/sub)
## Game Rules Reference
- 6 cards per player in 2x3 grid
- Lower score wins
- Matching columns cancel out (0 points)
- Jokers are -2 points
- Kings are 0 points
- Game ends when a player flips all cards

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# Production Dockerfile for Golf Card Game
FROM python:3.11-slim as base
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY server/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY server/ ./server/
COPY client/ ./client/
# Create non-root user
RUN useradd --create-home --shell /bin/bash appuser \
&& chown -R appuser:appuser /app
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
EXPOSE 8000
# Run with uvicorn from the server directory (server uses relative imports)
WORKDIR /app/server
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

573
INSTALL.md Normal file
View File

@@ -0,0 +1,573 @@
# Golf Game Installation Guide
Complete guide for installing and running the Golf card game server.
## Table of Contents
- [Quick Start](#quick-start)
- [Requirements](#requirements)
- [Development Setup](#development-setup)
- [Production Installation](#production-installation)
- [Docker Deployment](#docker-deployment)
- [Configuration Reference](#configuration-reference)
- [Troubleshooting](#troubleshooting)
---
## Quick Start
The fastest way to get started is using the interactive installer:
```bash
./scripts/install.sh
```
This provides a menu with options for:
- Development setup (Docker services + virtualenv + dependencies)
- Production installation to /opt/golfgame
- Systemd service configuration
- Status checks
---
## Requirements
### For Development
- **Python 3.11+** (3.12, 3.13, 3.14 also work)
- **Docker** and **Docker Compose** (for PostgreSQL and Redis)
- **Git**
### For Production
- **Python 3.11+**
- **PostgreSQL 16+**
- **Redis 7+**
- **systemd** (for service management)
- **nginx** (recommended, for reverse proxy)
---
## Development Setup
### Option A: Using the Installer (Recommended)
```bash
./scripts/install.sh
# Select option 1: Development Setup
```
This will:
1. Start PostgreSQL and Redis in Docker containers
2. Create a Python virtual environment
3. Install all dependencies
4. Generate a `.env` file configured for local development
### Option B: Manual Setup
#### 1. Start Docker Services
```bash
docker-compose -f docker-compose.dev.yml up -d
```
This starts:
- **PostgreSQL** on `localhost:5432` (user: `golf`, password: `devpassword`, database: `golf`)
- **Redis** on `localhost:6379`
Verify services are running:
```bash
docker-compose -f docker-compose.dev.yml ps
```
#### 2. Create Python Virtual Environment
```bash
# Create venv in project root
python3 -m venv .
# Activate it
source bin/activate
# Upgrade pip
pip install --upgrade pip
# Install dependencies (including dev tools)
pip install -e ".[dev]"
```
#### 3. Configure Environment
```bash
cp .env.example .env
```
Edit `.env` for development:
```bash
HOST=0.0.0.0
PORT=8000
DEBUG=true
LOG_LEVEL=DEBUG
ENVIRONMENT=development
DATABASE_URL=postgresql://golf:devpassword@localhost:5432/golf
POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf
```
#### 4. Run the Development Server
```bash
cd server
../bin/uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
Or use the helper script:
```bash
./scripts/dev-server.sh
```
The server will be available at http://localhost:8000
#### 5. Verify Installation
```bash
# Health check
curl http://localhost:8000/health
# Should return: {"status":"ok","timestamp":"..."}
```
### Stopping Development Services
```bash
# Stop the server: Ctrl+C
# Stop Docker containers
docker-compose -f docker-compose.dev.yml down
# Stop and remove volumes (clean slate)
docker-compose -f docker-compose.dev.yml down -v
```
---
## Production Installation
### Option A: Using the Installer (Recommended)
```bash
sudo ./scripts/install.sh
# Select option 2: Production Install to /opt/golfgame
```
### Option B: Manual Installation
#### 1. Install System Dependencies
```bash
# Debian/Ubuntu
sudo apt update
sudo apt install -y python3 python3-venv python3-pip postgresql redis-server nginx
# Start and enable services
sudo systemctl enable --now postgresql redis-server nginx
```
#### 2. Create PostgreSQL Database
```bash
sudo -u postgres psql << EOF
CREATE USER golf WITH PASSWORD 'your_secure_password';
CREATE DATABASE golf OWNER golf;
GRANT ALL PRIVILEGES ON DATABASE golf TO golf;
EOF
```
#### 3. Create Installation Directory
```bash
sudo mkdir -p /opt/golfgame
sudo chown $USER:$USER /opt/golfgame
```
#### 4. Clone and Install Application
```bash
cd /opt/golfgame
git clone https://github.com/alee/golfgame.git .
# Create virtual environment
python3 -m venv .
source bin/activate
# Install application
pip install --upgrade pip
pip install .
```
#### 5. Configure Production Environment
Create `/opt/golfgame/.env`:
```bash
# Generate a secret key
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
cat > /opt/golfgame/.env << EOF
HOST=0.0.0.0
PORT=8000
DEBUG=false
LOG_LEVEL=INFO
ENVIRONMENT=production
DATABASE_URL=postgresql://golf:your_secure_password@localhost:5432/golf
POSTGRES_URL=postgresql://golf:your_secure_password@localhost:5432/golf
SECRET_KEY=$SECRET_KEY
MAX_PLAYERS_PER_ROOM=6
ROOM_TIMEOUT_MINUTES=60
# Optional: Error tracking with Sentry
# SENTRY_DSN=https://your-sentry-dsn
# Optional: Email via Resend
# RESEND_API_KEY=your-api-key
EOF
# Secure the file
chmod 600 /opt/golfgame/.env
```
#### 6. Set Ownership
```bash
sudo chown -R www-data:www-data /opt/golfgame
```
#### 7. Create Systemd Service
Create `/etc/systemd/system/golfgame.service`:
```ini
[Unit]
Description=Golf Card Game Server
Documentation=https://github.com/alee/golfgame
After=network.target postgresql.service redis.service
Wants=postgresql.service redis.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/golfgame/server
Environment="PATH=/opt/golfgame/bin:/usr/local/bin:/usr/bin:/bin"
EnvironmentFile=/opt/golfgame/.env
ExecStart=/opt/golfgame/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/golfgame
[Install]
WantedBy=multi-user.target
```
#### 8. Enable and Start Service
```bash
sudo systemctl daemon-reload
sudo systemctl enable golfgame
sudo systemctl start golfgame
# Check status
sudo systemctl status golfgame
# View logs
journalctl -u golfgame -f
```
#### 9. Configure Nginx Reverse Proxy
Create `/etc/nginx/sites-available/golfgame`:
```nginx
upstream golfgame {
server 127.0.0.1:8000;
keepalive 64;
}
server {
listen 80;
server_name your-domain.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL configuration (use certbot for Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
proxy_pass http://golfgame;
proxy_http_version 1.1;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}
```
Enable the site:
```bash
sudo ln -s /etc/nginx/sites-available/golfgame /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
#### 10. SSL Certificate (Let's Encrypt)
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.com
```
---
## Docker Deployment
### Build the Docker Image
```bash
./scripts/docker-build.sh
# Or manually:
docker build -t golfgame:latest .
```
### Development with Docker
```bash
# Start dev services only (PostgreSQL + Redis)
docker-compose -f docker-compose.dev.yml up -d
```
### Production with Docker Compose
```bash
# Set required environment variables
export DB_PASSWORD="your-secure-database-password"
export SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
export ACME_EMAIL="your-email@example.com"
export DOMAIN="your-domain.com"
# Optional
export RESEND_API_KEY="your-resend-key"
export SENTRY_DSN="your-sentry-dsn"
# Start all services
docker-compose -f docker-compose.prod.yml up -d
# View logs
docker-compose -f docker-compose.prod.yml logs -f app
# Scale app instances
docker-compose -f docker-compose.prod.yml up -d --scale app=3
```
The production compose file includes:
- **app**: The Golf game server (scalable)
- **postgres**: PostgreSQL database
- **redis**: Redis for sessions
- **traefik**: Reverse proxy with automatic HTTPS
---
## Configuration Reference
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `HOST` | `0.0.0.0` | Server bind address |
| `PORT` | `8000` | Server port |
| `DEBUG` | `false` | Enable debug mode |
| `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
| `ENVIRONMENT` | `production` | Environment name |
| `DATABASE_URL` | - | PostgreSQL URL (event sourcing, game logs, stats) |
| `POSTGRES_URL` | - | PostgreSQL URL for auth/stats (can be same as DATABASE_URL) |
| `SECRET_KEY` | - | Secret key for JWT tokens |
| `MAX_PLAYERS_PER_ROOM` | `6` | Maximum players per game room |
| `ROOM_TIMEOUT_MINUTES` | `60` | Inactive room cleanup timeout |
| `ROOM_CODE_LENGTH` | `4` | Length of room codes |
| `DEFAULT_ROUNDS` | `9` | Default holes per game |
| `SENTRY_DSN` | - | Sentry error tracking DSN |
| `RESEND_API_KEY` | - | Resend API key for emails |
| `RATE_LIMIT_ENABLED` | `false` | Enable rate limiting |
### File Locations
| Path | Description |
|------|-------------|
| `/opt/golfgame/` | Production installation root |
| `/opt/golfgame/.env` | Production environment config |
| `/opt/golfgame/server/` | Server application code |
| `/opt/golfgame/client/` | Static web client |
| `/opt/golfgame/bin/` | Python virtualenv binaries |
| `/etc/systemd/system/golfgame.service` | Systemd service file |
| `/etc/nginx/sites-available/golfgame` | Nginx site config |
---
## Troubleshooting
### Check Service Status
```bash
# Systemd service
sudo systemctl status golfgame
journalctl -u golfgame -n 100
# Docker containers
docker-compose -f docker-compose.dev.yml ps
docker-compose -f docker-compose.dev.yml logs
# Using the installer
./scripts/install.sh
# Select option 6: Show Status
```
### Health Check
```bash
curl http://localhost:8000/health
# Expected: {"status":"ok","timestamp":"..."}
```
### Common Issues
#### "No module named 'config'"
The server must be started from the `server/` directory:
```bash
cd /opt/golfgame/server
../bin/uvicorn main:app --host 0.0.0.0 --port 8000
```
#### "Connection refused" on PostgreSQL
1. Check PostgreSQL is running:
```bash
sudo systemctl status postgresql
# Or for Docker:
docker ps | grep postgres
```
2. Verify connection settings in `.env`
3. Test connection:
```bash
psql -h localhost -U golf -d golf
```
#### "POSTGRES_URL not configured" warning
Add `POSTGRES_URL` to your `.env` file. This is required for authentication and stats features.
#### Broken virtualenv symlinks
If Python was upgraded, the virtualenv symlinks may break. Recreate it:
```bash
rm -rf bin lib lib64 pyvenv.cfg include share
python3 -m venv .
source bin/activate
pip install -e ".[dev]" # or just: pip install .
```
#### Permission denied on /opt/golfgame
```bash
sudo chown -R www-data:www-data /opt/golfgame
sudo chmod 600 /opt/golfgame/.env
```
### Updating
#### Development
```bash
git pull
source bin/activate
pip install -e ".[dev]"
# Server auto-reloads with --reload flag
```
#### Production
```bash
cd /opt/golfgame
sudo systemctl stop golfgame
sudo -u www-data git pull
sudo -u www-data ./bin/pip install .
sudo systemctl start golfgame
```
#### Docker
```bash
docker-compose -f docker-compose.prod.yml down
git pull
docker-compose -f docker-compose.prod.yml build
docker-compose -f docker-compose.prod.yml up -d
```
---
## Scripts Reference
| Script | Description |
|--------|-------------|
| `scripts/install.sh` | Interactive installer menu |
| `scripts/dev-server.sh` | Start development server |
| `scripts/docker-build.sh` | Build production Docker image |
---
## Support
- GitHub Issues: https://github.com/alee/golfgame/issues
- Documentation: See `README.md` for game rules and API docs

221
README.md
View File

@@ -1,61 +1,45 @@
# Golf Card Game
A multiplayer online 6-card Golf card game with AI opponents and extensive house rules support.
A real-time multiplayer 6-card Golf card game with AI opponents, smooth anime.js animations, and extensive house rules support.
## Features
- **Multiplayer:** 2-6 players via WebSocket
- **Real-time Multiplayer:** 2-6 players via WebSocket
- **AI Opponents:** 8 unique CPU personalities with distinct play styles
- **House Rules:** 15+ optional rule variants
- **Game Logging:** SQLite logging for AI decision analysis
- **Comprehensive Testing:** 80+ tests for rules and AI behavior
- **Smooth Animations:** Anime.js-powered card dealing, drawing, swapping, and flipping
- **User Accounts:** Registration, login, email verification
- **Stats & Leaderboards:** Player statistics, win rates, and rankings
- **Game Replay:** Review completed games with full playback
- **Admin Tools:** User management, game moderation, system monitoring
- **Event Sourcing:** Full game history stored for replay and analysis
- **Production Ready:** Docker, systemd, nginx, rate limiting, Sentry integration
## Quick Start
### 1. Install Dependencies
```bash
cd server
pip install -r requirements.txt
# Install dependencies
pip install -r server/requirements.txt
# Run the server
python server/main.py
# Visit http://localhost:8000
```
### 2. Start the Server
For full installation instructions (Docker, production deployment, etc.), see [INSTALL.md](INSTALL.md).
```bash
cd server
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
## How to Play
### 3. Open the Game
**6-Card Golf** is a card game where you try to get the **lowest score** across multiple rounds (holes).
Open `http://localhost:8000` in your browser.
- Each player has 6 cards in a 2x3 grid (most start face-down)
- On your turn: **draw** a card, then **swap** it with one of yours or **discard** it
- **Column pairs** (same rank top & bottom) score **0 points** — very powerful!
- When any player reveals all 6 cards, everyone else gets one final turn
- Lowest total score after all rounds wins
## Game Rules
See [server/RULES.md](server/RULES.md) for complete rules documentation.
### Basic Scoring
| Card | Points |
|------|--------|
| Ace | 1 |
| 2 | **-2** |
| 3-10 | Face value |
| Jack, Queen | 10 |
| King | **0** |
| Joker | -2 |
**Column pairs** (same rank in a column) score **0 points**.
### Turn Structure
1. Draw from deck OR take from discard pile
2. **If from deck:** Swap with a card OR discard and flip a face-down card
3. **If from discard:** Must swap (cannot re-discard)
### Ending
When a player reveals all 6 cards, others get one final turn. Lowest score wins.
**For detailed rules, card values, and house rule explanations, see the in-game Rules page or [server/RULES.md](server/RULES.md).**
## AI Personalities
@@ -72,23 +56,14 @@ When a player reveals all 6 cards, others get one final turn. Lowest score wins.
## House Rules
### Point Modifiers
- `super_kings` - Kings worth -2 (instead of 0)
- `ten_penny` - 10s worth 1 (instead of 10)
- `lucky_swing` - Single Joker worth -5
- `eagle_eye` - Paired Jokers score -8
The game supports 15+ optional house rules including:
### Bonuses & Penalties
- `knock_bonus` - First to go out gets -5
- `underdog_bonus` - Lowest scorer gets -3
- `knock_penalty` - +10 if you go out but aren't lowest
- `tied_shame` - +5 penalty for tied scores
- `blackjack` - Score of exactly 21 becomes 0
- **Flip Modes** - Standard, Speed Golf (must flip after discard), Suspense (optional flip near endgame)
- **Point Modifiers** - Super Kings (-2), Ten Penny (10=1), Lucky Swing Joker (-5)
- **Bonuses & Penalties** - Knock bonus/penalty, Underdog bonus, Tied Shame, Blackjack (21->0)
- **Joker Variants** - Standard, Eagle Eye (paired Jokers = -8)
### Gameplay Options
- `flip_on_discard` - Must flip a card when discarding from deck
- `use_jokers` - Add Jokers to deck
- `eagle_eye` - Paired Jokers score -8 instead of canceling
See the in-game Rules page or [server/RULES.md](server/RULES.md) for complete explanations.
## Development
@@ -96,51 +71,117 @@ When a player reveals all 6 cards, others get one final turn. Lowest score wins.
```
golfgame/
├── server/
│ ├── main.py # FastAPI WebSocket server
│ ├── game.py # Core game logic
│ ├── ai.py # AI decision making
│ ├── room.py # Room/lobby management
│ ├── game_log.py # SQLite logging
│ ├── game_analyzer.py # Decision analysis CLI
│ ├── simulate.py # AI-vs-AI simulation
│ ├── score_analysis.py # Score distribution analysis
│ ├── test_game.py # Game rules tests
│ ├── test_analyzer.py # Analyzer tests
│ ├── test_maya_bug.py # Bug regression tests
│ ├── test_house_rules.py # House rules testing
── RULES.md # Rules documentation
├── client/
│ ├── index.html
│ ├── style.css
└── app.js
├── server/ # Python FastAPI backend
│ ├── main.py # HTTP routes, WebSocket server, lifespan
│ ├── game.py # Core game logic, state machine
│ ├── ai.py # CPU opponent AI with timing/personality
│ ├── handlers.py # WebSocket message handlers
│ ├── room.py # Room/lobby management
│ ├── config.py # Environment configuration (pydantic)
│ ├── constants.py # Card values, game constants
│ ├── auth.py # Authentication (JWT, passwords)
│ ├── logging_config.py # Structured logging setup
│ ├── simulate.py # AI-vs-AI simulation runner
│ ├── game_analyzer.py # Decision analysis CLI
│ ├── score_analysis.py # Score distribution analysis
── routers/ # FastAPI route modules
│ │ ├── auth.py # Login, signup, verify endpoints
│ ├── admin.py # Admin management endpoints
│ ├── stats.py # Statistics & leaderboard endpoints
│ ├── replay.py # Game replay endpoints
│ │ └── health.py # Health check endpoints
│ ├── services/ # Business logic layer
│ │ ├── auth_service.py # User authentication
│ │ ├── admin_service.py # Admin tools
│ │ ├── stats_service.py # Player statistics & leaderboards
│ │ ├── replay_service.py # Game replay functionality
│ │ ├── game_logger.py # PostgreSQL game move logging
│ │ ├── spectator.py # Spectator mode
│ │ ├── email_service.py # Email notifications (Resend)
│ │ ├── recovery_service.py # Account recovery
│ │ └── ratelimit.py # Rate limiting
│ ├── stores/ # Data persistence layer
│ │ ├── event_store.py # PostgreSQL event sourcing
│ │ ├── user_store.py # User persistence
│ │ ├── state_cache.py # Redis state caching
│ │ └── pubsub.py # Pub/sub messaging
│ ├── models/ # Data models
│ │ ├── events.py # Event types for event sourcing
│ │ ├── game_state.py # Game state representation
│ │ └── user.py # User data model
│ ├── middleware/ # Request middleware
│ │ ├── security.py # CORS, CSP, security headers
│ │ ├── request_id.py # Request ID tracking
│ │ └── ratelimit.py # Rate limiting middleware
│ ├── RULES.md # Rules documentation
│ └── test_*.py # Test files
├── client/ # Vanilla JS frontend
│ ├── index.html # Main game page
│ ├── app.js # Main game controller
│ ├── card-animations.js # Unified anime.js animation system
│ ├── card-manager.js # DOM management for cards
│ ├── animation-queue.js # Animation sequencing
│ ├── timing-config.js # Centralized timing configuration
│ ├── state-differ.js # Diff game state for animations
│ ├── style.css # Styles (NO card transitions)
│ ├── admin.html # Admin panel
│ ├── admin.js # Admin panel interface
│ ├── admin.css # Admin panel styles
│ ├── replay.js # Game replay viewer
│ ├── leaderboard.js # Leaderboard display
│ └── ANIMATIONS.md # Animation system documentation
├── scripts/ # Helper scripts
│ ├── install.sh # Interactive installer
│ ├── dev-server.sh # Development server launcher
│ └── docker-build.sh # Docker image builder
├── docs/ # Architecture documentation
│ ├── ANIMATION-FLOWS.md # Animation flow diagrams
│ ├── v2/ # V2 architecture docs
│ └── v3/ # V3 feature & refactoring docs
├── tests/e2e/ # End-to-end tests (Playwright)
├── docker-compose.dev.yml # Dev Docker services (PostgreSQL + Redis)
├── docker-compose.prod.yml # Production Docker setup
├── Dockerfile # Container definition
├── pyproject.toml # Python project metadata
├── INSTALL.md # Installation & deployment guide
├── CLAUDE.md # Project context for AI assistants
└── README.md
```
### Running Tests
```bash
cd server
pytest test_game.py test_analyzer.py test_maya_bug.py -v
# All server tests
cd server && pytest -v
# Specific test files
pytest test_game.py test_ai_decisions.py test_handlers.py test_room.py -v
# With coverage
pytest --cov=. --cov-report=term-missing
```
### AI Simulation
```bash
# Run 50 games with 4 AI players
python simulate.py 50 4
# Run 500 games and check dumb move rate
python server/simulate.py 500
# Run detailed single game
python simulate.py detail 4
# Detailed single game output
python server/simulate.py 1 --detailed
# Compare rule presets
python server/simulate.py 100 --compare
# Analyze AI decisions for blunders
python game_analyzer.py blunders
python server/game_analyzer.py blunders
# Score distribution analysis
python score_analysis.py 100 4
# Test all house rules
python test_house_rules.py 40
python server/score_analysis.py 100
```
### AI Performance
@@ -153,10 +194,12 @@ From testing (1000+ games):
## Technology Stack
- **Backend:** Python 3.12+, FastAPI, WebSockets
- **Frontend:** Vanilla HTML/CSS/JavaScript
- **Database:** SQLite (optional, for game logging)
- **Testing:** pytest
- **Backend:** Python 3.11+, FastAPI, WebSockets
- **Frontend:** Vanilla HTML/CSS/JavaScript, anime.js (animations)
- **Database:** PostgreSQL (event sourcing, auth, stats, game logs)
- **Cache:** Redis (state caching, pub/sub)
- **Testing:** pytest, Playwright (e2e)
- **Deployment:** Docker, systemd, nginx
## License

307
client/ANIMATIONS.md Normal file
View File

@@ -0,0 +1,307 @@
# Card Animation System
This document describes the unified animation system for the Golf card game client.
For detailed animation flow diagrams (what triggers what, in what order, with what flags), see [`docs/ANIMATION-FLOWS.md`](../docs/ANIMATION-FLOWS.md).
## Architecture
**When to use anime.js vs CSS:**
- **Anime.js (CardAnimations)**: Card movements, flips, swaps, draws - anything involving card elements
- **CSS keyframes/transitions**: Simple UI feedback (button hover, badge entrance, status message fades) - non-card elements
**General rule:** If it moves a card, use anime.js. If it's UI chrome, CSS is fine.
| What | How |
|------|-----|
| Card movements | anime.js |
| Card flips | anime.js |
| Swap animations | anime.js |
| Pulse/glow effects on cards | anime.js |
| Button hover/active states | CSS transitions |
| Badge entrance/exit | CSS transitions |
| Status message fades | CSS transitions |
| Card hover states | anime.js `hoverIn()`/`hoverOut()` |
| Show/hide | CSS `.hidden` class only |
### Why anime.js?
- Consistent timing and easing across all animations
- Coordinated multi-element sequences via timelines
- Proper animation cancellation via `activeAnimations` tracking
- No conflicts between CSS and JS animation systems
---
## Core Files
| File | Purpose |
|------|---------|
| `card-animations.js` | Unified `CardAnimations` class - all animation logic |
| `timing-config.js` | Centralized timing/easing configuration |
| `style.css` | Static styles only (no transitions on cards) |
---
## CardAnimations Class API
Global instance available at `window.cardAnimations`.
### Draw Animations
```javascript
// Draw from deck - lift, move to hold area, flip to reveal
cardAnimations.animateDrawDeck(cardData, onComplete)
// Draw from discard - quick grab, no flip
cardAnimations.animateDrawDiscard(cardData, onComplete)
// For opponent draw-then-discard - deck to discard with flip
cardAnimations.animateDeckToDiscard(card, onComplete)
```
### Flip Animations
```javascript
// Generic flip animation on any card element
cardAnimations.animateFlip(element, cardData, onComplete)
// Initial flip at game start (local player)
cardAnimations.animateInitialFlip(cardElement, cardData, onComplete)
// Opponent card flip (fire-and-forget)
cardAnimations.animateOpponentFlip(cardElement, cardData, rotation)
```
### Swap Animations
```javascript
// Player swaps drawn card with hand card
cardAnimations.animateSwap(position, oldCard, newCard, handCardElement, onComplete)
// Opponent swap (fire-and-forget)
cardAnimations.animateOpponentSwap(playerId, position, discardCard, sourceCardElement, rotation, wasFaceUp)
```
### Discard Animations
```javascript
// Animate held card swooping to discard pile
cardAnimations.animateDiscard(heldCardElement, targetCard, onComplete)
```
### Ambient Effects (Looping)
```javascript
// "Your turn to draw" shake effect
cardAnimations.startTurnPulse(element)
cardAnimations.stopTurnPulse(element)
// CPU thinking glow
cardAnimations.startCpuThinking(element)
cardAnimations.stopCpuThinking(element)
// Initial flip phase - clickable cards glow
cardAnimations.startInitialFlipPulse(element)
cardAnimations.stopInitialFlipPulse(element)
cardAnimations.stopAllInitialFlipPulses()
```
### One-Shot Effects
```javascript
// Pulse when card lands on discard
cardAnimations.pulseDiscard()
// Pulse effect on face-up swap
cardAnimations.pulseSwap(element)
// Pop-in when element appears (use sparingly)
cardAnimations.popIn(element)
// Gold ring expanding effect before draw
cardAnimations.startDrawPulse(element)
```
### Utility Methods
```javascript
// Check if animation is in progress
cardAnimations.isBusy()
// Cancel all running animations
cardAnimations.cancel()
cardAnimations.cancelAll()
// Clean up animation elements
cardAnimations.cleanup()
```
---
## Animation Coordination
### Server-Client Timing
Server CPU timing (in `server/ai.py` `CPU_TIMING`) must account for client animation durations:
- `post_draw_settle`: Must be >= draw animation duration (~1.1s for deck draw)
- `post_action_pause`: Must be >= swap/discard animation duration (~0.5s)
### Preventing Animation Overlap
Animation overlay cards are marked with `data-animating="true"` while active.
Methods like `animateUnifiedSwap` and `animateOpponentDiscard` check for active
animations and wait before starting new ones.
### Card Hover Initialization
Call `cardAnimations.initHoverListeners(container)` after dynamically creating cards.
This is done automatically in `renderGame()` for player and opponent card areas.
---
## Animation Overlay Pattern
For complex animations (flips, swaps), the system:
1. Creates a temporary overlay element (`.draw-anim-card`)
2. Positions it exactly over the source card
3. Hides the original card (`opacity: 0` or `.swap-out`)
4. Animates the overlay
5. Removes overlay and reveals updated original card
This ensures smooth animations without modifying the DOM structure of game cards.
---
## Timing Configuration
All timing values are in `timing-config.js` and exposed as `window.TIMING`.
### Key Durations
All durations are configured in `timing-config.js` and read via `window.TIMING`.
| Animation | Duration | Config Key | Notes |
|-----------|----------|------------|-------|
| Flip | 320ms | `card.flip` | 3D rotateY with slight overshoot |
| Deck lift | 120ms | `draw.deckLift` | Visible lift before travel |
| Deck move | 250ms | `draw.deckMove` | Smooth travel to hold position |
| Deck flip | 320ms | `draw.deckFlip` | Reveal drawn card |
| Discard lift | 80ms | `draw.discardLift` | Quick decisive grab |
| Discard move | 200ms | `draw.discardMove` | Travel to hold position |
| Swap lift | 100ms | `swap.lift` | Pickup before arc travel |
| Swap arc | 320ms | `swap.arc` | Arc travel between positions |
| Swap settle | 100ms | `swap.settle` | Landing with gentle overshoot |
| Swap pulse | 400ms | — | Scale + brightness (face-up swap) |
| Turn shake | 400ms | — | Every 3 seconds |
### Easing Functions
Custom cubic bezier curves give cards natural weight and momentum:
```javascript
window.TIMING.anime.easing = {
flip: 'cubicBezier(0.34, 1.2, 0.64, 1)', // Slight overshoot snap
move: 'cubicBezier(0.22, 0.68, 0.35, 1.0)', // Smooth deceleration
lift: 'cubicBezier(0.0, 0.0, 0.2, 1)', // Quick out, soft stop
settle: 'cubicBezier(0.34, 1.05, 0.64, 1)', // Tiny overshoot on landing
arc: 'cubicBezier(0.45, 0, 0.15, 1)', // Smooth S-curve for arcs
pulse: 'easeInOutSine', // Smooth oscillation (loops)
}
```
---
## CSS Rules
### What CSS Does
- Static card appearance (colors, borders, sizing)
- Layout and positioning
- Card hover states (`:hover` scale/shadow - no movement)
- Show/hide via `.hidden` class
- **UI chrome animations** (buttons, badges, status messages):
- Button hover/active transitions
- Badge entrance/exit animations
- Status message fade in/out
- Modal transitions
### What CSS Does NOT Do (on card elements)
- No `transition` on any card element (`.card`, `.card-inner`, `.real-card`, `.swap-card`, `.held-card-floating`)
- No `@keyframes` for card movements or flips
- No `.flipped`, `.moving`, `.flipping` transition triggers for cards
### Important Classes
| Class | Purpose |
|-------|---------|
| `.draw-anim-card` | Temporary overlay during animation |
| `.draw-anim-inner` | 3D flip container |
| `.swap-out` | Hides original during swap animation |
| `.hidden` | Opacity 0, no display change |
| `.draw-pulse` | Gold ring expanding effect |
---
## Common Patterns
### Preventing Premature UI Updates
The `isDrawAnimating` flag in `app.js` prevents the held card from appearing before the draw animation completes:
```javascript
// In renderGame()
if (!this.isDrawAnimating && /* other conditions */) {
// Show held card
}
```
### Animation Sequencing
Use anime.js timelines for coordinated sequences:
```javascript
const T = window.TIMING;
const timeline = anime.timeline({
easing: T.anime.easing.move,
complete: () => { /* cleanup */ }
});
timeline.add({ targets: el, translateY: -15, duration: T.card.lift, easing: T.anime.easing.lift });
timeline.add({ targets: el, left: x, top: y, duration: T.card.move });
timeline.add({ targets: inner, rotateY: 0, duration: T.card.flip, easing: T.anime.easing.flip });
```
### Fire-and-Forget Animations
For opponent/CPU animations that don't block game flow:
```javascript
// No onComplete callback needed
cardAnimations.animateOpponentFlip(cardElement, cardData);
```
---
## Debugging
### Check Active Animations
```javascript
console.log(window.cardAnimations.activeAnimations);
```
### Force Cleanup
```javascript
window.cardAnimations.cancelAll();
```
### Animation Not Working?
1. Check that anime.js is loaded before card-animations.js
2. Verify element exists and is visible
3. Check for CSS transitions that might conflict
4. Look for errors in console

633
client/admin.css Normal file
View File

@@ -0,0 +1,633 @@
/* Golf Admin Dashboard Styles */
:root {
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-success: #059669;
--color-warning: #d97706;
--color-danger: #dc2626;
--color-bg: #f8fafc;
--color-surface: #ffffff;
--color-border: #e2e8f0;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-text-light: #94a3b8;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.5;
}
/* Screens */
.screen {
min-height: 100vh;
}
.hidden {
display: none !important;
}
/* Login Screen */
.login-container {
max-width: 400px;
margin: 100px auto;
padding: 2rem;
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.login-container h1 {
text-align: center;
margin-bottom: 1.5rem;
color: var(--color-primary);
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--color-text);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--color-primary);
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.error {
color: var(--color-danger);
margin-top: 1rem;
text-align: center;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
background: var(--color-border);
color: var(--color-text);
}
.btn:hover {
filter: brightness(0.95);
}
.btn:active {
transform: scale(0.98);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
background: var(--color-primary-dark);
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-warning {
background: var(--color-warning);
color: white;
}
.btn-danger {
background: var(--color-danger);
color: white;
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.75rem;
}
/* Navigation */
.admin-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow);
}
.nav-brand h1 {
font-size: 1.5rem;
color: var(--color-primary);
}
.nav-links {
display: flex;
gap: 0.5rem;
}
.nav-link {
padding: 0.5rem 1rem;
text-decoration: none;
color: var(--color-text-muted);
border-radius: var(--radius);
transition: background-color 0.2s, color 0.2s;
}
.nav-link:hover {
background: var(--color-bg);
color: var(--color-text);
}
.nav-link.active {
background: var(--color-primary);
color: white;
}
.nav-user {
display: flex;
align-items: center;
gap: 1rem;
color: var(--color-text-muted);
}
/* Content */
.admin-content {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Panels */
.panel {
background: var(--color-surface);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
}
.panel h2 {
margin-bottom: 1.5rem;
color: var(--color-text);
}
.panel-section {
margin-top: 2rem;
}
.panel-section h3 {
margin-bottom: 1rem;
color: var(--color-text);
}
.panel-toolbar {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--color-bg);
padding: 1.25rem;
border-radius: var(--radius);
text-align: center;
}
.stat-value {
display: block;
font-size: 2rem;
font-weight: 700;
color: var(--color-primary);
}
.stat-label {
display: block;
font-size: 0.875rem;
color: var(--color-text-muted);
margin-top: 0.25rem;
}
/* Data Tables */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.data-table th {
background: var(--color-bg);
font-weight: 600;
color: var(--color-text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.data-table tbody tr:hover {
background: var(--color-bg);
}
.data-table.small {
font-size: 0.875rem;
}
.data-table.small th,
.data-table.small td {
padding: 0.5rem;
}
/* Search Bar */
.search-bar {
display: flex;
gap: 0.5rem;
}
.search-bar input {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
min-width: 250px;
}
.search-bar input:focus {
outline: none;
border-color: var(--color-primary);
}
/* Filter Bar */
.filter-bar {
display: flex;
gap: 0.5rem;
}
.filter-bar select {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: white;
}
/* Checkbox */
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.875rem;
}
/* Create Invite Form */
.create-invite-form {
display: flex;
gap: 1rem;
align-items: center;
}
.create-invite-form label {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.create-invite-form input {
width: 80px;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
/* Status Badges */
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-success {
background: #dcfce7;
color: #166534;
}
.badge-danger {
background: #fee2e2;
color: #991b1b;
}
.badge-warning {
background: #fef3c7;
color: #92400e;
}
.badge-info {
background: #dbeafe;
color: #1e40af;
}
.badge-muted {
background: #f1f5f9;
color: #64748b;
}
/* Modals */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--color-surface);
border-radius: var(--radius);
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-content.modal-small {
max-width: 400px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.modal-header h3 {
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--color-text-muted);
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: var(--color-text);
}
.modal-body {
padding: 1.5rem;
}
/* User Detail Modal */
.user-detail-grid {
display: grid;
gap: 0.75rem;
}
.detail-row {
display: flex;
gap: 1rem;
}
.detail-label {
font-weight: 500;
color: var(--color-text-muted);
min-width: 120px;
}
.detail-value {
color: var(--color-text);
}
.user-actions {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
.user-actions h4 {
margin-bottom: 1rem;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
#ban-history-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
#ban-history-section h4 {
margin-bottom: 1rem;
}
/* Toast Notifications */
#toast-container {
position: fixed;
bottom: 2rem;
right: 2rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 2000;
}
.toast {
padding: 1rem 1.5rem;
border-radius: var(--radius);
background: var(--color-text);
color: white;
box-shadow: var(--shadow-lg);
animation: slideIn 0.3s ease;
}
.toast.success {
background: var(--color-success);
}
.toast.error {
background: var(--color-danger);
}
.toast.warning {
background: var(--color-warning);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive */
@media (max-width: 768px) {
.admin-nav {
flex-direction: column;
gap: 1rem;
}
.nav-links {
flex-wrap: wrap;
justify-content: center;
}
.admin-content {
padding: 1rem;
}
.panel-toolbar {
flex-direction: column;
align-items: stretch;
}
.search-bar {
flex-direction: column;
}
.search-bar input {
min-width: auto;
width: 100%;
}
.create-invite-form {
flex-direction: column;
align-items: stretch;
}
.data-table {
font-size: 0.875rem;
}
.data-table th,
.data-table td {
padding: 0.5rem;
}
}
/* Utility Classes */
.text-muted {
color: var(--color-text-muted);
}
.text-success {
color: var(--color-success);
}
.text-danger {
color: var(--color-danger);
}
.text-warning {
color: var(--color-warning);
}
.text-small {
font-size: 0.875rem;
}
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }

368
client/admin.html Normal file
View File

@@ -0,0 +1,368 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Golf Admin Dashboard</title>
<link rel="stylesheet" href="admin.css">
</head>
<body>
<!-- Login Screen -->
<div id="login-screen" class="screen">
<div class="login-container">
<h1>Golf Admin</h1>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
<p id="login-error" class="error"></p>
</form>
</div>
</div>
<!-- Dashboard Screen -->
<div id="dashboard-screen" class="screen hidden">
<nav class="admin-nav">
<div class="nav-brand">
<h1>Golf Admin</h1>
</div>
<div class="nav-links">
<a href="#" data-panel="dashboard" class="nav-link active">Dashboard</a>
<a href="#" data-panel="users" class="nav-link">Users</a>
<a href="#" data-panel="games" class="nav-link">Games</a>
<a href="#" data-panel="invites" class="nav-link">Invites</a>
<a href="#" data-panel="audit" class="nav-link">Audit Log</a>
</div>
<div class="nav-user">
<span id="admin-username"></span>
<button id="logout-btn" class="btn btn-small">Logout</button>
</div>
</nav>
<main class="admin-content">
<!-- Dashboard Panel -->
<section id="dashboard-panel" class="panel">
<h2>System Overview</h2>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value" id="stat-active-users">-</span>
<span class="stat-label">Active Users (1h)</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-active-games">-</span>
<span class="stat-label">Active Games</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-total-users">-</span>
<span class="stat-label">Total Users</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-games-today">-</span>
<span class="stat-label">Games Today</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-reg-today">-</span>
<span class="stat-label">Registrations Today</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-reg-week">-</span>
<span class="stat-label">Registrations (7d)</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-total-games">-</span>
<span class="stat-label">Total Games</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-events-hour">-</span>
<span class="stat-label">Events (1h)</span>
</div>
</div>
<div class="panel-section">
<h3>Top Players</h3>
<table id="top-players-table" class="data-table">
<thead>
<tr>
<th>#</th>
<th>Username</th>
<th>Wins</th>
<th>Games</th>
<th>Win Rate</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- Users Panel -->
<section id="users-panel" class="panel hidden">
<h2>User Management</h2>
<div class="panel-toolbar">
<div class="search-bar">
<input type="text" id="user-search" placeholder="Search by username or email...">
<button id="user-search-btn" class="btn">Search</button>
</div>
<label class="checkbox-label">
<input type="checkbox" id="include-banned" checked>
Include banned
</label>
</div>
<table id="users-table" class="data-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Games</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="pagination">
<button id="users-prev" class="btn btn-small" disabled>Previous</button>
<span id="users-page-info">Page 1</span>
<button id="users-next" class="btn btn-small">Next</button>
</div>
</section>
<!-- Games Panel -->
<section id="games-panel" class="panel hidden">
<h2>Active Games</h2>
<button id="refresh-games-btn" class="btn">Refresh</button>
<table id="games-table" class="data-table">
<thead>
<tr>
<th>Room Code</th>
<th>Players</th>
<th>Phase</th>
<th>Round</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Invites Panel -->
<section id="invites-panel" class="panel hidden">
<h2>Invite Codes</h2>
<div class="panel-toolbar">
<div class="create-invite-form">
<label>
Max Uses:
<input type="number" id="invite-max-uses" value="1" min="1" max="100">
</label>
<label>
Expires in (days):
<input type="number" id="invite-expires-days" value="7" min="1" max="365">
</label>
<button id="create-invite-btn" class="btn btn-primary">Create Invite</button>
</div>
<label class="checkbox-label">
<input type="checkbox" id="include-expired">
Show expired
</label>
</div>
<table id="invites-table" class="data-table">
<thead>
<tr>
<th>Code</th>
<th>Uses</th>
<th>Remaining</th>
<th>Created By</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Audit Log Panel -->
<section id="audit-panel" class="panel hidden">
<h2>Audit Log</h2>
<div class="panel-toolbar">
<div class="filter-bar">
<select id="audit-action-filter">
<option value="">All Actions</option>
<option value="ban_user">Ban User</option>
<option value="unban_user">Unban User</option>
<option value="force_password_reset">Force Password Reset</option>
<option value="change_role">Change Role</option>
<option value="impersonate_user">Impersonate</option>
<option value="view_game">View Game</option>
<option value="end_game">End Game</option>
<option value="create_invite">Create Invite</option>
<option value="revoke_invite">Revoke Invite</option>
</select>
<select id="audit-target-filter">
<option value="">All Targets</option>
<option value="user">Users</option>
<option value="game">Games</option>
<option value="invite_code">Invites</option>
</select>
<button id="audit-filter-btn" class="btn">Filter</button>
</div>
</div>
<table id="audit-table" class="data-table">
<thead>
<tr>
<th>Time</th>
<th>Admin</th>
<th>Action</th>
<th>Target</th>
<th>Details</th>
<th>IP</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="pagination">
<button id="audit-prev" class="btn btn-small" disabled>Previous</button>
<span id="audit-page-info">Page 1</span>
<button id="audit-next" class="btn btn-small">Next</button>
</div>
</section>
</main>
</div>
<!-- User Detail Modal -->
<div id="user-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>User Details</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="user-detail-grid">
<div class="detail-row">
<span class="detail-label">Username:</span>
<span id="detail-username" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Email:</span>
<span id="detail-email" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Role:</span>
<span id="detail-role" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Status:</span>
<span id="detail-status" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Games Played:</span>
<span id="detail-games-played" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Games Won:</span>
<span id="detail-games-won" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Joined:</span>
<span id="detail-joined" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Last Login:</span>
<span id="detail-last-login" class="detail-value"></span>
</div>
</div>
<div class="user-actions">
<h4>Actions</h4>
<div class="action-buttons">
<button id="action-ban" class="btn btn-danger">Ban User</button>
<button id="action-unban" class="btn btn-success hidden">Unban User</button>
<button id="action-reset-pw" class="btn btn-warning">Force Password Reset</button>
<button id="action-make-admin" class="btn">Make Admin</button>
<button id="action-remove-admin" class="btn hidden">Remove Admin</button>
<button id="action-impersonate" class="btn">Impersonate (Read-Only)</button>
</div>
</div>
<div id="ban-history-section">
<h4>Ban History</h4>
<table id="ban-history-table" class="data-table small">
<thead>
<tr>
<th>Date</th>
<th>Reason</th>
<th>By</th>
<th>Status</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Ban User Modal -->
<div id="ban-modal" class="modal hidden">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Ban User</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="ban-form">
<div class="form-group">
<label for="ban-reason">Reason:</label>
<textarea id="ban-reason" required placeholder="Enter reason for ban..."></textarea>
</div>
<div class="form-group">
<label for="ban-duration">Duration (days, leave empty for permanent):</label>
<input type="number" id="ban-duration" min="1" max="365" placeholder="Permanent">
</div>
<div class="form-actions">
<button type="button" class="btn modal-close">Cancel</button>
<button type="submit" class="btn btn-danger">Ban User</button>
</div>
</form>
</div>
</div>
</div>
<!-- End Game Modal -->
<div id="end-game-modal" class="modal hidden">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>End Game</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="end-game-form">
<div class="form-group">
<label for="end-game-reason">Reason:</label>
<textarea id="end-game-reason" required placeholder="Enter reason for ending game..."></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn modal-close">Cancel</button>
<button type="submit" class="btn btn-danger">End Game</button>
</div>
</form>
</div>
</div>
</div>
<!-- Toast Container -->
<div id="toast-container"></div>
<script src="admin.js"></script>
</body>
</html>

832
client/admin.js Normal file
View File

@@ -0,0 +1,832 @@
/**
* Golf Admin Dashboard
* JavaScript for admin interface functionality
*/
// State
let authToken = null;
let currentUser = null;
let currentPanel = 'dashboard';
let selectedUserId = null;
// Pagination state
let usersPage = 0;
let auditPage = 0;
const PAGE_SIZE = 20;
// =============================================================================
// API Functions
// =============================================================================
async function apiRequest(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(endpoint, {
...options,
headers,
});
if (response.status === 401) {
// Unauthorized - clear auth and show login
logout();
throw new Error('Session expired. Please login again.');
}
if (response.status === 403) {
throw new Error('Admin access required');
}
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Request failed');
}
return data;
}
// Auth API
async function login(username, password) {
const data = await apiRequest('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
return data;
}
// Admin API
async function getStats() {
return apiRequest('/api/admin/stats');
}
async function getUsers(query = '', offset = 0, includeBanned = true) {
const params = new URLSearchParams({
query,
offset,
limit: PAGE_SIZE,
include_banned: includeBanned,
});
return apiRequest(`/api/admin/users?${params}`);
}
async function getUser(userId) {
return apiRequest(`/api/admin/users/${userId}`);
}
async function getUserBanHistory(userId) {
return apiRequest(`/api/admin/users/${userId}/ban-history`);
}
async function banUser(userId, reason, durationDays) {
return apiRequest(`/api/admin/users/${userId}/ban`, {
method: 'POST',
body: JSON.stringify({
reason,
duration_days: durationDays || null,
}),
});
}
async function unbanUser(userId) {
return apiRequest(`/api/admin/users/${userId}/unban`, {
method: 'POST',
});
}
async function forcePasswordReset(userId) {
return apiRequest(`/api/admin/users/${userId}/force-password-reset`, {
method: 'POST',
});
}
async function changeUserRole(userId, role) {
return apiRequest(`/api/admin/users/${userId}/role`, {
method: 'PUT',
body: JSON.stringify({ role }),
});
}
async function impersonateUser(userId) {
return apiRequest(`/api/admin/users/${userId}/impersonate`, {
method: 'POST',
});
}
async function getGames() {
return apiRequest('/api/admin/games');
}
async function getGameDetails(gameId) {
return apiRequest(`/api/admin/games/${gameId}`);
}
async function endGame(gameId, reason) {
return apiRequest(`/api/admin/games/${gameId}/end`, {
method: 'POST',
body: JSON.stringify({ reason }),
});
}
async function getInvites(includeExpired = false) {
const params = new URLSearchParams({ include_expired: includeExpired });
return apiRequest(`/api/admin/invites?${params}`);
}
async function createInvite(maxUses, expiresDays) {
return apiRequest('/api/admin/invites', {
method: 'POST',
body: JSON.stringify({
max_uses: maxUses,
expires_days: expiresDays,
}),
});
}
async function revokeInvite(code) {
return apiRequest(`/api/admin/invites/${code}`, {
method: 'DELETE',
});
}
async function getAuditLog(offset = 0, action = '', targetType = '') {
const params = new URLSearchParams({
offset,
limit: PAGE_SIZE,
});
if (action) params.append('action', action);
if (targetType) params.append('target_type', targetType);
return apiRequest(`/api/admin/audit?${params}`);
}
// =============================================================================
// UI Functions
// =============================================================================
function showScreen(screenId) {
document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden'));
document.getElementById(screenId).classList.remove('hidden');
}
function showPanel(panelId) {
currentPanel = panelId;
document.querySelectorAll('.panel').forEach(p => p.classList.add('hidden'));
document.getElementById(`${panelId}-panel`).classList.remove('hidden');
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.panel === panelId);
});
// Load panel data
switch (panelId) {
case 'dashboard':
loadDashboard();
break;
case 'users':
loadUsers();
break;
case 'games':
loadGames();
break;
case 'invites':
loadInvites();
break;
case 'audit':
loadAuditLog();
break;
}
}
function showModal(modalId) {
document.getElementById(modalId).classList.remove('hidden');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.add('hidden');
}
function hideAllModals() {
document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden'));
}
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 4000);
}
function formatDate(isoString) {
if (!isoString) return '-';
const date = new Date(isoString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatDateShort(isoString) {
if (!isoString) return '-';
const date = new Date(isoString);
return date.toLocaleDateString();
}
function getStatusBadge(user) {
if (user.is_banned) {
return '<span class="badge badge-danger">Banned</span>';
}
if (!user.is_active) {
return '<span class="badge badge-muted">Inactive</span>';
}
if (user.force_password_reset) {
return '<span class="badge badge-warning">Reset Required</span>';
}
if (!user.email_verified && user.email) {
return '<span class="badge badge-warning">Unverified</span>';
}
return '<span class="badge badge-success">Active</span>';
}
// =============================================================================
// Data Loading
// =============================================================================
async function loadDashboard() {
try {
const stats = await getStats();
document.getElementById('stat-active-users').textContent = stats.active_users_now;
document.getElementById('stat-active-games').textContent = stats.active_games_now;
document.getElementById('stat-total-users').textContent = stats.total_users;
document.getElementById('stat-games-today').textContent = stats.games_today;
document.getElementById('stat-reg-today').textContent = stats.registrations_today;
document.getElementById('stat-reg-week').textContent = stats.registrations_week;
document.getElementById('stat-total-games').textContent = stats.total_games_completed;
document.getElementById('stat-events-hour').textContent = stats.events_last_hour;
// Top players table
const tbody = document.querySelector('#top-players-table tbody');
tbody.innerHTML = '';
stats.top_players.forEach((player, index) => {
const winRate = player.games_played > 0
? Math.round((player.games_won / player.games_played) * 100)
: 0;
tbody.innerHTML += `
<tr>
<td>${index + 1}</td>
<td>${escapeHtml(player.username)}</td>
<td>${player.games_won}</td>
<td>${player.games_played}</td>
<td>${winRate}%</td>
</tr>
`;
});
if (stats.top_players.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-muted">No players yet</td></tr>';
}
} catch (error) {
showToast('Failed to load dashboard: ' + error.message, 'error');
}
}
async function loadUsers() {
try {
const query = document.getElementById('user-search').value;
const includeBanned = document.getElementById('include-banned').checked;
const data = await getUsers(query, usersPage * PAGE_SIZE, includeBanned);
const tbody = document.querySelector('#users-table tbody');
tbody.innerHTML = '';
data.users.forEach(user => {
tbody.innerHTML += `
<tr>
<td>${escapeHtml(user.username)}</td>
<td>${escapeHtml(user.email || '-')}</td>
<td><span class="badge badge-${user.role === 'admin' ? 'info' : 'muted'}">${user.role}</span></td>
<td>${getStatusBadge(user)}</td>
<td>${user.games_played} (${user.games_won} wins)</td>
<td>${formatDateShort(user.created_at)}</td>
<td>
<button class="btn btn-small" data-action="view-user" data-id="${user.id}">View</button>
</td>
</tr>
`;
});
if (data.users.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">No users found</td></tr>';
}
// Update pagination
document.getElementById('users-page-info').textContent = `Page ${usersPage + 1}`;
document.getElementById('users-prev').disabled = usersPage === 0;
document.getElementById('users-next').disabled = data.users.length < PAGE_SIZE;
} catch (error) {
showToast('Failed to load users: ' + error.message, 'error');
}
}
async function viewUser(userId) {
try {
selectedUserId = userId;
const user = await getUser(userId);
const history = await getUserBanHistory(userId);
// Populate details
document.getElementById('detail-username').textContent = user.username;
document.getElementById('detail-email').textContent = user.email || '-';
document.getElementById('detail-role').textContent = user.role;
document.getElementById('detail-status').innerHTML = getStatusBadge(user);
document.getElementById('detail-games-played').textContent = user.games_played;
document.getElementById('detail-games-won').textContent = user.games_won;
document.getElementById('detail-joined').textContent = formatDate(user.created_at);
document.getElementById('detail-last-login').textContent = formatDate(user.last_login);
// Update action buttons visibility
document.getElementById('action-ban').classList.toggle('hidden', user.is_banned);
document.getElementById('action-unban').classList.toggle('hidden', !user.is_banned);
document.getElementById('action-make-admin').classList.toggle('hidden', user.role === 'admin');
document.getElementById('action-remove-admin').classList.toggle('hidden', user.role !== 'admin');
// Ban history
const historyBody = document.querySelector('#ban-history-table tbody');
historyBody.innerHTML = '';
history.history.forEach(ban => {
const status = ban.unbanned_at
? `<span class="badge badge-success">Unbanned</span>`
: (ban.expires_at && new Date(ban.expires_at) < new Date()
? `<span class="badge badge-muted">Expired</span>`
: `<span class="badge badge-danger">Active</span>`);
historyBody.innerHTML += `
<tr>
<td>${formatDateShort(ban.banned_at)}</td>
<td>${escapeHtml(ban.reason || '-')}</td>
<td>${escapeHtml(ban.banned_by)}</td>
<td>${status}</td>
</tr>
`;
});
if (history.history.length === 0) {
historyBody.innerHTML = '<tr><td colspan="4" class="text-muted">No ban history</td></tr>';
}
showModal('user-modal');
} catch (error) {
showToast('Failed to load user: ' + error.message, 'error');
}
}
async function loadGames() {
try {
const data = await getGames();
const tbody = document.querySelector('#games-table tbody');
tbody.innerHTML = '';
data.games.forEach(game => {
tbody.innerHTML += `
<tr>
<td><strong>${escapeHtml(game.room_code)}</strong></td>
<td>${game.player_count}</td>
<td>${game.phase || game.status || '-'}</td>
<td>${game.current_round || '-'}</td>
<td><span class="badge badge-${game.status === 'playing' ? 'success' : 'info'}">${game.status}</span></td>
<td>${formatDate(game.created_at)}</td>
<td>
<button class="btn btn-small btn-danger" data-action="end-game" data-id="${game.game_id}">End</button>
</td>
</tr>
`;
});
if (data.games.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">No active games</td></tr>';
}
} catch (error) {
showToast('Failed to load games: ' + error.message, 'error');
}
}
let selectedGameId = null;
function promptEndGame(gameId) {
selectedGameId = gameId;
document.getElementById('end-game-reason').value = '';
showModal('end-game-modal');
}
async function loadInvites() {
try {
const includeExpired = document.getElementById('include-expired').checked;
const data = await getInvites(includeExpired);
const tbody = document.querySelector('#invites-table tbody');
tbody.innerHTML = '';
data.codes.forEach(invite => {
const isExpired = new Date(invite.expires_at) < new Date();
const status = !invite.is_active
? '<span class="badge badge-danger">Revoked</span>'
: isExpired
? '<span class="badge badge-muted">Expired</span>'
: invite.remaining_uses <= 0
? '<span class="badge badge-warning">Used Up</span>'
: '<span class="badge badge-success">Active</span>';
tbody.innerHTML += `
<tr>
<td><code>${escapeHtml(invite.code)}</code></td>
<td>${invite.use_count} / ${invite.max_uses}</td>
<td>${invite.remaining_uses}</td>
<td>${escapeHtml(invite.created_by_username)}</td>
<td>${formatDate(invite.expires_at)}</td>
<td>${status}</td>
<td>
${invite.is_active && !isExpired && invite.remaining_uses > 0
? `<button class="btn btn-small" data-action="copy-invite" data-code="${escapeHtml(invite.code)}">Copy Link</button>
<button class="btn btn-small btn-danger" data-action="revoke-invite" data-code="${escapeHtml(invite.code)}">Revoke</button>`
: '-'
}
</td>
</tr>
`;
});
if (data.codes.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">No invite codes</td></tr>';
}
} catch (error) {
showToast('Failed to load invites: ' + error.message, 'error');
}
}
async function loadAuditLog() {
try {
const action = document.getElementById('audit-action-filter').value;
const targetType = document.getElementById('audit-target-filter').value;
const data = await getAuditLog(auditPage * PAGE_SIZE, action, targetType);
const tbody = document.querySelector('#audit-table tbody');
tbody.innerHTML = '';
data.entries.forEach(entry => {
const details = Object.keys(entry.details).length > 0
? `<code class="text-small">${escapeHtml(JSON.stringify(entry.details))}</code>`
: '-';
tbody.innerHTML += `
<tr>
<td>${formatDate(entry.created_at)}</td>
<td>${escapeHtml(entry.admin_username)}</td>
<td><span class="badge badge-info">${entry.action}</span></td>
<td>${entry.target_type ? `${entry.target_type}: ${entry.target_id || '-'}` : '-'}</td>
<td>${details}</td>
<td class="text-muted text-small">${entry.ip_address || '-'}</td>
</tr>
`;
});
if (data.entries.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">No audit entries</td></tr>';
}
// Update pagination
document.getElementById('audit-page-info').textContent = `Page ${auditPage + 1}`;
document.getElementById('audit-prev').disabled = auditPage === 0;
document.getElementById('audit-next').disabled = data.entries.length < PAGE_SIZE;
} catch (error) {
showToast('Failed to load audit log: ' + error.message, 'error');
}
}
// =============================================================================
// Actions
// =============================================================================
async function handleBanUser(event) {
event.preventDefault();
const reason = document.getElementById('ban-reason').value;
const duration = document.getElementById('ban-duration').value;
try {
await banUser(selectedUserId, reason, duration ? parseInt(duration) : null);
showToast('User banned successfully', 'success');
hideAllModals();
loadUsers();
} catch (error) {
showToast('Failed to ban user: ' + error.message, 'error');
}
}
async function handleUnbanUser() {
if (!confirm('Are you sure you want to unban this user?')) return;
try {
await unbanUser(selectedUserId);
showToast('User unbanned successfully', 'success');
hideAllModals();
loadUsers();
} catch (error) {
showToast('Failed to unban user: ' + error.message, 'error');
}
}
async function handleForcePasswordReset() {
if (!confirm('Are you sure you want to force a password reset for this user? They will be logged out.')) return;
try {
await forcePasswordReset(selectedUserId);
showToast('Password reset required for user', 'success');
hideAllModals();
loadUsers();
} catch (error) {
showToast('Failed to force password reset: ' + error.message, 'error');
}
}
async function handleMakeAdmin() {
if (!confirm('Are you sure you want to make this user an admin?')) return;
try {
await changeUserRole(selectedUserId, 'admin');
showToast('User is now an admin', 'success');
hideAllModals();
loadUsers();
} catch (error) {
showToast('Failed to change role: ' + error.message, 'error');
}
}
async function handleRemoveAdmin() {
if (!confirm('Are you sure you want to remove admin privileges from this user?')) return;
try {
await changeUserRole(selectedUserId, 'user');
showToast('Admin privileges removed', 'success');
hideAllModals();
loadUsers();
} catch (error) {
showToast('Failed to change role: ' + error.message, 'error');
}
}
async function handleImpersonate() {
try {
const data = await impersonateUser(selectedUserId);
showToast(`Viewing as ${data.user.username} (read-only). Check console for details.`, 'success');
console.log('Impersonation data:', data);
} catch (error) {
showToast('Failed to impersonate: ' + error.message, 'error');
}
}
async function handleEndGame(event) {
event.preventDefault();
const reason = document.getElementById('end-game-reason').value;
try {
await endGame(selectedGameId, reason);
showToast('Game ended successfully', 'success');
hideAllModals();
loadGames();
} catch (error) {
showToast('Failed to end game: ' + error.message, 'error');
}
}
async function handleCreateInvite() {
const maxUses = parseInt(document.getElementById('invite-max-uses').value) || 1;
const expiresDays = parseInt(document.getElementById('invite-expires-days').value) || 7;
try {
const data = await createInvite(maxUses, expiresDays);
showToast(`Invite code created: ${data.code}`, 'success');
loadInvites();
} catch (error) {
showToast('Failed to create invite: ' + error.message, 'error');
}
}
function copyInviteLink(code) {
const link = `${window.location.origin}/?invite=${encodeURIComponent(code)}`;
navigator.clipboard.writeText(link).then(() => {
showToast('Invite link copied!', 'success');
}).catch(() => {
// Fallback: select text for manual copy
prompt('Copy this link:', link);
});
}
async function promptRevokeInvite(code) {
if (!confirm(`Are you sure you want to revoke invite code ${code}?`)) return;
try {
await revokeInvite(code);
showToast('Invite code revoked', 'success');
loadInvites();
} catch (error) {
showToast('Failed to revoke invite: ' + error.message, 'error');
}
}
// =============================================================================
// Auth
// =============================================================================
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorEl = document.getElementById('login-error');
try {
const data = await login(username, password);
// Check if user is admin
if (data.user.role !== 'admin') {
errorEl.textContent = 'Admin access required';
return;
}
// Store auth
authToken = data.token;
currentUser = data.user;
localStorage.setItem('adminToken', data.token);
localStorage.setItem('adminUser', JSON.stringify(data.user));
// Show dashboard
document.getElementById('admin-username').textContent = currentUser.username;
showScreen('dashboard-screen');
showPanel('dashboard');
} catch (error) {
errorEl.textContent = error.message;
}
}
function logout() {
authToken = null;
currentUser = null;
localStorage.removeItem('adminToken');
localStorage.removeItem('adminUser');
showScreen('login-screen');
}
function checkAuth() {
const savedToken = localStorage.getItem('adminToken');
const savedUser = localStorage.getItem('adminUser');
if (savedToken && savedUser) {
authToken = savedToken;
currentUser = JSON.parse(savedUser);
if (currentUser.role === 'admin') {
document.getElementById('admin-username').textContent = currentUser.username;
showScreen('dashboard-screen');
showPanel('dashboard');
return;
}
}
showScreen('login-screen');
}
// =============================================================================
// Utilities
// =============================================================================
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// =============================================================================
// Event Listeners
// =============================================================================
document.addEventListener('DOMContentLoaded', () => {
// Login form
document.getElementById('login-form').addEventListener('submit', handleLogin);
// Logout button
document.getElementById('logout-btn').addEventListener('click', logout);
// Navigation
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
showPanel(link.dataset.panel);
});
});
// Users panel
document.getElementById('user-search-btn').addEventListener('click', () => {
usersPage = 0;
loadUsers();
});
document.getElementById('user-search').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
usersPage = 0;
loadUsers();
}
});
document.getElementById('include-banned').addEventListener('change', () => {
usersPage = 0;
loadUsers();
});
document.getElementById('users-prev').addEventListener('click', () => {
if (usersPage > 0) {
usersPage--;
loadUsers();
}
});
document.getElementById('users-next').addEventListener('click', () => {
usersPage++;
loadUsers();
});
// User modal actions
document.getElementById('action-ban').addEventListener('click', () => {
document.getElementById('ban-reason').value = '';
document.getElementById('ban-duration').value = '';
showModal('ban-modal');
});
document.getElementById('action-unban').addEventListener('click', handleUnbanUser);
document.getElementById('action-reset-pw').addEventListener('click', handleForcePasswordReset);
document.getElementById('action-make-admin').addEventListener('click', handleMakeAdmin);
document.getElementById('action-remove-admin').addEventListener('click', handleRemoveAdmin);
document.getElementById('action-impersonate').addEventListener('click', handleImpersonate);
// Ban form
document.getElementById('ban-form').addEventListener('submit', handleBanUser);
// Games panel
document.getElementById('refresh-games-btn').addEventListener('click', loadGames);
// End game form
document.getElementById('end-game-form').addEventListener('submit', handleEndGame);
// Invites panel
document.getElementById('create-invite-btn').addEventListener('click', handleCreateInvite);
document.getElementById('include-expired').addEventListener('change', loadInvites);
// Audit panel
document.getElementById('audit-filter-btn').addEventListener('click', () => {
auditPage = 0;
loadAuditLog();
});
document.getElementById('audit-prev').addEventListener('click', () => {
if (auditPage > 0) {
auditPage--;
loadAuditLog();
}
});
document.getElementById('audit-next').addEventListener('click', () => {
auditPage++;
loadAuditLog();
});
// Modal close buttons
document.querySelectorAll('.modal-close').forEach(btn => {
btn.addEventListener('click', hideAllModals);
});
// Close modal on overlay click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
hideAllModals();
}
});
});
// Delegated click handlers for dynamically-created buttons
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'view-user') viewUser(btn.dataset.id);
else if (action === 'end-game') promptEndGame(btn.dataset.id);
else if (action === 'copy-invite') copyInviteLink(btn.dataset.code);
else if (action === 'revoke-invite') promptRevokeInvite(btn.dataset.code);
});
// Check auth on load
checkAuth();
});

395
client/animation-queue.js Normal file
View File

@@ -0,0 +1,395 @@
// AnimationQueue - Sequences card animations properly
// Ensures animations play in order without overlap
class AnimationQueue {
constructor(cardManager, getSlotRect, getLocationRect, playSound) {
this.cardManager = cardManager;
this.getSlotRect = getSlotRect; // Function to get slot position
this.getLocationRect = getLocationRect; // Function to get deck/discard position
this.playSound = playSound || (() => {}); // Sound callback
this.queue = [];
this.processing = false;
this.animationInProgress = false;
// Timing configuration (ms) - use centralized TIMING config
const T = window.TIMING || {};
this.timing = {
flipDuration: T.card?.flip || 540,
moveDuration: T.card?.move || 270,
cardLift: T.card?.lift || 100,
pauseAfterFlip: T.pause?.afterFlip || 144,
pauseAfterDiscard: T.pause?.afterDiscard || 550,
pauseBeforeNewCard: T.pause?.beforeNewCard || 150,
pauseAfterSwapComplete: T.pause?.afterSwapComplete || 400,
pauseBetweenAnimations: T.pause?.betweenAnimations || 90,
pauseBeforeFlip: T.pause?.beforeFlip || 50,
// Beat timing
beatBase: T.beat?.base || 1000,
beatVariance: T.beat?.variance || 200,
fadeOut: T.beat?.fadeOut || 300,
fadeIn: T.beat?.fadeIn || 300,
};
}
// Add movements to the queue and start processing
async enqueue(movements, onComplete) {
if (!movements || movements.length === 0) {
if (onComplete) onComplete();
return;
}
// Add completion callback to last movement
const movementsWithCallback = movements.map((m, i) => ({
...m,
onComplete: i === movements.length - 1 ? onComplete : null
}));
this.queue.push(...movementsWithCallback);
if (!this.processing) {
await this.processQueue();
}
}
// Process queued animations one at a time
async processQueue() {
if (this.processing) return;
this.processing = true;
this.animationInProgress = true;
while (this.queue.length > 0) {
const movement = this.queue.shift();
try {
await this.animate(movement);
} catch (e) {
console.error('Animation error:', e);
}
// Callback after last movement
if (movement.onComplete) {
movement.onComplete();
}
// Pause between animations
if (this.queue.length > 0) {
await this.delay(this.timing.pauseBetweenAnimations);
}
}
this.processing = false;
this.animationInProgress = false;
}
// Route to appropriate animation
async animate(movement) {
switch (movement.type) {
case 'flip':
await this.animateFlip(movement);
break;
case 'swap':
await this.animateSwap(movement);
break;
case 'discard':
await this.animateDiscard(movement);
break;
case 'draw-deck':
await this.animateDrawDeck(movement);
break;
case 'draw-discard':
await this.animateDrawDiscard(movement);
break;
}
}
// Animate a card flip
async animateFlip(movement) {
const { playerId, position, faceUp, card } = movement;
// Get slot position
const slotRect = this.getSlotRect(playerId, position);
if (!slotRect || slotRect.width === 0 || slotRect.height === 0) {
return;
}
// Create animation card at slot position
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
this.setCardPosition(animCard, slotRect);
const inner = animCard.querySelector('.card-inner');
const front = animCard.querySelector('.card-face-front');
// Set up what we're flipping to (front face)
this.setCardFront(front, card);
// Start face down (flipped = showing back)
inner.classList.add('flipped');
// Force a reflow to ensure the initial state is applied
animCard.offsetHeight;
// Animate the flip
this.playSound('flip');
await this.delay(this.timing.pauseBeforeFlip);
// Remove flipped to trigger animation to front
inner.classList.remove('flipped');
await this.delay(this.timing.flipDuration);
await this.delay(this.timing.pauseAfterFlip);
// Clean up
animCard.remove();
}
// Animate a card swap - smooth continuous motion
async animateSwap(movement) {
const { playerId, position, oldCard, newCard } = movement;
const slotRect = this.getSlotRect(playerId, position);
const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding');
if (!slotRect || !discardRect || slotRect.width === 0) {
return;
}
// Create animation cards
const handCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(handCard);
this.setCardPosition(handCard, slotRect);
const handInner = handCard.querySelector('.card-inner');
const handFront = handCard.querySelector('.card-face-front');
const heldCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(heldCard);
this.setCardPosition(heldCard, holdingRect || discardRect);
const heldInner = heldCard.querySelector('.card-inner');
const heldFront = heldCard.querySelector('.card-face-front');
// Set up initial state
this.setCardFront(handFront, oldCard);
if (!oldCard.face_up) {
handInner.classList.add('flipped');
}
this.setCardFront(heldFront, newCard);
heldInner.classList.remove('flipped');
// Step 1: If face-down, flip to reveal
if (!oldCard.face_up) {
this.playSound('flip');
handInner.classList.remove('flipped');
await this.delay(this.timing.flipDuration);
}
// Step 2: Quick crossfade swap
handCard.classList.add('fade-out');
heldCard.classList.add('fade-out');
await this.delay(150);
this.setCardPosition(handCard, discardRect);
this.setCardPosition(heldCard, slotRect);
this.playSound('card');
handCard.classList.remove('fade-out');
heldCard.classList.remove('fade-out');
handCard.classList.add('fade-in');
heldCard.classList.add('fade-in');
await this.delay(150);
// Clean up
handCard.remove();
heldCard.remove();
}
// Create a temporary animation card element
createAnimCard() {
const card = document.createElement('div');
card.className = 'real-card anim-card';
card.innerHTML = `
<div class="card-inner">
<div class="card-face card-face-front"></div>
<div class="card-face card-face-back"><span>?</span></div>
</div>
`;
return card;
}
// Set card position
setCardPosition(card, rect) {
card.style.left = `${rect.left}px`;
card.style.top = `${rect.top}px`;
card.style.width = `${rect.width}px`;
card.style.height = `${rect.height}px`;
}
// Set card front content
setCardFront(frontEl, cardData) {
frontEl.className = 'card-face card-face-front';
if (!cardData) return;
if (cardData.rank === '★') {
frontEl.classList.add('joker');
const jokerIcon = cardData.suit === 'hearts' ? '🐉' : '👹';
frontEl.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = cardData.suit === 'hearts' || cardData.suit === 'diamonds';
frontEl.classList.add(isRed ? 'red' : 'black');
const suitSymbol = this.getSuitSymbol(cardData.suit);
frontEl.innerHTML = `${cardData.rank}<br>${suitSymbol}`;
}
}
getSuitSymbol(suit) {
const symbols = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' };
return symbols[suit] || '';
}
// Animate discarding a card (from hand to discard pile) - called for other players
async animateDiscard(movement) {
const { card, fromPlayerId, fromPosition } = movement;
// If no specific position, animate from opponent's area
const discardRect = this.getLocationRect('discard');
if (!discardRect) return;
let startRect;
if (fromPosition !== null && fromPosition !== undefined) {
startRect = this.getSlotRect(fromPlayerId, fromPosition);
}
// Fallback: use discard position offset upward
if (!startRect) {
startRect = {
left: discardRect.left,
top: discardRect.top - 80,
width: discardRect.width,
height: discardRect.height
};
}
// Create animation card
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
this.setCardPosition(animCard, startRect);
const inner = animCard.querySelector('.card-inner');
const front = animCard.querySelector('.card-face-front');
// Show the card that was discarded
this.setCardFront(front, card);
inner.classList.remove('flipped');
// Move to discard
this.playSound('card');
animCard.classList.add('moving');
this.setCardPosition(animCard, discardRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
// Same timing as player swap - let discard land and pulse settle
await this.delay(this.timing.pauseAfterDiscard);
// Clean up
animCard.remove();
}
// Animate drawing from deck
async animateDrawDeck(movement) {
const { playerId } = movement;
const deckRect = this.getLocationRect('deck');
const holdingRect = this.getLocationRect('holding');
if (!deckRect || !holdingRect) return;
// Create animation card at deck position (face down)
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
this.setCardPosition(animCard, deckRect);
const inner = animCard.querySelector('.card-inner');
inner.classList.add('flipped'); // Show back
// Move to holding position
this.playSound('card');
animCard.classList.add('moving');
this.setCardPosition(animCard, holdingRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
// Brief settle before state updates
await this.delay(this.timing.pauseBeforeNewCard);
// Clean up - renderGame will show the holding card state
animCard.remove();
}
// Animate drawing from discard - show card lifting and moving to holding position
async animateDrawDiscard(movement) {
const { card } = movement;
const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding');
if (!discardRect || !holdingRect) return;
// Create animation card at discard position (face UP - visible card)
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
this.setCardPosition(animCard, discardRect);
const inner = animCard.querySelector('.card-inner');
const front = animCard.querySelector('.card-face-front');
// Show the card face (discard is always visible)
if (card) {
this.setCardFront(front, card);
}
inner.classList.remove('flipped'); // Face up
// Lift effect before moving - card rises slightly
animCard.style.transform = 'translateY(-8px) scale(1.05)';
animCard.style.transition = `transform ${this.timing.cardLift}ms ease-out`;
await this.delay(this.timing.cardLift);
// Move to holding position
this.playSound('card');
animCard.classList.add('moving');
animCard.style.transform = '';
this.setCardPosition(animCard, holdingRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
// Brief settle before state updates
await this.delay(this.timing.pauseBeforeNewCard);
// Clean up - renderGame will show the holding card state
animCard.remove();
}
// Check if animations are currently playing
isAnimating() {
return this.animationInProgress;
}
// Clear the queue (for interruption)
clear() {
this.queue = [];
}
// Utility delay
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Export for use in app.js
if (typeof module !== 'undefined' && module.exports) {
module.exports = AnimationQueue;
}

8
client/anime.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

1766
client/card-animations.js Normal file

File diff suppressed because it is too large Load Diff

300
client/card-manager.js Normal file
View File

@@ -0,0 +1,300 @@
// CardManager - Manages persistent card DOM elements
// Cards are REAL elements that exist in ONE place and move between locations
class CardManager {
constructor(cardLayer) {
this.cardLayer = cardLayer;
// Map of "playerId-position" -> card element
this.handCards = new Map();
// Special cards
this.deckCard = null;
this.discardCard = null;
this.holdingCard = null;
}
// Initialize cards for a game state
initializeCards(gameState, playerId, getSlotRect, getDeckRect, getDiscardRect) {
this.clear();
// Create cards for each player's hand
for (const player of gameState.players) {
for (let i = 0; i < 6; i++) {
const card = player.cards[i];
const slotKey = `${player.id}-${i}`;
const cardEl = this.createCardElement(card);
// Position at slot (will be updated later if rect not ready)
const rect = getSlotRect(player.id, i);
if (rect && rect.width > 0) {
this.positionCard(cardEl, rect);
} else {
// Start invisible, will be positioned by updateAllPositions
cardEl.style.opacity = '0';
}
this.handCards.set(slotKey, {
element: cardEl,
cardData: card,
playerId: player.id,
position: i
});
this.cardLayer.appendChild(cardEl);
}
}
}
// Create a card DOM element with 3D flip structure
createCardElement(cardData) {
const card = document.createElement('div');
card.className = 'real-card';
card.innerHTML = `
<div class="card-inner">
<div class="card-face card-face-front"></div>
<div class="card-face card-face-back"></div>
</div>
`;
this.updateCardAppearance(card, cardData);
return card;
}
// Update card visual state (face up/down, content)
updateCardAppearance(cardEl, cardData) {
const inner = cardEl.querySelector('.card-inner');
const front = cardEl.querySelector('.card-face-front');
const back = cardEl.querySelector('.card-face-back');
// Reset front classes
front.className = 'card-face card-face-front';
// Apply deck color to card back
if (back) {
// Remove any existing deck color classes
back.className = back.className.replace(/\bdeck-\w+/g, '').trim();
back.className = 'card-face card-face-back';
const deckColor = this.getDeckColorClass(cardData);
if (deckColor) {
back.classList.add(deckColor);
}
}
if (!cardData || !cardData.face_up || !cardData.rank) {
// Face down or no data
inner.classList.add('flipped');
front.innerHTML = '';
} else {
// Face up with data
inner.classList.remove('flipped');
if (cardData.rank === '★') {
front.classList.add('joker');
const icon = cardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = cardData.suit === 'hearts' || cardData.suit === 'diamonds';
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${cardData.rank}<br>${this.getSuitSymbol(cardData.suit)}`;
}
}
}
// Get the deck color class for a card based on its deck_id
getDeckColorClass(cardData) {
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
return null;
}
// Get deck colors from game state (set by app.js)
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
return `deck-${colorName}`;
}
getSuitSymbol(suit) {
return { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
}
// Position a card at a rect
positionCard(cardEl, rect, animate = false) {
if (animate) {
cardEl.classList.add('moving');
}
cardEl.style.left = `${rect.left}px`;
cardEl.style.top = `${rect.top}px`;
cardEl.style.width = `${rect.width}px`;
cardEl.style.height = `${rect.height}px`;
// On mobile, scale font proportional to card width so rank/suit fit
if (document.body.classList.contains('mobile-portrait')) {
cardEl.style.fontSize = `${rect.width * 0.35}px`;
} else {
cardEl.style.fontSize = '';
}
if (animate) {
const moveDuration = window.TIMING?.card?.moving || 350;
setTimeout(() => cardEl.classList.remove('moving'), moveDuration);
}
}
// Get a hand card by player and position
getHandCard(playerId, position) {
return this.handCards.get(`${playerId}-${position}`);
}
// Update all card positions to match current slot positions
// Returns number of cards successfully positioned
updateAllPositions(getSlotRect) {
let positioned = 0;
for (const [key, cardInfo] of this.handCards) {
const rect = getSlotRect(cardInfo.playerId, cardInfo.position);
if (rect && rect.width > 0) {
this.positionCard(cardInfo.element, rect, false);
// Restore visibility if it was hidden
cardInfo.element.style.opacity = '1';
positioned++;
}
}
return positioned;
}
// Animate a card flip
async flipCard(playerId, position, newCardData, duration = null) {
// Use centralized timing if not specified
if (duration === null) {
duration = window.TIMING?.cardManager?.flipDuration || 400;
}
const cardInfo = this.getHandCard(playerId, position);
if (!cardInfo) return;
const inner = cardInfo.element.querySelector('.card-inner');
const front = cardInfo.element.querySelector('.card-face-front');
// Set up the front content before flip
front.className = 'card-face card-face-front';
if (newCardData.rank === '★') {
front.classList.add('joker');
const icon = newCardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = newCardData.suit === 'hearts' || newCardData.suit === 'diamonds';
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${newCardData.rank}<br>${this.getSuitSymbol(newCardData.suit)}`;
}
// Animate flip
inner.classList.remove('flipped');
await this.delay(duration);
cardInfo.cardData = newCardData;
}
// Animate a swap: hand card goes to discard, new card comes to hand
async animateSwap(playerId, position, oldCardData, newCardData, getSlotRect, getDiscardRect, duration = null) {
// Use centralized timing if not specified
if (duration === null) {
duration = window.TIMING?.cardManager?.moveDuration || 250;
}
const cardInfo = this.getHandCard(playerId, position);
if (!cardInfo) return;
const slotRect = getSlotRect(playerId, position);
const discardRect = getDiscardRect();
if (!slotRect || !discardRect) return;
if (!oldCardData || !oldCardData.rank) {
// Can't animate without card data - just update appearance
this.updateCardAppearance(cardInfo.element, newCardData);
cardInfo.cardData = newCardData;
return;
}
const cardEl = cardInfo.element;
const inner = cardEl.querySelector('.card-inner');
const front = cardEl.querySelector('.card-face-front');
// Step 1: If face down, flip to reveal the old card
if (!oldCardData.face_up) {
// Set front to show old card
front.className = 'card-face card-face-front';
if (oldCardData.rank === '★') {
front.classList.add('joker');
const icon = oldCardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = oldCardData.suit === 'hearts' || oldCardData.suit === 'diamonds';
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${oldCardData.rank}<br>${this.getSuitSymbol(oldCardData.suit)}`;
}
inner.classList.remove('flipped');
const flipDuration = window.TIMING?.cardManager?.flipDuration || 400;
await this.delay(flipDuration);
}
// Step 2: Move card to discard
cardEl.classList.add('moving');
this.positionCard(cardEl, discardRect);
await this.delay(duration + 50);
cardEl.classList.remove('moving');
// Pause to show the discarded card
const pauseDuration = window.TIMING?.cardManager?.moveDuration || 250;
await this.delay(pauseDuration);
// Step 3: Update card to show new card and move back to hand
front.className = 'card-face card-face-front';
if (newCardData.rank === '★') {
front.classList.add('joker');
const icon = newCardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = newCardData.suit === 'hearts' || newCardData.suit === 'diamonds';
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${newCardData.rank}<br>${this.getSuitSymbol(newCardData.suit)}`;
}
if (!newCardData.face_up) {
inner.classList.add('flipped');
}
cardEl.classList.add('moving');
this.positionCard(cardEl, slotRect);
await this.delay(duration + 50);
cardEl.classList.remove('moving');
cardInfo.cardData = newCardData;
}
// Set holding state for a card (drawn card highlight)
setHolding(playerId, position, isHolding) {
const cardInfo = this.getHandCard(playerId, position);
if (cardInfo) {
cardInfo.element.classList.toggle('holding', isHolding);
}
}
// Clear all cards
clear() {
for (const [key, cardInfo] of this.handCards) {
cardInfo.element.remove();
}
this.handCards.clear();
if (this.holdingCard) {
this.holdingCard.remove();
this.holdingCard = null;
}
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = CardManager;
}

67
client/golfball-logo.svg Normal file
View File

@@ -0,0 +1,67 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 100 104" width="100" height="100">
<defs>
<!-- Gradient for 3D ball effect - transparent base -->
<radialGradient id="ballGradient" cx="30%" cy="25%" r="65%" fx="25%" fy="20%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="50%" stop-color="#f5f5f5" stop-opacity="0.9"/>
<stop offset="80%" stop-color="#e0e0e0" stop-opacity="0.85"/>
<stop offset="100%" stop-color="#c8c8c8" stop-opacity="0.8"/>
</radialGradient>
<!-- Dimple shading gradient -->
<radialGradient id="dimpleGrad" cx="40%" cy="35%" r="60%">
<stop offset="0%" stop-color="#d0d0d0"/>
<stop offset="100%" stop-color="#b8b8b8"/>
</radialGradient>
<!-- Clip for dimples to stay within ball -->
<clipPath id="ballClip">
<circle cx="50" cy="44" r="45"/>
</clipPath>
</defs>
<!-- Main ball base -->
<circle cx="50" cy="44" r="46" fill="url(#ballGradient)"/>
<!-- Dimples - balanced pattern with more spacing -->
<g clip-path="url(#ballClip)" fill="url(#dimpleGrad)" opacity="0.5">
<!-- Outer ring -->
<circle cx="50" cy="2" r="3.5"/>
<circle cx="74" cy="12" r="3.5"/>
<circle cx="88" cy="38" r="3.5"/>
<circle cx="85" cy="64" r="3.5"/>
<circle cx="62" cy="84" r="3.5"/>
<circle cx="38" cy="84" r="3.5"/>
<circle cx="15" cy="64" r="3.5"/>
<circle cx="12" cy="38" r="3.5"/>
<circle cx="26" cy="12" r="3.5"/>
<!-- Middle ring - slightly offset -->
<circle cx="62" cy="16" r="3.2"/>
<circle cx="79" cy="50" r="3.2"/>
<circle cx="68" cy="72" r="3.2"/>
<circle cx="50" cy="80" r="3.2"/>
<circle cx="32" cy="72" r="3.2"/>
<circle cx="21" cy="50" r="3.2"/>
<circle cx="38" cy="16" r="3.2"/>
<!-- Inner - avoiding center -->
<circle cx="50" cy="10" r="2.8"/>
<circle cx="70" cy="32" r="2.8"/>
<circle cx="30" cy="32" r="2.8"/>
<circle cx="72" cy="58" r="2.8"/>
<circle cx="28" cy="58" r="2.8"/>
</g>
<!-- Subtle inner shadow for depth -->
<circle cx="50" cy="44" r="45" fill="none" stroke="#a0a0a0" stroke-width="1" opacity="0.3"/>
<!-- Outer edge highlight -->
<circle cx="50" cy="44" r="46" fill="none" stroke="#ffffff" stroke-width="0.5" opacity="0.5"/>
<!-- Card suits - single row, larger -->
<text x="22" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">&#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="59" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">&#9824;</text>
<text x="77" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">&#9829;</text>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -2,52 +2,94 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Golf Card Game</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<!-- Auth Bar (shown when logged in) -->
<div id="auth-bar" class="auth-bar hidden">
<span id="auth-username"></span>
<button id="auth-logout-btn" class="btn btn-small">Logout</button>
</div>
<!-- Lobby Screen -->
<div id="lobby-screen" class="screen active">
<h1>🏌️ Golf</h1>
<p class="subtitle">6-Card Golf Card Game</p>
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span> <span class="golf-title">Golf</span></h1>
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
<div class="form-group">
<label for="player-name">Your Name</label>
<input type="text" id="player-name" placeholder="Enter your name" maxlength="12">
<div class="alpha-banner">Beta Testing - Bear with us while, stuff.</div>
<!-- Auth prompt for unauthenticated users -->
<div id="auth-prompt" class="auth-prompt">
<p>Log in or sign up to play.</p>
<div class="button-group">
<button id="login-btn" class="btn btn-primary">Login</button>
<button id="signup-btn" class="btn btn-secondary">Sign Up</button>
</div>
</div>
<div class="button-group">
<button id="create-room-btn" class="btn btn-primary">Create Room</button>
</div>
<!-- Game controls (shown only when authenticated) -->
<div id="lobby-game-controls" class="hidden">
<div class="button-group">
<button id="find-game-btn" class="btn btn-primary">Find Game</button>
</div>
<div class="divider">or</div>
<div class="divider">or</div>
<div class="form-group">
<label for="room-code">Room Code</label>
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
</div>
<div class="button-group">
<button id="create-room-btn" class="btn btn-secondary">Create Private Room</button>
</div>
<div class="button-group">
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
<div class="form-group">
<label for="room-code">Join Private Room</label>
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
</div>
<div class="button-group">
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
</div>
</div>
<p id="lobby-error" class="error"></p>
<footer class="app-footer">v3.1.5 &copy; Aaron D. Lee</footer>
</div>
<!-- Matchmaking Screen -->
<div id="matchmaking-screen" class="screen">
<h2>Finding Game...</h2>
<div class="matchmaking-spinner"></div>
<p id="matchmaking-status">Searching for opponents...</p>
<p id="matchmaking-time" class="matchmaking-timer">0:00</p>
<p id="matchmaking-queue-info" class="matchmaking-info"></p>
<div class="button-group">
<button id="cancel-matchmaking-btn" class="btn btn-danger">Cancel</button>
</div>
</div>
<!-- Waiting Room Screen -->
<div id="waiting-screen" class="screen">
<div class="room-code-banner">
<span class="room-code-label">ROOM CODE</span>
<span class="room-code-value" id="display-room-code"></span>
<button class="room-code-copy" id="copy-room-code" title="Copy to clipboard">📋</button>
<div class="room-code-buttons">
<button class="room-code-copy" id="copy-room-code" title="Copy code">📋</button>
<button class="room-code-copy" id="share-room-link" title="Copy invite link">🌐</button>
</div>
</div>
<div class="waiting-layout">
<div class="waiting-left-col">
<div class="players-list">
<h3>Players</h3>
<div class="players-list-header">
<h3>Players</h3>
<div id="cpu-controls-section" class="cpu-controls hidden">
<span class="cpu-controls-label">CPU:</span>
<button id="remove-cpu-btn" class="cpu-ctrl-btn btn-danger" title="Remove CPU"></button>
<button id="add-cpu-btn" class="cpu-ctrl-btn btn-success" title="Add CPU">+</button>
</div>
</div>
<ul id="players-list"></ul>
</div>
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
@@ -57,20 +99,14 @@
<h3>Game Settings</h3>
<div class="basic-settings-row">
<div class="form-group">
<label>CPU Players</label>
<div class="cpu-controls">
<button id="remove-cpu-btn" class="btn btn-small btn-danger">(-) Delete</button>
<button id="add-cpu-btn" class="btn btn-small btn-success">(+) Add</button>
<label>Decks</label>
<div class="stepper-control">
<button type="button" id="decks-minus" class="stepper-btn"></button>
<span id="num-decks-display" class="stepper-value">1</span>
<input type="hidden" id="num-decks" value="1">
<button type="button" id="decks-plus" class="stepper-btn">+</button>
</div>
</div>
<div class="form-group">
<label for="num-decks">Decks</label>
<select id="num-decks">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
</div>
<div class="form-group">
<label for="num-rounds">Holes</label>
<select id="num-rounds">
@@ -80,13 +116,36 @@
<option value="1">1</option>
</select>
</div>
<div class="form-group">
<label for="initial-flips">Cards Revealed</label>
<select id="initial-flips">
<option value="2" selected>2 cards</option>
<option value="1">1 card</option>
<option value="0">None</option>
</select>
<div id="deck-colors-group" class="form-group">
<label for="deck-color-preset">Card Backs</label>
<div class="deck-color-selector">
<select id="deck-color-preset">
<optgroup label="Themes">
<option value="classic" selected>Classic</option>
<option value="ninja">Ninja Turtles</option>
<option value="ocean">Ocean</option>
<option value="forest">Forest</option>
<option value="sunset">Sunset</option>
<option value="berry">Berry</option>
<option value="neon">Neon</option>
<option value="royal">Royal</option>
<option value="earth">Earth</option>
</optgroup>
<optgroup label="Single Color">
<option value="all-red">All Red</option>
<option value="all-blue">All Blue</option>
<option value="all-green">All Green</option>
<option value="all-gold">All Gold</option>
<option value="all-purple">All Purple</option>
<option value="all-teal">All Teal</option>
<option value="all-pink">All Pink</option>
<option value="all-slate">All Slate</option>
</optgroup>
</select>
<div id="deck-color-preview" class="deck-color-preview">
<div class="preview-card deck-red"></div>
</div>
</div>
</div>
</div>
<p id="deck-recommendation" class="recommendation hidden">Recommended: 2+ decks for 4+ players</p>
@@ -94,20 +153,34 @@
<summary>Advanced Options</summary>
<div class="advanced-options-grid">
<!-- Left Column: Variants & Jokers -->
<!-- Left Column: Gameplay & Jokers -->
<div class="options-column">
<div class="options-category">
<h4>Variants</h4>
<h4>Gameplay</h4>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="flip-on-discard">
<span>Flip on Discard</span>
<span class="rule-desc">Flip card when discarding from deck</span>
<div class="select-option">
<label for="flip-mode">Flip on Discard</label>
<select id="flip-mode">
<option value="never">Standard (no flip)</option>
<option value="always">Speed Golf (must flip)</option>
<option value="endgame">Endgame (opt. flip late in game)</option>
</select>
<span class="rule-desc">After discarding a drawn card</span>
</div>
<label class="checkbox-label inline">
<input type="checkbox" id="flip-as-action">
<span>Flip as Action</span>
<span class="rule-desc"><span class="suit suit-black"></span>flip instead of draw</span>
</label>
<label class="checkbox-label">
<label class="checkbox-label inline">
<input type="checkbox" id="knock-penalty">
<span>Knock Penalty</span>
<span class="rule-desc">+10 if you go out but don't have lowest</span>
<span class="rule-desc"><span class="suit suit-red"></span>+10 if not lowest</span>
</label>
<label class="checkbox-label inline">
<input type="checkbox" id="knock-early">
<span>Early Knock</span>
<span class="rule-desc"><span class="suit suit-black"></span>flip all (≤2) to go out</span>
</label>
</div>
</div>
@@ -122,68 +195,84 @@
<label class="radio-label">
<input type="radio" name="joker-mode" value="standard">
<span>Standard</span>
<span class="rule-desc">2 per deck, -2 pts / 0 paired</span>
<span class="rule-desc"><span class="suit suit-black"></span>2 per deck, -2 / 0 paired</span>
</label>
<label class="radio-label">
<input type="radio" name="joker-mode" value="lucky-swing">
<span>Lucky Swing</span>
<span class="rule-desc">1-2-3 decks - 1 Joker, -5 pt</span>
<span class="rule-desc"><span class="suit suit-red"></span>1 Joker total, -5!</span>
</label>
<label class="radio-label">
<input type="radio" name="joker-mode" value="eagle-eye">
<span>Eagle-Eyed</span>
<span class="rule-desc">★ = +2 pts, -4 pts paired</span>
</label>
</div>
</div>
<div class="options-category">
<h4>Point Modifiers</h4>
<div class="checkbox-group">
<label class="checkbox-label inline">
<input type="checkbox" id="super-kings">
<span>Super Kings</span>
<span class="rule-desc">K = -2 pts</span>
</label>
<label class="checkbox-label inline">
<input type="checkbox" id="ten-penny">
<span>Ten Penny</span>
<span class="rule-desc">10 = 1 pt</span>
<span class="rule-desc"><span class="suit suit-black"></span>+2 / -4 paired</span>
</label>
</div>
</div>
</div>
<!-- Right Column: Bonuses & Gameplay -->
<!-- Right Column: Card Values & Bonuses -->
<div class="options-column">
<div class="options-category">
<h4>Card Values</h4>
<div class="checkbox-group">
<label class="checkbox-label inline">
<input type="checkbox" id="super-kings">
<span>Super Kings</span>
<span class="rule-desc"><span class="suit suit-red"></span>K = -2</span>
</label>
<label class="checkbox-label inline">
<input type="checkbox" id="ten-penny">
<span>Ten Penny</span>
<span class="rule-desc"><span class="suit suit-black"></span>10 = 1</span>
</label>
<label class="checkbox-label inline">
<input type="checkbox" id="one-eyed-jacks">
<span>One-Eyed Jacks</span>
<span class="rule-desc"><span class="suit suit-red"></span>J♥/J♠ = 0</span>
</label>
<label class="checkbox-label inline">
<input type="checkbox" id="negative-pairs-keep-value">
<span>Negative Pairs</span>
<span class="rule-desc"><span class="suit suit-black"></span>paired 2s/Jokers = -4</span>
</label>
</div>
</div>
<div class="options-category">
<h4>Bonuses & Penalties</h4>
<div class="checkbox-group">
<label class="checkbox-label">
<label class="checkbox-label inline">
<input type="checkbox" id="knock-bonus">
<span>Knock Out Bonus</span>
<span class="rule-desc">-5 for going out first</span>
<span>Knock Bonus</span>
<span class="rule-desc"><span class="suit suit-red"></span>-5 going out first</span>
</label>
<label class="checkbox-label">
<label class="checkbox-label inline">
<input type="checkbox" id="underdog-bonus">
<span>Underdog Bonus</span>
<span class="rule-desc">-3 for lowest score each hole</span>
<span>Underdog</span>
<span class="rule-desc"><span class="suit suit-black"></span>-3 lowest score</span>
</label>
<label class="checkbox-label">
<label class="checkbox-label inline">
<input type="checkbox" id="tied-shame">
<span>Tied Shame</span>
<span class="rule-desc">+5 if you tie with someone</span>
<span class="rule-desc"><span class="suit suit-red"></span>+5 if tied</span>
</label>
<label class="checkbox-label inline">
<input type="checkbox" id="blackjack">
<span>Blackjack</span>
<span class="rule-desc">21 pts = 0 pts</span>
<span class="rule-desc"><span class="suit suit-black"></span>score 21 = 0</span>
</label>
<label class="checkbox-label inline">
<input type="checkbox" id="four-of-a-kind">
<span>Four of a Kind</span>
<span class="rule-desc"><span class="suit suit-red"></span>-20 bonus</span>
</label>
<label class="checkbox-label inline">
<input type="checkbox" id="wolfpack">
<span>Wolfpack</span>
<span class="rule-desc">2 pairs of Jacks = -5 pts</span>
<span class="rule-desc"><span class="suit suit-black"></span>4 Jacks = -20</span>
</label>
<p id="wolfpack-combo-note" class="combo-note hidden">🃏 4 Jacks = -40 (stacks!)</p>
</div>
</div>
@@ -191,30 +280,43 @@
</div>
</details>
<div id="unranked-notice" class="unranked-notice hidden">Games with house rules are unranked and won't affect leaderboard stats.</div>
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
</div>
<p id="waiting-message" class="info">Waiting for host to start the game...</p>
</div>
<footer class="app-footer">v3.1.5 &copy; Aaron D. Lee</footer>
</div>
<!-- Game Screen -->
<div id="game-screen" class="screen">
<!-- Card layer for persistent card elements -->
<div id="card-layer"></div>
<div class="game-layout">
<div class="game-main">
<div class="game-header">
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
<div class="turn-info" id="turn-info">Your turn</div>
<div class="score-info">Showing: <span id="your-score">0</span></div>
<div class="header-buttons">
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
<div class="header-col header-col-left">
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
<div id="active-rules-bar" class="active-rules-bar hidden">
<span class="rules-label">Rules:</span>
<span id="active-rules-list" class="rules-list"></span>
</div>
</div>
<div class="header-col header-col-center">
<div id="status-message" class="status-message"></div>
<div id="final-turn-badge" class="final-turn-badge hidden">
<span class="final-turn-icon"></span>
<span class="final-turn-text">FINAL TURN</span>
</div>
</div>
<div class="header-col header-col-right">
<span id="game-username" class="game-username hidden"></span>
<button id="game-logout-btn" class="btn btn-small hidden">Logout</button>
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
</div>
</div>
<div id="active-rules-bar" class="active-rules-bar hidden">
<span class="rules-label">Rules:</span>
<span id="active-rules-list" class="rules-list"></span>
</div>
<div class="game-table">
@@ -223,25 +325,58 @@
<div class="player-row">
<div class="table-center">
<div class="deck-area">
<div id="deck" class="card card-back">
<span>?</span>
<!-- Held card slot (left of deck) -->
<div id="held-card-slot" class="held-card-slot hidden">
<div id="held-card-display" class="card card-front">
<span id="held-card-content"></span>
</div>
<span class="held-label">Holding</span>
</div>
<div id="discard" class="card">
<span id="discard-content"></span>
<div class="pile-wrapper">
<span class="pile-label">DRAW</span>
<div id="deck" class="card card-back"></div>
</div>
<div class="pile-wrapper">
<span class="pile-label">DISCARD</span>
<div class="discard-stack">
<div id="discard" class="card">
<span id="discard-content"></span>
</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 id="drawn-card-area" class="hidden">
<div id="drawn-card" class="card"></div>
<button id="discard-btn" class="btn btn-small">Discard</button>
</div>
</div>
<div class="player-section">
<div id="flip-prompt" class="flip-prompt hidden"></div>
<div class="player-area">
<h4 id="player-header"><span class="player-name">You</span><span id="your-score" class="player-showing">0</span></h4>
<div id="player-cards" class="card-grid"></div>
</div>
<div id="toast" class="toast hidden"></div>
</div>
<!-- Animation overlay for card movements -->
<div id="swap-animation" class="swap-animation hidden">
<!-- Card being discarded from hand -->
<div id="swap-card-from-hand" class="swap-card">
<div class="swap-card-inner">
<div class="swap-card-front"></div>
<div class="swap-card-back"></div>
</div>
</div>
<!-- Drawn card being held (animates to hand) -->
<div id="held-card" class="swap-card hidden">
<div class="swap-card-inner">
<div class="swap-card-front"></div>
<div class="swap-card-back"></div>
</div>
</div>
</div>
</div>
</div>
@@ -255,6 +390,11 @@
<!-- Right panel: Scores -->
<div id="scoreboard" class="side-panel right-panel">
<div id="game-buttons" class="game-buttons hidden">
<button id="next-round-btn" class="btn btn-next-round hidden">Next Hole</button>
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
<hr class="scores-divider">
</div>
<h4>Scores</h4>
<table id="score-table">
<thead>
@@ -267,11 +407,419 @@
</thead>
<tbody></tbody>
</table>
<div id="game-buttons" class="game-buttons hidden">
<button id="next-round-btn" class="btn btn-small btn-primary hidden">Next Hole</button>
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
</div>
</div>
</div>
<!-- Mobile bottom bar (hidden on desktop) -->
<div id="mobile-bottom-bar">
<div class="mobile-round-info">Hole <span id="mobile-current-round">1</span>/<span id="mobile-total-rounds">9</span></div>
<button class="mobile-bar-btn mobile-rules-btn" id="mobile-rules-btn" data-drawer="rules-drawer"><span id="mobile-rules-icon">RULES</span></button>
<button class="mobile-bar-btn" data-drawer="standings-panel">Scorecard</button>
<button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button>
</div>
<!-- Mobile rules drawer -->
<div id="rules-drawer" class="side-panel rules-drawer-panel">
<h4>Active Rules</h4>
<div id="mobile-rules-content"></div>
</div>
<!-- Drawer backdrop for mobile -->
<div id="drawer-backdrop" class="drawer-backdrop"></div>
</div>
<!-- Rules Screen -->
<div id="rules-screen" class="screen">
<div class="rules-container">
<div class="rules-header">
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
<h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1>
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
</div>
<!-- Table of Contents -->
<nav class="rules-toc">
<div class="toc-title">Quick Navigation</div>
<div class="toc-links">
<a href="#rules-basic" class="toc-link">
<span class="toc-icon">🎯</span>
<span class="toc-text">Basic Rules</span>
</a>
<a href="#rules-card-values" class="toc-link">
<span class="toc-icon">🃏</span>
<span class="toc-text">Card Values</span>
</a>
<a href="#rules-pairing" class="toc-link">
<span class="toc-icon">👯</span>
<span class="toc-text">Column Pairing</span>
</a>
<a href="#rules-turn" class="toc-link">
<span class="toc-icon">🔄</span>
<span class="toc-text">Turn Structure</span>
</a>
<a href="#rules-flip-mode" class="toc-link">
<span class="toc-icon">🔃</span>
<span class="toc-text">Flip Modes</span>
</a>
<a href="#rules-house-rules" class="toc-link">
<span class="toc-icon">🏠</span>
<span class="toc-text">House Rules</span>
</a>
<a href="#rules-faq" class="toc-link">
<span class="toc-icon"></span>
<span class="toc-text">FAQ</span>
</a>
</div>
</nav>
<section id="rules-basic" class="rules-section">
<h2>Basic Rules</h2>
<p><strong>6-Card Golf</strong> is a card game where players try to achieve the <strong>lowest score</strong> over multiple rounds ("holes"). Like golf, lower is better!</p>
<ul>
<li>Each player has <strong>6 cards</strong> arranged in a 2-row by 3-column grid</li>
<li>Most cards start <strong>face-down</strong> (hidden from everyone)</li>
<li>On your turn: <strong>draw one card</strong>, then either <strong>swap it</strong> with one of yours or <strong>discard it</strong></li>
<li>When any player reveals <strong>all 6 of their cards</strong>, everyone else gets <strong>one final turn</strong></li>
<li>After all rounds ("holes") are played, the player with the <strong>lowest total score wins</strong></li>
</ul>
</section>
<section id="rules-card-values" class="rules-section">
<h2>Card Values</h2>
<table class="rules-table">
<thead>
<tr><th>Card</th><th>Points</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td>Joker</td><td class="value-negative">-2</td><td>Best card! (requires Jokers to be enabled)</td></tr>
<tr><td>2</td><td class="value-negative">-2</td><td>Excellent - gives you negative points!</td></tr>
<tr><td>Ace (A)</td><td class="value-low">1</td><td>Very low and safe</td></tr>
<tr><td>King (K)</td><td class="value-zero">0</td><td>Zero points - great for making pairs!</td></tr>
<tr><td>3 through 10</td><td>Face value</td><td>3=3 pts, 4=4 pts, ..., 10=10 pts</td></tr>
<tr><td>Jack (J), Queen (Q)</td><td class="value-high">10</td><td>High cards - replace these quickly!</td></tr>
</tbody>
</table>
</section>
<section id="rules-pairing" class="rules-section">
<h2>Column Pairing (IMPORTANT!)</h2>
<p><strong>This is the most important rule to understand:</strong></p>
<p>If both cards in a <strong>vertical column</strong> have the <strong>same rank</strong> (like two 8s or two Jacks), that entire column scores <strong>0 points</strong> - regardless of what the cards are worth individually!</p>
<div class="rules-example">
<h4>Example:</h4>
<pre>
Your 6-card grid:
Col1 Col2 Col3
[8] [5] [7] ← Top row
[8] [3] [9] ← Bottom row
Column 1: 8 + 8 = PAIR! = 0 points (not 16!)
Column 2: 5 + 3 = 8 points
Column 3: 7 + 9 = 16 points
TOTAL: 0 + 8 + 16 = 24 points</pre>
</div>
<p class="rules-warning"><strong>IMPORTANT:</strong> When you pair cards, you get 0 points for that column - even if the cards have negative values! Two 2s paired = 0 points (not -4). Two Jokers paired = 0 points (not -4). <em>Exception: The "Negative Pairs Keep Value" house rule changes this - paired negative cards keep their -4 value!</em></p>
</section>
<section id="rules-turn" class="rules-section">
<h2>Turn Structure (Step by Step)</h2>
<h3>Step 1: Draw a Card</h3>
<p>You MUST draw exactly one card. Choose from:</p>
<ul>
<li><strong>The Deck</strong> (face-down pile) - You don't know what you'll get!</li>
<li><strong>The Discard Pile</strong> (face-up pile) - You can see exactly what card you're taking</li>
</ul>
<h3>Step 2: Use or Discard the Card</h3>
<div class="rules-case">
<h4>If you drew from the DECK:</h4>
<p>You have two options:</p>
<ul>
<li><strong>SWAP:</strong> Replace any one of your 6 cards with the drawn card. The old card goes to the discard pile.</li>
<li><strong>DISCARD:</strong> Put the drawn card directly on the discard pile without using it.</li>
</ul>
</div>
<div class="rules-case">
<h4>If you drew from the DISCARD PILE:</h4>
<p>You MUST swap - you cannot put the same card back on the discard pile.</p>
</div>
</section>
<section id="rules-flip-mode" class="rules-section">
<h2>Flip on Discard Rules (3 Modes)</h2>
<p>This setting affects what happens when you draw from the deck and choose to <strong>discard</strong> (not swap):</p>
<div class="rules-mode">
<h3>Standard Mode (No Flip)</h3>
<p class="mode-summary">Default setting. Discarding ends your turn immediately.</p>
<p><strong>How it works:</strong> When you draw from the deck and decide not to use it, you simply discard it and your turn is over. Nothing else happens.</p>
<p><strong>Strategic impact:</strong> Information is precious. You only learn what's in your hand by actively swapping cards, so there's more gambling on face-down cards. Rewards good memory and tracking what opponents discard.</p>
<p><strong>Best for:</strong> Traditional gameplay, longer games, players who enjoy mystery and risk.</p>
</div>
<div class="rules-mode">
<h3>Speed Golf Mode (Must Flip)</h3>
<p class="mode-summary">Every discard reveals one of your hidden cards.</p>
<p><strong>How it works:</strong> When you draw from the deck and discard, you MUST also flip over one of your face-down cards. This is mandatory - you cannot skip it.</p>
<p><strong>Strategic impact:</strong> Even "bad" draws give you information. Reduces the luck factor since everyone makes more informed decisions. Games naturally end faster with less hidden information.</p>
<p><strong>Best for:</strong> Quick games, players who prefer skill over luck.</p>
</div>
<div class="rules-mode">
<h3>Endgame Mode (Catch-Up Flip)</h3>
<p class="mode-summary">Optional flip activates when any player has only 1 hidden card left.</p>
<p><strong>How it works:</strong></p>
<ul>
<li>Early in the round: Discarding ends your turn (like Standard mode)</li>
<li><strong>When ANY player has 1 or fewer face-down cards:</strong> After discarding, you MAY choose to flip one of your hidden cards OR skip</li>
</ul>
<p><strong>Strategic impact:</strong> This is a <strong>catch-up mechanic</strong>. When someone is about to go out, trailing players can accelerate their information gathering to find pairs or swap out bad cards. The leader (who triggered this) doesn't benefit since they have no hidden cards left. Reduces the "runaway leader" problem and keeps games competitive.</p>
<p><strong>Best for:</strong> Competitive play where you want trailing players to have a fighting chance.</p>
</div>
</section>
<section id="rules-house-rules" class="rules-section">
<h2>House Rules (Optional Variants)</h2>
<div class="rules-mode">
<h3>Point Modifiers</h3>
<div class="house-rule">
<h4>Super Kings</h4>
<p>Kings are worth <strong>-2 points</strong> instead of 0.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Pairing Kings now has a real cost — two Kings in separate columns score -4 total, but paired they score 0. Makes you think twice before completing a King pair.</p>
</div>
<div class="house-rule">
<h4>Ten Penny</h4>
<p>10s are worth <strong>1 point</strong> instead of 10.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Drawing a 10 is no longer a crisis — Queens and Jacks become the only truly dangerous cards. Reduces the penalty spread between mid-range and high cards.</p>
</div>
</div>
<div class="rules-mode">
<h3>Joker Variants</h3>
<div class="house-rule">
<h4>Standard Jokers</h4>
<p>2 Jokers per deck, each worth <strong>-2 points</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Jokers are premium finds, but pairing them wastes their value (0 points instead of -4). Best placed in different columns.</p>
</div>
<div class="house-rule">
<h4>Lucky Swing</h4>
<p>Only <strong>1 Joker</strong> in the entire deck, worth <strong>-5 points</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> With only one Joker in the deck, finding it is a major swing. Raises the stakes on every draw from the deck.</p>
</div>
<div class="house-rule">
<h4>Eagle Eye</h4>
<p>Jokers are worth <strong>+2 unpaired</strong>, but <strong>-4 when paired</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Risk/reward Jokers. Finding one actually hurts you (+2) until you commit to finding the second. Rewards aggressive searching and creates tense decisions about whether to keep hunting or cut your losses.</p>
</div>
</div>
<div class="rules-mode">
<h3>Going Out Rules</h3>
<div class="house-rule">
<h4>Knock Penalty</h4>
<p><strong>+10 points</strong> if you go out but don't have the lowest score.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> You need to be confident you have the lowest score before going out. Rewards patience and reading your opponents' likely hands.</p>
</div>
<div class="house-rule">
<h4>Knock Bonus</h4>
<p><strong>-5 points</strong> for going out first (regardless of who wins).</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Rewards racing to finish. The 5-point bonus can offset a slightly worse hand, creating a tension between improving your score and ending the round quickly.</p>
</div>
<p class="combo-note"><em>Combining Knock Penalty + Knock Bonus creates high-stakes "going out" decisions: -5 if you win, +10 if you lose!</em></p>
</div>
<div class="rules-mode">
<h3>Scoring Bonuses</h3>
<div class="house-rule">
<h4>Underdog Bonus</h4>
<p>Round winner gets <strong>-3 points</strong> extra.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Gives trailing players a way to close the gap — win a round and claw back 3 extra points. Over multiple holes, a player who's behind can mount a comeback by stringing together strong rounds.</p>
</div>
<div class="house-rule">
<h4>Tied Shame</h4>
<p>If you tie another player's score, <strong>both get +5 penalty</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Punishes playing it safe. If you suspect a tie, you need to take risks to break it — a last-turn swap you'd normally skip becomes worth considering.</p>
</div>
<div class="house-rule">
<h4>Blackjack</h4>
<p>Score of exactly <strong>21 becomes 0</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Turns a bad round into a great one. If your score lands on exactly 21, you walk away with 0 instead. Worth keeping in mind before making that last swap.</p>
</div>
<div class="house-rule">
<h4>Wolfpack</h4>
<p>Having <strong>all 4 Jacks</strong> (2 pairs) gives <strong>-20 bonus</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Turns a potential disaster (40 points of Jacks) into a triumph. If you already have a pair of Jacks in one column and a third Jack appears, the -20 bonus makes it worth grabbing and hunting for the fourth.</p>
</div>
</div>
<div class="rules-mode">
<h3>Game Variants</h3>
<div class="house-rule">
<h4>Flip as Action</h4>
<p>Use your turn to flip one of your face-down cards without drawing. Ends your turn immediately.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Lets you gather information without risking a bad deck draw. Conservative players can learn their hand safely. However, you miss the chance to actively improve your hand - you're just learning what you have.</p>
</div>
<div class="house-rule">
<h4>Four of a Kind</h4>
<p>Having 4 cards of the same rank across two columns scores <strong>-20 bonus</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Rewards collecting matching cards beyond column pairs. Once you have a pair in one column, grabbing a third or fourth of that rank for another column becomes worthwhile. Stacks with Wolfpack: four Jacks = -40 total.</p>
</div>
<div class="house-rule">
<h4>Negative Pairs Keep Value</h4>
<p>When you pair 2s or Jokers in a column, they keep their combined <strong>-4 points</strong> instead of becoming 0.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Major change! Pairing your best cards is now beneficial. Two 2s paired = -4 points, not 0. This encourages hunting for duplicate negative cards and fundamentally changes how you value 2s and Jokers.</p>
</div>
<div class="house-rule">
<h4>One-Eyed Jacks</h4>
<p>The Jack of Hearts (J♥) and Jack of Spades (J♠) - the "one-eyed" Jacks - are worth <strong>0 points</strong> instead of 10.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Two of the four Jacks become safe cards, comparable to Kings. J♥ and J♠ are now good cards to keep! Only J♣ and J♦ remain dangerous. Reduces the "Jack disaster" probability by half.</p>
</div>
<div class="house-rule">
<h4>Early Knock</h4>
<p>If you have <strong>2 or fewer face-down cards</strong>, you may use your turn to flip all remaining cards at once and immediately end the round. Click the "Knock!" button during your draw phase.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> A high-risk, high-reward option! If you're confident your hidden cards are low, you can knock early to surprise opponents. But if those hidden cards are bad, you've just locked in a terrible score. Best used when you've deduced your face-down cards are safe (like after drawing and discarding duplicates).</p>
</div>
</div>
</section>
<section id="rules-faq" class="rules-section">
<h2>Frequently Asked Questions</h2>
<div class="faq-item">
<h4>Q: Can I look at my face-down cards?</h4>
<p>A: No! Once the game starts, you cannot peek at your own face-down cards. You only see them when they get flipped face-up (either by swapping or by the flip-on-discard rule).</p>
</div>
<div class="faq-item">
<h4>Q: Can I swap a face-down card without looking at it first?</h4>
<p>A: Yes! In fact, that's often the best strategy - if you have a card that seems high based on probability, swap it out before you even see it.</p>
</div>
<div class="faq-item">
<h4>Q: What happens when someone reveals all their cards?</h4>
<p>A: Once ANY player has all 6 cards face-up, every other player gets exactly ONE more turn. Then the round ends and scores are calculated.</p>
</div>
<div class="faq-item">
<h4>Q: Do I have to go out (reveal all cards) to win?</h4>
<p>A: No! You can win the round even with face-down cards. The player with the lowest score wins, regardless of how many cards are revealed.</p>
</div>
<div class="faq-item">
<h4>Q: When do pairs count?</h4>
<p>A: Pairs only count in VERTICAL columns (top card + bottom card in the same column). Horizontal or diagonal matches don't create pairs.</p>
</div>
<div class="faq-item">
<h4>Q: Can I make a pair with face-down cards?</h4>
<p>A: Face-down cards are still counted for scoring, but since you can't see them, you're gambling that they might form a pair. At the end of the round, all cards are revealed and pairs are calculated.</p>
</div>
<div class="faq-item">
<h4>Q: What if the deck runs out of cards?</h4>
<p>A: The discard pile (except the top card) is shuffled to create a new deck.</p>
</div>
<div class="faq-item">
<h4>Q: In Endgame mode, when exactly can I flip?</h4>
<p>A: The optional flip activates the moment ANY player (including you) has 1 or fewer face-down cards remaining. From that point until the round ends, whenever you discard from the deck, you'll get the option to flip or skip.</p>
</div>
<div class="faq-item">
<h4>Q: How does Endgame mode help trailing players?</h4>
<p>A: When someone is close to going out, they've likely optimized their hand already. The optional flip lets everyone else accelerate their information gathering - flipping cards to find pairs or identify which cards to swap out. The leader doesn't benefit (they have no hidden cards left), so it's purely a catch-up mechanic.</p>
</div>
<div class="faq-item">
<h4>Q: Why would I skip the flip in Endgame mode?</h4>
<p>A: If you're already winning or your remaining hidden cards are statistically likely to be good, you might prefer not to risk revealing a disaster. It's a calculated gamble!</p>
</div>
</section>
</div>
</div>
<!-- Leaderboard Screen -->
<div id="leaderboard-screen" class="screen">
<div class="leaderboard-container">
<div class="leaderboard-header">
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">&laquo; Back</button>
<h1>Leaderboard</h1>
<p class="leaderboard-subtitle">Top players ranked by performance</p>
</div>
<div class="leaderboard-tabs" id="leaderboard-tabs">
<button class="leaderboard-tab active" data-metric="wins">Wins</button>
<button class="leaderboard-tab" data-metric="win_rate">Win Rate</button>
<button class="leaderboard-tab" data-metric="avg_score">Avg Score</button>
<button class="leaderboard-tab" data-metric="knockouts">Knockouts</button>
<button class="leaderboard-tab" data-metric="streak">Best Streak</button>
<button class="leaderboard-tab" data-metric="rating">Rating</button>
</div>
<div id="leaderboard-content">
<div class="leaderboard-loading">Loading...</div>
</div>
</div>
</div>
<!-- Replay Screen -->
<div id="replay-screen" class="screen">
<header class="replay-header">
<h2 id="replay-title">Game Replay</h2>
<div id="replay-meta" class="replay-meta"></div>
</header>
<div id="replay-board" class="replay-board-container">
<!-- Board renders here -->
</div>
<div id="replay-event-description" class="event-description"></div>
<div id="replay-controls" class="replay-controls">
<button id="replay-btn-start" class="replay-btn" title="Go to start"></button>
<button id="replay-btn-prev" class="replay-btn" title="Previous"></button>
<button id="replay-btn-play" class="replay-btn replay-btn-play" title="Play/Pause"></button>
<button id="replay-btn-next" class="replay-btn" title="Next"></button>
<button id="replay-btn-end" class="replay-btn" title="Go to end"></button>
<div class="timeline">
<input type="range" min="0" max="0" value="0" id="replay-timeline" class="timeline-slider">
<span id="replay-frame-counter" class="frame-counter">0 / 0</span>
</div>
<div class="speed-control">
<label>Speed:</label>
<select id="replay-speed" class="speed-select">
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
<option value="4">4x</option>
</select>
</div>
</div>
<div class="replay-actions">
<button id="replay-btn-share" class="btn btn-small">Share Replay</button>
<button id="replay-btn-export" class="btn btn-small">Export JSON</button>
<button id="replay-btn-back" class="btn btn-small btn-secondary">Back to Menu</button>
</div>
</div>
</div>
<!-- Player Stats Modal -->
<div id="player-stats-modal" class="modal player-stats-modal hidden">
<div class="modal-content">
<button class="modal-close-btn" id="player-stats-close">&times;</button>
<div id="player-stats-content">
<div class="leaderboard-loading">Loading...</div>
</div>
</div>
</div>
@@ -288,6 +836,91 @@
</div>
</div>
<!-- Auth Modal -->
<div id="auth-modal" class="modal hidden">
<div class="modal-content modal-auth">
<button id="auth-modal-close" class="modal-close-btn">&times;</button>
<!-- Login Form -->
<div id="login-form-container">
<h3>Login</h3>
<form id="login-form">
<div class="form-group">
<input type="text" id="login-username" placeholder="Username" required>
</div>
<div class="form-group">
<input type="password" id="login-password" placeholder="Password" required>
</div>
<p id="login-error" class="error"></p>
<button type="submit" class="btn btn-primary btn-full">Login</button>
</form>
<p class="auth-switch">Don't have an account? <a href="#" id="show-signup">Sign up</a></p>
<p class="auth-switch"><a href="#" id="show-forgot">Forgot password?</a></p>
</div>
<!-- Forgot Password Form -->
<div id="forgot-form-container" class="hidden">
<h3>Reset Password</h3>
<p class="auth-hint">Enter your email and we'll send you a reset link.</p>
<form id="forgot-form">
<div class="form-group">
<input type="email" id="forgot-email" placeholder="Email" required>
</div>
<p id="forgot-error" class="error"></p>
<p id="forgot-success" class="success"></p>
<button type="submit" class="btn btn-primary btn-full">Send Reset Link</button>
</form>
<p class="auth-switch"><a href="#" id="forgot-back-login">Back to login</a></p>
</div>
<!-- Reset Password Form (from email link) -->
<div id="reset-form-container" class="hidden">
<h3>Set New Password</h3>
<form id="reset-form">
<div class="form-group">
<input type="password" id="reset-password" placeholder="New password" required minlength="8">
</div>
<div class="form-group">
<input type="password" id="reset-password-confirm" placeholder="Confirm password" required minlength="8">
</div>
<p id="reset-error" class="error"></p>
<p id="reset-success" class="success"></p>
<button type="submit" class="btn btn-primary btn-full">Reset Password</button>
</form>
</div>
<!-- Signup Form -->
<div id="signup-form-container" class="hidden">
<h3>Sign Up</h3>
<form id="signup-form">
<div class="form-group">
<input type="text" id="signup-invite-code" placeholder="Invite Code" required>
</div>
<div class="form-group">
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
</div>
<div class="form-group">
<input type="email" id="signup-email" placeholder="Email (optional)">
</div>
<div class="form-group">
<input type="password" id="signup-password" placeholder="Password" required minlength="8">
</div>
<p id="signup-error" class="error"></p>
<button type="submit" class="btn btn-primary btn-full">Create Account</button>
</form>
<p class="auth-switch">Already have an account? <a href="#" id="show-login">Login</a></p>
</div>
</div>
</div>
<script src="anime.min.js"></script>
<script src="timing-config.js"></script>
<script src="card-animations.js"></script>
<script src="card-manager.js"></script>
<script src="state-differ.js"></script>
<script src="animation-queue.js"></script>
<script src="leaderboard.js"></script>
<script src="replay.js"></script>
<script src="app.js"></script>
</body>
</html>

316
client/leaderboard.js Normal file
View File

@@ -0,0 +1,316 @@
/**
* Leaderboard component for Golf game.
* Handles leaderboard display, metric switching, and player stats modal.
*/
class LeaderboardComponent {
constructor() {
this.currentMetric = 'wins';
this.cache = new Map();
this.cacheTimeout = 60000; // 1 minute cache
this.elements = {
screen: document.getElementById('leaderboard-screen'),
backBtn: document.getElementById('leaderboard-back-btn'),
openBtn: document.getElementById('leaderboard-btn'),
tabs: document.getElementById('leaderboard-tabs'),
content: document.getElementById('leaderboard-content'),
statsModal: document.getElementById('player-stats-modal'),
statsContent: document.getElementById('player-stats-content'),
statsClose: document.getElementById('player-stats-close'),
};
this.metricLabels = {
wins: 'Total Wins',
win_rate: 'Win Rate',
avg_score: 'Avg Score',
knockouts: 'Knockouts',
streak: 'Best Streak',
rating: 'Rating',
};
this.metricFormats = {
wins: (v) => v.toLocaleString(),
win_rate: (v) => `${v.toFixed(1)}%`,
avg_score: (v) => v.toFixed(1),
knockouts: (v) => v.toLocaleString(),
streak: (v) => v.toLocaleString(),
rating: (v) => Math.round(v).toLocaleString(),
};
this.init();
}
init() {
// Open leaderboard
this.elements.openBtn?.addEventListener('click', () => this.show());
// Back button
this.elements.backBtn?.addEventListener('click', () => this.hide());
// Tab switching
this.elements.tabs?.addEventListener('click', (e) => {
if (e.target.classList.contains('leaderboard-tab')) {
this.switchMetric(e.target.dataset.metric);
}
});
// Close player stats modal
this.elements.statsClose?.addEventListener('click', () => this.closePlayerStats());
this.elements.statsModal?.addEventListener('click', (e) => {
if (e.target === this.elements.statsModal) {
this.closePlayerStats();
}
});
// Handle clicks on player names
this.elements.content?.addEventListener('click', (e) => {
const playerLink = e.target.closest('.player-link');
if (playerLink) {
const userId = playerLink.dataset.userId;
if (userId) {
this.showPlayerStats(userId);
}
}
});
}
show() {
// Hide other screens, show leaderboard
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
this.elements.screen.classList.add('active');
this.loadLeaderboard(this.currentMetric);
}
hide() {
this.elements.screen.classList.remove('active');
document.getElementById('lobby-screen').classList.add('active');
}
switchMetric(metric) {
if (metric === this.currentMetric) return;
this.currentMetric = metric;
// Update tab styling
this.elements.tabs.querySelectorAll('.leaderboard-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.metric === metric);
});
this.loadLeaderboard(metric);
}
async loadLeaderboard(metric) {
// Check cache
const cacheKey = `leaderboard_${metric}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.time < this.cacheTimeout) {
this.renderLeaderboard(cached.data, metric);
return;
}
// Show loading
this.elements.content.innerHTML = '<div class="leaderboard-loading">Loading...</div>';
try {
const response = await fetch(`/api/stats/leaderboard?metric=${metric}&limit=50`);
if (!response.ok) throw new Error('Failed to load leaderboard');
const data = await response.json();
// Cache the result
this.cache.set(cacheKey, { data, time: Date.now() });
this.renderLeaderboard(data, metric);
} catch (error) {
console.error('Error loading leaderboard:', error);
this.elements.content.innerHTML = `
<div class="leaderboard-empty">
<p>Failed to load leaderboard</p>
<button class="btn btn-small btn-secondary" onclick="leaderboard.loadLeaderboard('${metric}')">Retry</button>
</div>
`;
}
}
renderLeaderboard(data, metric) {
const entries = data.entries || [];
if (entries.length === 0) {
this.elements.content.innerHTML = `
<div class="leaderboard-empty">
<p>No players on the leaderboard yet.</p>
<p>Play 5+ games to appear here!</p>
</div>
`;
return;
}
const formatValue = this.metricFormats[metric] || (v => v);
const currentUserId = this.getCurrentUserId();
let html = `
<table class="leaderboard-table">
<thead>
<tr>
<th class="rank-col">#</th>
<th class="username-col">Player</th>
<th class="value-col">${this.metricLabels[metric]}</th>
<th class="games-col">Games</th>
</tr>
</thead>
<tbody>
`;
entries.forEach(entry => {
const isMe = entry.user_id === currentUserId;
const medal = this.getMedal(entry.rank);
html += `
<tr class="${isMe ? 'my-row' : ''}">
<td class="rank-col">${medal || entry.rank}</td>
<td class="username-col">
<span class="player-link" data-user-id="${entry.user_id}">
${this.escapeHtml(entry.username)}${isMe ? ' (you)' : ''}
</span>
</td>
<td class="value-col">${formatValue(entry.value)}</td>
<td class="games-col">${entry.games_played}</td>
</tr>
`;
});
html += '</tbody></table>';
this.elements.content.innerHTML = html;
}
getMedal(rank) {
switch (rank) {
case 1: return '<span class="medal">&#x1F947;</span>';
case 2: return '<span class="medal">&#x1F948;</span>';
case 3: return '<span class="medal">&#x1F949;</span>';
default: return null;
}
}
async showPlayerStats(userId) {
this.elements.statsModal.classList.remove('hidden');
this.elements.statsContent.innerHTML = '<div class="leaderboard-loading">Loading...</div>';
try {
const [statsRes, achievementsRes] = await Promise.all([
fetch(`/api/stats/players/${userId}`),
fetch(`/api/stats/players/${userId}/achievements`),
]);
if (!statsRes.ok) throw new Error('Failed to load player stats');
const stats = await statsRes.json();
const achievements = achievementsRes.ok ? await achievementsRes.json() : { achievements: [] };
this.renderPlayerStats(stats, achievements.achievements || []);
} catch (error) {
console.error('Error loading player stats:', error);
this.elements.statsContent.innerHTML = `
<div class="leaderboard-empty">
<p>Failed to load player stats</p>
</div>
`;
}
}
renderPlayerStats(stats, achievements) {
const currentUserId = this.getCurrentUserId();
const isMe = stats.user_id === currentUserId;
let html = `
<div class="player-stats-header">
<h3>${this.escapeHtml(stats.username)}${isMe ? ' (you)' : ''}</h3>
${stats.games_played >= 5 ? '<p class="rank-badge">Ranked Player</p>' : '<p class="rank-badge">Unranked (needs 5+ games)</p>'}
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">${stats.games_won}</div>
<div class="stat-label">Wins</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.win_rate.toFixed(1)}%</div>
<div class="stat-label">Win Rate</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.games_played}</div>
<div class="stat-label">Games</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.avg_score.toFixed(1)}</div>
<div class="stat-label">Avg Score</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.best_round_score ?? '-'}</div>
<div class="stat-label">Best Round</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.knockouts}</div>
<div class="stat-label">Knockouts</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.best_win_streak}</div>
<div class="stat-label">Best Streak</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.rounds_played}</div>
<div class="stat-label">Rounds</div>
</div>
</div>
`;
// Achievements section
if (achievements.length > 0) {
html += `
<div class="achievements-section">
<h4>Achievements (${achievements.length})</h4>
<div class="achievements-grid">
`;
achievements.forEach(a => {
html += `
<div class="achievement-badge" title="${this.escapeHtml(a.description)}">
<span class="icon">${a.icon}</span>
<span class="name">${this.escapeHtml(a.name)}</span>
</div>
`;
});
html += '</div></div>';
}
this.elements.statsContent.innerHTML = html;
}
closePlayerStats() {
this.elements.statsModal.classList.add('hidden');
}
getCurrentUserId() {
// Get user ID from auth state if available
if (window.authState && window.authState.user) {
return window.authState.user.id;
}
return null;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Public method to clear cache (e.g., after game ends)
clearCache() {
this.cache.clear();
}
}
// Initialize global leaderboard instance
const leaderboard = new LeaderboardComponent();

587
client/replay.js Normal file
View File

@@ -0,0 +1,587 @@
// Golf Card Game - Replay Viewer
class ReplayViewer {
constructor() {
this.frames = [];
this.metadata = null;
this.currentFrame = 0;
this.isPlaying = false;
this.playbackSpeed = 1.0;
this.playInterval = null;
this.gameId = null;
this.shareCode = null;
this.initElements();
this.bindEvents();
}
initElements() {
this.replayScreen = document.getElementById('replay-screen');
this.replayTitle = document.getElementById('replay-title');
this.replayMeta = document.getElementById('replay-meta');
this.replayBoard = document.getElementById('replay-board');
this.eventDescription = document.getElementById('replay-event-description');
this.controlsContainer = document.getElementById('replay-controls');
this.frameCounter = document.getElementById('replay-frame-counter');
this.timelineSlider = document.getElementById('replay-timeline');
this.speedSelect = document.getElementById('replay-speed');
// Control buttons
this.btnStart = document.getElementById('replay-btn-start');
this.btnPrev = document.getElementById('replay-btn-prev');
this.btnPlay = document.getElementById('replay-btn-play');
this.btnNext = document.getElementById('replay-btn-next');
this.btnEnd = document.getElementById('replay-btn-end');
// Action buttons
this.btnShare = document.getElementById('replay-btn-share');
this.btnExport = document.getElementById('replay-btn-export');
this.btnBack = document.getElementById('replay-btn-back');
}
bindEvents() {
if (this.btnStart) this.btnStart.onclick = () => this.goToFrame(0);
if (this.btnEnd) this.btnEnd.onclick = () => this.goToFrame(this.frames.length - 1);
if (this.btnPrev) this.btnPrev.onclick = () => this.prevFrame();
if (this.btnNext) this.btnNext.onclick = () => this.nextFrame();
if (this.btnPlay) this.btnPlay.onclick = () => this.togglePlay();
if (this.timelineSlider) {
this.timelineSlider.oninput = (e) => {
this.goToFrame(parseInt(e.target.value));
};
}
if (this.speedSelect) {
this.speedSelect.onchange = (e) => {
this.playbackSpeed = parseFloat(e.target.value);
if (this.isPlaying) {
this.stopPlayback();
this.startPlayback();
}
};
}
if (this.btnShare) {
this.btnShare.onclick = () => this.showShareDialog();
}
if (this.btnExport) {
this.btnExport.onclick = () => this.exportGame();
}
if (this.btnBack) {
this.btnBack.onclick = () => this.hide();
}
// Keyboard controls
document.addEventListener('keydown', (e) => {
if (!this.replayScreen || !this.replayScreen.classList.contains('active')) return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
this.prevFrame();
break;
case 'ArrowRight':
e.preventDefault();
this.nextFrame();
break;
case ' ':
e.preventDefault();
this.togglePlay();
break;
case 'Home':
e.preventDefault();
this.goToFrame(0);
break;
case 'End':
e.preventDefault();
this.goToFrame(this.frames.length - 1);
break;
}
});
}
async loadReplay(gameId) {
this.gameId = gameId;
this.shareCode = null;
try {
const token = localStorage.getItem('authToken');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const response = await fetch(`/api/replay/game/${gameId}`, { headers });
if (!response.ok) {
throw new Error('Failed to load replay');
}
const data = await response.json();
this.frames = data.frames;
this.metadata = data.metadata;
this.currentFrame = 0;
this.show();
this.render();
this.updateControls();
} catch (error) {
console.error('Failed to load replay:', error);
this.showError('Failed to load replay. You may not have permission to view this game.');
}
}
async loadSharedReplay(shareCode) {
this.shareCode = shareCode;
this.gameId = null;
try {
const response = await fetch(`/api/replay/shared/${shareCode}`);
if (!response.ok) {
throw new Error('Replay not found or expired');
}
const data = await response.json();
this.frames = data.frames;
this.metadata = data.metadata;
this.gameId = data.game_id;
this.currentFrame = 0;
// Update title with share info
if (data.title) {
this.replayTitle.textContent = data.title;
}
this.show();
this.render();
this.updateControls();
} catch (error) {
console.error('Failed to load shared replay:', error);
this.showError('Replay not found or has expired.');
}
}
show() {
// Hide other screens
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
this.replayScreen.classList.add('active');
// Update title
if (!this.shareCode && this.metadata) {
this.replayTitle.textContent = 'Game Replay';
}
// Update meta
if (this.metadata) {
const players = this.metadata.players.join(' vs ');
const duration = this.formatDuration(this.metadata.duration);
const rounds = `${this.metadata.total_rounds} hole${this.metadata.total_rounds > 1 ? 's' : ''}`;
this.replayMeta.innerHTML = `<span>${players}</span> | <span>${rounds}</span> | <span>${duration}</span>`;
}
}
hide() {
this.stopPlayback();
this.replayScreen.classList.remove('active');
// Return to lobby
document.getElementById('lobby-screen').classList.add('active');
}
render() {
if (!this.frames.length) return;
const frame = this.frames[this.currentFrame];
const state = frame.state;
this.renderBoard(state);
this.renderEventInfo(frame);
this.updateTimeline();
}
renderBoard(state) {
const currentPlayerId = state.current_player_id;
// Build HTML for all players
let html = '<div class="replay-players">';
state.players.forEach((player, idx) => {
const isCurrent = player.id === currentPlayerId;
html += `
<div class="replay-player ${isCurrent ? 'is-current' : ''}">
<div class="replay-player-header">
<span class="replay-player-name">${this.escapeHtml(player.name)}</span>
<span class="replay-player-score">Score: ${player.score} | Total: ${player.total_score}</span>
</div>
<div class="replay-player-cards">
${this.renderPlayerCards(player.cards)}
</div>
</div>
`;
});
html += '</div>';
// Center area (deck and discard)
html += `
<div class="replay-center">
<div class="replay-deck">
<div class="card card-back">
<span class="deck-count">${state.deck_remaining}</span>
</div>
</div>
<div class="replay-discard">
${state.discard_top ? this.renderCard(state.discard_top, true) : '<div class="card card-empty"></div>'}
</div>
${state.drawn_card ? `
<div class="replay-drawn">
<span class="drawn-label">Drawn:</span>
${this.renderCard(state.drawn_card, true)}
</div>
` : ''}
</div>
`;
// Game info
html += `
<div class="replay-info">
<span>Round ${state.current_round} / ${state.total_rounds}</span>
<span>Phase: ${this.formatPhase(state.phase)}</span>
</div>
`;
this.replayBoard.innerHTML = html;
}
renderPlayerCards(cards) {
let html = '<div class="replay-cards-grid">';
// Render as 2 rows x 3 columns
for (let row = 0; row < 2; row++) {
html += '<div class="replay-cards-row">';
for (let col = 0; col < 3; col++) {
const idx = row * 3 + col;
const card = cards[idx];
if (card) {
html += this.renderCard(card, card.face_up);
} else {
html += '<div class="card card-empty"></div>';
}
}
html += '</div>';
}
html += '</div>';
return html;
}
renderCard(card, revealed = false) {
if (!revealed || !card.face_up) {
return '<div class="card card-back"></div>';
}
const suit = card.suit;
const rank = card.rank;
const isRed = suit === 'hearts' || suit === 'diamonds';
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
return `
<div class="card ${isRed ? 'card-red' : 'card-black'}">
<span class="card-rank">${rank}</span>
<span class="card-suit">${suitSymbol}</span>
</div>
`;
}
renderEventInfo(frame) {
const descriptions = {
'game_created': 'Game created',
'player_joined': `${frame.event_data?.player_name || 'Player'} joined`,
'player_left': `Player left the game`,
'game_started': 'Game started',
'round_started': `Round ${frame.event_data?.round || ''} started`,
'initial_flip': `${this.getPlayerName(frame.player_id)} revealed initial cards`,
'card_drawn': `${this.getPlayerName(frame.player_id)} drew from ${frame.event_data?.source || 'deck'}`,
'card_swapped': `${this.getPlayerName(frame.player_id)} swapped a card`,
'card_discarded': `${this.getPlayerName(frame.player_id)} discarded`,
'card_flipped': `${this.getPlayerName(frame.player_id)} flipped a card`,
'flip_skipped': `${this.getPlayerName(frame.player_id)} skipped flip`,
'knock_early': `${this.getPlayerName(frame.player_id)} knocked early!`,
'round_ended': `Round ended`,
'game_ended': `Game over! ${this.metadata?.winner || 'Winner'} wins!`,
};
const desc = descriptions[frame.event_type] || frame.event_type;
const time = this.formatTimestamp(frame.timestamp);
this.eventDescription.innerHTML = `
<span class="event-time">${time}</span>
<span class="event-text">${desc}</span>
`;
}
getPlayerName(playerId) {
if (!playerId || !this.frames.length) return 'Player';
const currentState = this.frames[this.currentFrame]?.state;
if (!currentState) return 'Player';
const player = currentState.players.find(p => p.id === playerId);
return player?.name || 'Player';
}
updateControls() {
if (this.timelineSlider) {
this.timelineSlider.max = Math.max(0, this.frames.length - 1);
this.timelineSlider.value = this.currentFrame;
}
// Show/hide share button based on whether we own the game
if (this.btnShare) {
this.btnShare.style.display = this.gameId && localStorage.getItem('authToken') ? '' : 'none';
}
}
updateTimeline() {
if (this.timelineSlider) {
this.timelineSlider.value = this.currentFrame;
}
if (this.frameCounter) {
this.frameCounter.textContent = `${this.currentFrame + 1} / ${this.frames.length}`;
}
}
goToFrame(index) {
this.currentFrame = Math.max(0, Math.min(index, this.frames.length - 1));
this.render();
}
nextFrame() {
if (this.currentFrame < this.frames.length - 1) {
this.currentFrame++;
this.render();
} else if (this.isPlaying) {
this.togglePlay(); // Stop at end
}
}
prevFrame() {
if (this.currentFrame > 0) {
this.currentFrame--;
this.render();
}
}
togglePlay() {
this.isPlaying = !this.isPlaying;
if (this.btnPlay) {
this.btnPlay.textContent = this.isPlaying ? '⏸' : '▶';
}
if (this.isPlaying) {
this.startPlayback();
} else {
this.stopPlayback();
}
}
startPlayback() {
const baseInterval = 1000; // 1 second between frames
this.playInterval = setInterval(() => {
this.nextFrame();
}, baseInterval / this.playbackSpeed);
}
stopPlayback() {
if (this.playInterval) {
clearInterval(this.playInterval);
this.playInterval = null;
}
}
async showShareDialog() {
if (!this.gameId) return;
const modal = document.createElement('div');
modal.className = 'modal active';
modal.id = 'share-modal';
modal.innerHTML = `
<div class="modal-content">
<h3>Share This Game</h3>
<div class="form-group">
<label for="share-title">Title (optional)</label>
<input type="text" id="share-title" placeholder="Epic comeback win!">
</div>
<div class="form-group">
<label for="share-expiry">Expires in</label>
<select id="share-expiry">
<option value="">Never</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</div>
<div id="share-result" class="hidden">
<p>Share this link:</p>
<div class="share-link-container">
<input type="text" id="share-link" readonly>
<button class="btn btn-small" id="share-copy-btn">Copy</button>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" id="share-generate-btn">Generate Link</button>
<button class="btn btn-secondary" id="share-cancel-btn">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
const generateBtn = modal.querySelector('#share-generate-btn');
const cancelBtn = modal.querySelector('#share-cancel-btn');
const copyBtn = modal.querySelector('#share-copy-btn');
cancelBtn.onclick = () => modal.remove();
generateBtn.onclick = async () => {
const title = modal.querySelector('#share-title').value || null;
const expiry = modal.querySelector('#share-expiry').value || null;
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`/api/replay/game/${this.gameId}/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
title,
expires_days: expiry ? parseInt(expiry) : null,
}),
});
if (!response.ok) {
throw new Error('Failed to create share link');
}
const data = await response.json();
const fullUrl = `${window.location.origin}/replay/${data.share_code}`;
modal.querySelector('#share-link').value = fullUrl;
modal.querySelector('#share-result').classList.remove('hidden');
generateBtn.classList.add('hidden');
} catch (error) {
console.error('Failed to create share link:', error);
alert('Failed to create share link');
}
};
copyBtn.onclick = () => {
const input = modal.querySelector('#share-link');
input.select();
document.execCommand('copy');
copyBtn.textContent = 'Copied!';
setTimeout(() => copyBtn.textContent = 'Copy', 2000);
};
}
async exportGame() {
if (!this.gameId) return;
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`/api/replay/game/${this.gameId}/export`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Failed to export game');
}
const data = await response.json();
// Download as JSON file
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `golf-game-${this.gameId.substring(0, 8)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to export game:', error);
alert('Failed to export game');
}
}
showError(message) {
this.show();
this.replayBoard.innerHTML = `
<div class="replay-error">
<p>${this.escapeHtml(message)}</p>
<button class="btn btn-primary" onclick="replayViewer.hide()">Back to Lobby</button>
</div>
`;
}
formatDuration(seconds) {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
formatTimestamp(seconds) {
return this.formatDuration(seconds);
}
formatPhase(phase) {
const phases = {
'waiting': 'Waiting',
'initial_flip': 'Initial Flip',
'playing': 'Playing',
'final_turn': 'Final Turn',
'round_over': 'Round Over',
'game_over': 'Game Over',
};
return phases[phase] || phase;
}
escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
}
// Global instance
const replayViewer = new ReplayViewer();
// Check URL for replay links
document.addEventListener('DOMContentLoaded', () => {
const path = window.location.pathname;
// Handle /replay/{share_code} URLs
if (path.startsWith('/replay/')) {
const shareCode = path.substring(8);
if (shareCode) {
replayViewer.loadSharedReplay(shareCode);
}
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { ReplayViewer, replayViewer };
}

165
client/state-differ.js Normal file
View File

@@ -0,0 +1,165 @@
// StateDiffer - Detects what changed between game states
// Generates movement instructions for the animation queue
class StateDiffer {
constructor() {
this.previousState = null;
}
// Compare old and new state, return array of movements
diff(oldState, newState) {
const movements = [];
if (!oldState || !newState) {
return movements;
}
// Check for initial flip phase - still animate initial flips
if (oldState.waiting_for_initial_flip && !newState.waiting_for_initial_flip) {
// Initial flip just completed - detect which cards were flipped
for (const newPlayer of newState.players) {
const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
if (oldPlayer) {
for (let i = 0; i < 6; i++) {
if (!oldPlayer.cards[i].face_up && newPlayer.cards[i].face_up) {
movements.push({
type: 'flip',
playerId: newPlayer.id,
position: i,
faceUp: true,
card: newPlayer.cards[i]
});
}
}
}
}
return movements;
}
// Still in initial flip selection - no animations
if (newState.waiting_for_initial_flip) {
return movements;
}
// Check for turn change - the previous player just acted
const previousPlayerId = oldState.current_player_id;
const currentPlayerId = newState.current_player_id;
const turnChanged = previousPlayerId !== currentPlayerId;
// Detect if a swap happened (discard changed AND a hand position changed)
const newTop = newState.discard_top;
const oldTop = oldState.discard_top;
const discardChanged = newTop && (!oldTop ||
oldTop.rank !== newTop.rank ||
oldTop.suit !== newTop.suit);
// Find hand changes for the player who just played
if (turnChanged && previousPlayerId) {
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
if (oldPlayer && newPlayer) {
// First pass: detect swaps (card identity changed)
const swappedPositions = new Set();
for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
// Card identity changed = swap happened at this position
if (this.cardIdentityChanged(oldCard, newCard)) {
swappedPositions.add(i);
// Use discard_top for the revealed card (more reliable for opponents)
const revealedCard = newState.discard_top || { ...oldCard, face_up: true };
movements.push({
type: 'swap',
playerId: previousPlayerId,
position: i,
oldCard: revealedCard,
newCard: newCard
});
break; // Only one swap per turn
}
}
// Second pass: detect flips (card went from face_down to face_up, not a swap)
for (let i = 0; i < 6; i++) {
if (swappedPositions.has(i)) continue; // Skip if already detected as swap
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
if (this.cardWasFlipped(oldCard, newCard)) {
movements.push({
type: 'flip',
playerId: previousPlayerId,
position: i,
faceUp: true,
card: newCard
});
}
}
}
}
// Detect drawing (current player just drew)
if (newState.has_drawn_card && !oldState.has_drawn_card) {
// Discard pile decreased = drew from discard
const drewFromDiscard = !newState.discard_top ||
(oldState.discard_top &&
(!newState.discard_top ||
oldState.discard_top.rank !== newState.discard_top.rank ||
oldState.discard_top.suit !== newState.discard_top.suit));
movements.push({
type: drewFromDiscard ? 'draw-discard' : 'draw-deck',
playerId: currentPlayerId,
card: drewFromDiscard ? oldState.discard_top : null // Include card for discard draw animation
});
}
return movements;
}
// Check if the card identity (rank+suit) changed between old and new
// Returns true if definitely different cards, false if same or unknown
cardIdentityChanged(oldCard, newCard) {
// If both have rank/suit data, compare directly
if (oldCard.rank && newCard.rank) {
return oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit;
}
// Can't determine - assume same card (flip, not swap)
return false;
}
// Check if a card was just flipped (same card, now face up)
cardWasFlipped(oldCard, newCard) {
return !oldCard.face_up && newCard.face_up;
}
// Get a summary of movements for debugging
summarize(movements) {
return movements.map(m => {
switch (m.type) {
case 'flip':
return `Flip: Player ${m.playerId} position ${m.position}`;
case 'swap':
return `Swap: Player ${m.playerId} position ${m.position}`;
case 'discard':
return `Discard: ${m.card.rank}${m.card.suit} from player ${m.fromPlayerId}`;
case 'draw-deck':
return `Draw from deck: Player ${m.playerId}`;
case 'draw-discard':
return `Draw from discard: Player ${m.playerId}`;
default:
return `Unknown: ${m.type}`;
}
}).join('\n');
}
}
// Export for use in app.js
if (typeof module !== 'undefined' && module.exports) {
module.exports = StateDiffer;
}

File diff suppressed because it is too large Load Diff

175
client/timing-config.js Normal file
View File

@@ -0,0 +1,175 @@
// Centralized timing configuration for all animations and pauses
// Edit these values to tune the feel of card animations and CPU gameplay
const TIMING = {
// Card animations (milliseconds)
card: {
flip: 320, // Card flip duration — readable but snappy
move: 300, // General card movement
lift: 100, // Perceptible lift before travel
settle: 80, // Gentle landing cushion
},
// Pauses - minimal, let animations flow
pause: {
afterFlip: 0, // No pause - flow into next action
afterDiscard: 100, // Brief settle
beforeNewCard: 0, // No pause
afterSwapComplete: 100, // Brief settle
betweenAnimations: 0, // No gaps - continuous flow
beforeFlip: 0, // No pause
},
// Beat timing for animation phases (~1.2 sec with variance)
beat: {
base: 1200, // Base beat duration (longer to see results)
variance: 200, // +/- variance for natural feel
fadeOut: 300, // Fade out duration
fadeIn: 300, // Fade in duration
},
// UI feedback durations (milliseconds)
feedback: {
drawPulse: 375, // Draw pile highlight duration (25% slower for clear sequencing)
discardLand: 375, // Discard land effect duration (25% slower)
cardFlipIn: 300, // Card flip-in effect duration
statusMessage: 2000, // Toast/status message duration
copyConfirm: 2000, // Copy button confirmation duration
discardPickup: 250, // Discard pickup animation duration
},
// CSS animation timing (for reference - actual values in style.css)
css: {
cpuConsidering: 1500, // CPU considering pulse cycle
},
// Anime.js animation configuration
anime: {
easing: {
flip: 'cubicBezier(0.34, 1.2, 0.64, 1)', // Slight overshoot snap
move: 'cubicBezier(0.22, 0.68, 0.35, 1.0)', // Smooth deceleration
lift: 'cubicBezier(0.0, 0.0, 0.2, 1)', // Quick out, soft stop
settle: 'cubicBezier(0.34, 1.05, 0.64, 1)', // Tiny overshoot on landing
arc: 'cubicBezier(0.45, 0, 0.15, 1)', // Smooth S-curve for arcs
pulse: 'easeInOutSine', // Keep for loops
},
loop: {
turnPulse: { duration: 2000 },
cpuThinking: { duration: 1500 },
initialFlipGlow: { duration: 1500 },
}
},
// Card manager specific
cardManager: {
flipDuration: 320, // Card flip animation
moveDuration: 300, // Card move animation
},
// V3_02: Dealing animation
dealing: {
shufflePause: 400, // Pause after shuffle sound
cardFlyTime: 150, // Time for card to fly to destination
cardStagger: 80, // Delay between cards
roundPause: 50, // Pause between deal rounds
discardFlipDelay: 200, // Pause before flipping discard
},
// V3_03: Round end reveal timing
reveal: {
lastPlayPause: 2000, // Pause after last play animation before reveals
voluntaryWindow: 2000, // Time for players to flip their own cards
initialPause: 250, // Pause before auto-reveals start
cardStagger: 50, // Between cards in same hand
playerPause: 200, // Pause after each player's reveal
highlightDuration: 100, // Player area highlight fade-in
},
// V3_04: Pair celebration
celebration: {
pairDuration: 200, // Celebration animation length
pairDelay: 25, // Slight delay before celebration
},
// V3_07: Score tallying animation
tally: {
initialPause: 100, // After reveal, before tally
cardHighlight: 70, // Duration to show each card value
columnPause: 30, // Between columns
pairCelebration: 200, // Pair cancel effect
playerPause: 50, // Between players
finalScoreReveal: 400, // Final score animation
},
// Opponent initial flip stagger (after dealing)
// All players flip concurrently within this window (not taking turns)
initialFlips: {
windowStart: 500, // Minimum delay before any opponent starts flipping
windowEnd: 2500, // Maximum delay before opponent starts (random in range)
cardStagger: 400, // Delay between an opponent's two card flips
},
// V3_11: Physical swap animation
swap: {
lift: 100, // Time to lift cards — visible pickup
arc: 320, // Time for arc travel
settle: 100, // Time to settle into place — with overshoot easing
},
// Draw animation durations (replaces hardcoded values in card-animations.js)
draw: {
deckLift: 120, // Lift off deck before travel
deckMove: 250, // Travel to holding position
deckRevealPause: 80, // Brief pause before flip (easing does the rest)
deckFlip: 320, // Flip to reveal drawn card
deckViewPause: 120, // Time to see revealed card
discardLift: 80, // Quick grab from discard
discardMove: 200, // Travel to holding position
discardViewPause: 60, // Brief settle after arrival
pulseDelay: 200, // Delay before card appears (pulse visible first)
},
// Turn pulse (deck shake)
turnPulse: {
initialDelay: 5000, // Delay before first shake
interval: 5400, // Time between shakes
duration: 300, // Shake animation duration
},
// V3_17: Knock notification
knock: {
statusDuration: 2500, // How long the knock status message persists
},
// V3_17: Scoresheet modal
scoresheet: {
playerStagger: 150, // Delay between player row animations
columnStagger: 80, // Delay between column animations within a row
pairGlowDelay: 200, // Delay before paired columns glow
},
// Player swap animation steps - smooth continuous motion
playerSwap: {
flipToReveal: 400, // Initial flip to show card
pauseAfterReveal: 50, // Tiny beat to register the card
moveToDiscard: 400, // Move old card to discard
pulseBeforeSwap: 0, // No pulse - just flow
completePause: 50, // Tiny settle
},
};
// Helper to get beat duration with variance
function getBeatDuration() {
const base = TIMING.beat.base;
const variance = TIMING.beat.variance;
return base + (Math.random() * variance * 2 - variance);
}
// Export for module systems, also attach to window for direct use
if (typeof module !== 'undefined' && module.exports) {
module.exports = TIMING;
}
if (typeof window !== 'undefined') {
window.TIMING = TIMING;
window.getBeatDuration = getBeatDuration;
}

58
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,58 @@
# Development Docker Compose for Golf Game V2
#
# Provides PostgreSQL and Redis for local development.
#
# Usage:
# docker-compose -f docker-compose.dev.yml up -d
#
# Connect to PostgreSQL:
# docker exec -it golfgame-postgres-1 psql -U golf
#
# Connect to Redis CLI:
# docker exec -it golfgame-redis-1 redis-cli
#
# View logs:
# docker-compose -f docker-compose.dev.yml logs -f
#
# Stop services:
# docker-compose -f docker-compose.dev.yml down
#
# Stop and remove volumes (clean slate):
# docker-compose -f docker-compose.dev.yml down -v
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
# Enable AOF persistence for durability
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: golf
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: golf
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U golf"]
interval: 5s
timeout: 3s
retries: 5
volumes:
redis_data:
driver: local
postgres_data:
driver: local

166
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,166 @@
# Production Docker Compose for Golf Card Game
#
# Usage:
# # Set required environment variables first
# export DB_PASSWORD=your-secure-password
# export SECRET_KEY=your-secret-key
# export ACME_EMAIL=your-email@example.com
#
# # Start services
# docker-compose -f docker-compose.prod.yml up -d
#
# # View logs
# docker-compose -f docker-compose.prod.yml logs -f app
#
# # Scale app instances
# docker-compose -f docker-compose.prod.yml up -d --scale app=2
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:-production}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- LOG_LEVEL_GAME=${LOG_LEVEL_GAME:-}
- LOG_LEVEL_AI=${LOG_LEVEL_AI:-}
- LOG_LEVEL_HANDLERS=${LOG_LEVEL_HANDLERS:-}
- LOG_LEVEL_ROOM=${LOG_LEVEL_ROOM:-}
- LOG_LEVEL_AUTH=${LOG_LEVEL_AUTH:-}
- LOG_LEVEL_STORES=${LOG_LEVEL_STORES:-}
- BASE_URL=${BASE_URL:-https://golf.example.com}
- RATE_LIMIT_ENABLED=true
- INVITE_ONLY=true
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
- MATCHMAKING_ENABLED=true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
deploy:
replicas: 1
restart_policy:
condition: on-failure
max_attempts: 3
resources:
limits:
memory: 256M
reservations:
memory: 64M
networks:
- internal
- web
labels:
- "traefik.enable=true"
- "traefik.docker.network=golfgame_web"
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)"
- "traefik.http.routers.golf.entrypoints=websecure"
- "traefik.http.routers.golf.tls=true"
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
# www -> bare domain redirect
- "traefik.http.routers.golf-www.rule=Host(`www.${DOMAIN:-golf.example.com}`)"
- "traefik.http.routers.golf-www.entrypoints=websecure"
- "traefik.http.routers.golf-www.tls=true"
- "traefik.http.routers.golf-www.tls.certresolver=letsencrypt"
- "traefik.http.routers.golf-www.middlewares=www-redirect"
- "traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\\.(.+)"
- "traefik.http.middlewares.www-redirect.redirectregex.replacement=https://$${1}"
- "traefik.http.middlewares.www-redirect.redirectregex.permanent=true"
- "traefik.http.services.golf.loadbalancer.server.port=8000"
# WebSocket sticky sessions
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"
- "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: 192M
reservations:
memory: 64M
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 32mb --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: 64M
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: 64M
volumes:
postgres_data:
redis_data:
letsencrypt:
networks:
internal:
driver: bridge
web:
driver: bridge

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

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

616
docs/ANIMATION-FLOWS.md Normal file
View File

@@ -0,0 +1,616 @@
# Animation Flow Reference
Complete reference for how card animations are triggered, sequenced, and cleaned up.
All animations use anime.js via the `CardAnimations` class (`client/card-animations.js`).
Timing is configured in `client/timing-config.js`.
---
## Table of Contents
1. [Architecture Overview](#architecture-overview)
2. [Animation Flags](#animation-flags)
3. [Flow 1: Local Player Draws from Deck](#flow-1-local-player-draws-from-deck)
4. [Flow 2: Local Player Draws from Discard](#flow-2-local-player-draws-from-discard)
5. [Flow 3: Local Player Swaps](#flow-3-local-player-swaps)
6. [Flow 4: Local Player Discards](#flow-4-local-player-discards)
7. [Flow 5: Opponent Draws from Deck then Swaps](#flow-5-opponent-draws-from-deck-then-swaps)
8. [Flow 6: Opponent Draws from Deck then Discards](#flow-6-opponent-draws-from-deck-then-discards)
9. [Flow 7: Opponent Draws from Discard then Swaps](#flow-7-opponent-draws-from-discard-then-swaps)
10. [Flow 8: Initial Card Flip](#flow-8-initial-card-flip)
11. [Flow 9: Deal Animation](#flow-9-deal-animation)
12. [Flow 10: Round End Reveal](#flow-10-round-end-reveal)
13. [Flag Lifecycle Summary](#flag-lifecycle-summary)
14. [Safety Clears](#safety-clears)
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ app.js │
│ │
│ User Click / WebSocket ──► triggerAnimationsForStateChange │
│ │ │ │
│ ▼ ▼ │
│ Set flags ──────────────► CardAnimations method │
│ │ │ │
│ ▼ ▼ │
│ renderGame() skips anime.js timeline runs │
│ flagged elements │ │
│ │ ▼ │
│ │ Callback fires │
│ │ │ │
│ ▼ ▼ │
│ Flags cleared ◄──────── renderGame() called │
│ │ │
│ ▼ │
│ Normal rendering resumes │
└─────────────────────────────────────────────────────────────┘
```
**Key principle:** Flags block `renderGame()` from updating the DOM while animations are in flight. The animation callback clears flags and triggers a fresh render.
---
## Animation Flags
Flags in `app.js` that prevent `renderGame()` from updating the discard pile or held card during animations:
| Flag | Type | Blocks | Purpose |
|------|------|--------|---------|
| `isDrawAnimating` | bool | Discard pile, held card | Draw animation in progress |
| `localDiscardAnimating` | bool | Discard pile | Local player discarding drawn card |
| `opponentDiscardAnimating` | bool | Discard pile | Opponent discarding without swap |
| `opponentSwapAnimation` | object/null | Discard pile, turn indicator | Opponent swap `{ playerId, position }` |
| `dealAnimationInProgress` | bool | Flip prompts | Deal animation running |
| `swapAnimationInProgress` | bool | Game state application | Local swap — defers incoming state |
**renderGame() skip logic:**
```
if (localDiscardAnimating OR opponentSwapAnimation OR
opponentDiscardAnimating OR isDrawAnimating):
→ skip discard pile update
```
---
## Flow 1: Local Player Draws from Deck
**Trigger:** User clicks deck
```
User clicks deck
drawFromDeck()
├─ Validate: isMyTurn(), no drawnCard
└─ Send: { type: 'draw', source: 'deck' }
Server responds: 'card_drawn'
├─ Store drawnCard, drawnFromDiscard=false
├─ Clear stale flags (opponentSwap, opponentDiscard)
├─ SET isDrawAnimating = true
└─ hideDrawnCard()
cardAnimations.animateDrawDeck(card, callback)
├─ Pulse deck (gold ring)
├─ Wait pulseDelay (200ms)
_animateDrawDeckCard() timeline:
┌─────────────────────────────────────────┐
│ 1. Lift off deck (120ms, lift ease) │
│ translateY: -15, rotate wobble │
│ │
│ 2. Move to hold pos (250ms, move ease) │
│ left/top to holdingRect │
│ │
│ 3. Brief pause (80ms) │
│ │
│ 4. Flip to reveal (320ms, flip ease) │
│ rotateY: 180→0, play flip sound │
│ │
│ 5. View pause (120ms) │
└─────────────────────────────────────────┘
Callback:
├─ CLEAR isDrawAnimating = false
├─ displayHeldCard(card) with popIn
├─ renderGame()
└─ Show toast: "Swap with a card or discard"
```
**Total animation time:** ~200 + 120 + 250 + 80 + 320 + 120 = ~1090ms
---
## Flow 2: Local Player Draws from Discard
**Trigger:** User clicks discard pile
```
User clicks discard
drawFromDiscard()
├─ Validate: isMyTurn(), no drawnCard, discard_top exists
└─ Send: { type: 'draw', source: 'discard' }
Server responds: 'card_drawn'
├─ Store drawnCard, drawnFromDiscard=true
├─ Clear stale flags
├─ SET isDrawAnimating = true
└─ hideDrawnCard()
cardAnimations.animateDrawDiscard(card, callback)
├─ Pulse discard (gold ring)
├─ Wait pulseDelay (200ms)
_animateDrawDiscardCard() timeline:
┌─────────────────────────────────────────┐
│ Hide actual discard pile (opacity: 0) │
│ │
│ 1. Quick lift (80ms, lift ease) │
│ translateY: -12, scale: 1.05 │
│ │
│ 2. Move to hold pos (200ms, move ease) │
│ left/top to holdingRect │
│ │
│ 3. Brief settle (60ms) │
└─────────────────────────────────────────┘
Callback:
├─ Restore discard pile opacity
├─ CLEAR isDrawAnimating = false
├─ displayHeldCard(card) with popIn
├─ renderGame()
└─ Show toast: "Swap with a card or discard"
```
**Total animation time:** ~200 + 80 + 200 + 60 = ~540ms
---
## Flow 3: Local Player Swaps
**Trigger:** User clicks hand card while holding a drawn card
```
User clicks hand card (position N)
handleCardClick(position)
└─ drawnCard exists → animateSwap(position)
animateSwap(position)
├─ SET swapAnimationInProgress = true
├─ Hide originals (swap-out class, visibility:hidden)
├─ Store drawnCard, clear this.drawnCard
├─ SET skipNextDiscardFlip = true
└─ Send: { type: 'swap', position }
├──────────────────────────────────┐
│ Face-up card? │ Face-down card?
▼ ▼
Card data known Store pendingSwapData
immediately Wait for server response
│ │
│ ▼
│ Server: 'game_state'
│ ├─ Detect swapAnimationInProgress
│ ├─ Store pendingGameState
│ └─ updateSwapAnimation(discard_top)
│ │
▼──────────────────────────────────▼
cardAnimations.animateUnifiedSwap()
_doArcSwap() timeline:
┌───────────────────────────────────────────┐
│ (If face-down: flip first, 320ms) │
│ │
│ 1. Lift both cards (100ms, lift ease) │
│ translateY: -10, scale: 1.03 │
│ │
│ 2a. Hand card arcs (320ms, arc ease) │
│ → discard pile │
│ │
│ 2b. Held card arcs (320ms, arc ease) │ ← parallel
│ → hand slot │ with 2a
│ │
│ 3. Settle (100ms, settle ease)│
│ scale: 1.02→1 (gentle overshoot) │
└───────────────────────────────────────────┘
Callback → completeSwapAnimation()
├─ Clean up animation state, remove classes
├─ CLEAR swapAnimationInProgress = false
├─ Apply pendingGameState if exists
└─ renderGame()
```
**Total animation time:** ~100 + 320 + 100 = ~520ms (face-up), ~840ms (face-down)
---
## Flow 4: Local Player Discards
**Trigger:** User clicks discard button while holding a drawn card
```
User clicks discard button
discardDrawn()
├─ Store discardedCard
├─ Send: { type: 'discard' }
├─ Clear drawnCard, hide toast/button
├─ Get heldRect (position of floating card)
├─ Hide floating held card
├─ SET skipNextDiscardFlip = true
└─ SET localDiscardAnimating = true
cardAnimations.animateHeldToDiscard(card, heldRect, callback)
Timeline:
┌───────────────────────────────────────────┐
│ 1. Lift (100ms, lift ease) │
│ translateY: -8, scale: 1.02 │
│ │
│ 2. Arc to discard (320ms, arc ease) │
│ left/top with arc peak above │
│ │
│ 3. Settle (100ms, settle ease)│
│ scale: 1.02→1 │
└───────────────────────────────────────────┘
Callback:
├─ updateDiscardPileDisplay(card)
├─ pulseDiscardLand()
├─ SET skipNextDiscardFlip = true
└─ CLEAR localDiscardAnimating = false
```
**Total animation time:** ~100 + 320 + 100 = ~520ms
---
## Flow 5: Opponent Draws from Deck then Swaps
**Trigger:** State change detected via WebSocket `game_state` update
```
Server sends game_state (opponent drew + swapped)
triggerAnimationsForStateChange(old, new)
├─── STEP 1: Draw Detection ───────────────────────┐
│ drawn_card: null → something │
│ drawn_player_id != local player │
│ Discard unchanged → drew from DECK │
│ │
│ ├─ Clear stale opponent flags │
│ ├─ SET isDrawAnimating = true │
│ └─ animateDrawDeck(null, callback) │
│ │ │
│ └─ Callback: CLEAR isDrawAnimating │
│ │
├─── STEP 2: Swap Detection ───────────────────────┐
│ discard_top changed │
│ Previous player's hand has different card │
│ NOT justDetectedDraw (skip guard) │
│ │
│ └─ fireSwapAnimation(playerId, card, pos) │
│ │ │
│ ▼ │
│ SET opponentSwapAnimation = { playerId, pos } │
│ Hide source card (swap-out) │
│ │ │
│ ▼ │
│ animateUnifiedSwap() → _doArcSwap() │
│ (same timeline as Flow 3) │
│ │ │
│ ▼ │
│ Callback: │
│ ├─ Restore source card │
│ ├─ CLEAR opponentSwapAnimation = null │
│ └─ renderGame() │
└───────────────────────────────────────────────────┘
```
**Note:** STEP 1 and STEP 2 are detected in the same `triggerAnimationsForStateChange` call. The draw animation fires first; the swap animation fires after (may overlap slightly depending on timing).
---
## Flow 6: Opponent Draws from Deck then Discards
**Trigger:** State change — opponent drew from deck but didn't swap (discarded drawn card)
```
Server sends game_state (opponent drew + discarded)
triggerAnimationsForStateChange(old, new)
├─── STEP 1: Draw Detection ──────────────────┐
│ (Same as Flow 5 — draw from deck) │
│ SET isDrawAnimating = true │
│ animateDrawDeck(null, callback) │
│ │
├─── STEP 2: Discard Detection ────────────────┐
│ discard_top changed │
│ No hand position changed (no swap) │
│ │
│ └─ fireDiscardAnimation(card, playerId) │
│ │ │
│ ▼ │
│ SET opponentDiscardAnimating = true │
│ SET skipNextDiscardFlip = true │
│ │ │
│ ▼ │
│ animateOpponentDiscard(card, callback) │
│ │
│ Timeline: │
│ ┌────────────────────────────────────────┐ │
│ │ (Wait for draw overlay to clear) │ │
│ │ │ │
│ │ 1. Lift (100ms, lift ease) │ │
│ │ 2. Arc→discard (320ms, arc ease) │ │
│ │ 3. Settle (100ms, settle ease) │ │
│ └────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Callback: │
│ ├─ CLEAR opponentDiscardAnimating = false │
│ ├─ updateDiscardPileDisplay(card) │
│ └─ pulseDiscardLand() │
└───────────────────────────────────────────────┘
```
---
## Flow 7: Opponent Draws from Discard then Swaps
**Trigger:** State change — opponent took from discard pile and swapped
```
Server sends game_state (opponent drew from discard + swapped)
triggerAnimationsForStateChange(old, new)
├─── STEP 1: Draw Detection ──────────────────┐
│ drawn_card: null → something │
│ Discard top CHANGED → drew from DISCARD │
│ │
│ ├─ Clear stale opponent flags │
│ ├─ SET isDrawAnimating = true │
│ └─ animateDrawDiscard(card, callback) │
│ │
├─── STEP 2: Skip Guard ───────────────────────┐
│ justDetectedDraw AND discard changed? │
│ YES → SKIP STEP 2 │
│ │
│ The discard change was from REMOVING a │
│ card (draw), not ADDING one (discard). │
│ The swap detection comes from a LATER │
│ state update when the turn completes. │
└───────────────────────────────────────────────┘
(Next state update detects the swap via STEP 2)
└─ fireSwapAnimation() — same as Flow 5
```
**Critical:** The skip guard (`!justDetectedDraw`) prevents double-animating when an opponent draws from the discard pile. Without it, the discard change would trigger both a draw animation AND a discard animation.
---
## Flow 8: Initial Card Flip
**Trigger:** User clicks face-down card during the initial flip phase (start of round)
```
User clicks face-down card (position N)
handleCardClick(position)
├─ Check: waiting_for_initial_flip
├─ Validate: card is face-down, not already tracked
├─ Add to locallyFlippedCards set
├─ Add to selectedCards array
└─ fireLocalFlipAnimation(position, card)
fireLocalFlipAnimation()
├─ Add to animatingPositions set (prevent overlap)
└─ cardAnimations.animateInitialFlip(cardEl, card, callback)
Timeline:
┌──────────────────────────────────┐
│ Create overlay at card position │
│ Hide original (opacity: 0) │
│ │
│ 1. Flip (320ms, flip) │
│ rotateY: 180→0 │
│ Play flip sound │
└──────────────────────────────────┘
Callback:
├─ Remove overlay, restore original
└─ Remove from animatingPositions
renderGame() (called after click)
└─ Shows flipped state immediately (optimistic)
(If all required flips selected)
└─ Send: { type: 'flip_cards', positions: [...] }
Server confirms → clear locallyFlippedCards
```
---
## Flow 9: Deal Animation
**Trigger:** `game_started` or `round_started` WebSocket message
```
Server: 'game_started' / 'round_started'
Reset all state, cancel animations
SET dealAnimationInProgress = true
renderGame() — layout card slots
Hide player/opponent cards (visibility: hidden)
cardAnimations.animateDealing(gameState, getPlayerRect, callback)
┌─────────────────────────────────────────────────┐
│ Shuffle pause (400ms) │
│ │
│ For each deal round (6 total): │
│ For each player (dealer's left first): │
│ ┌─────────────────────────────────────┐ │
│ │ Create overlay at deck position │ │
│ │ Fly to player card slot (150ms) │ │
│ │ Play card sound │ │
│ │ Stagger delay (80ms) │ │
│ └─────────────────────────────────────┘ │
│ Round pause (50ms) │
│ │
│ Wait for last cards to land │
│ Flip discard card (200ms delay + flip sound) │
│ Clean up all overlays │
└─────────────────────────────────────────────────┘
Callback:
├─ CLEAR dealAnimationInProgress = false
├─ Show real cards (visibility: visible)
├─ renderGame()
└─ animateOpponentInitialFlips()
┌─────────────────────────────────────────────────┐
│ For each opponent: │
│ Random delay (500-2500ms window) │
│ For each face-up card: │
│ Temporarily show as face-down │
│ animateOpponentFlip() (320ms) │
│ Stagger (400ms between cards) │
└─────────────────────────────────────────────────┘
```
**Total deal time:** ~400 + (6 rounds x players x 230ms) + 350ms flip
---
## Flow 10: Round End Reveal
**Trigger:** `round_over` WebSocket message after round ends
```
Server: 'game_state' (phase → 'round_over')
├─ Detect roundJustEnded
├─ Save pre/post reveal states
└─ Update gameState but DON'T render
Server: 'round_over' (scores, rankings)
runRoundEndReveal(scores, rankings)
├─ SET revealAnimationInProgress = true
├─ renderGame() — show current layout
├─ Compute cardsToReveal (face-down → face-up)
└─ Get reveal order (knocker first, then clockwise)
┌──────────────────────────────────────────┐
│ For each player (in reveal order): │
│ Highlight player area │
│ Pause (200ms) │
│ │
│ For each face-down card: │
│ animateRevealFlip(id, pos, card) │
│ ├─ Local: animateInitialFlip (320ms) │
│ └─ Opponent: animateOpponentFlip │
│ Stagger (100ms) │
│ │
│ Wait for last flip + pause │
│ Remove highlight │
└──────────────────────────────────────────┘
CLEAR revealAnimationInProgress = false
renderGame()
Run score tally animation
Show scoreboard
```
---
## Flag Lifecycle Summary
Every flag follows the same pattern: **SET before animation, CLEAR in callback**.
```
SET flag ──► Animation runs ──► Callback fires ──► CLEAR flag
renderGame()
```
### Where each flag is cleared
| Flag | Normal Clear | Safety Clears |
|------|-------------|---------------|
| `isDrawAnimating` | Draw animation callback | — |
| `localDiscardAnimating` | Discard animation callback | Fallback path |
| `opponentDiscardAnimating` | Opponent discard callback | `your_turn`, `card_drawn`, before opponent draw |
| `opponentSwapAnimation` | Swap animation callback | `your_turn`, `card_drawn`, before opponent draw, new round |
| `dealAnimationInProgress` | Deal complete callback | — |
| `swapAnimationInProgress` | `completeSwapAnimation()` | — |
---
## Safety Clears
Stale flags can freeze the UI. Multiple locations clear opponent flags as a safety net:
| Location | Clears | When |
|----------|--------|------|
| `your_turn` message handler | `opponentSwapAnimation`, `opponentDiscardAnimating` | Player's turn starts |
| `card_drawn` handler (deck) | `opponentSwapAnimation`, `opponentDiscardAnimating` | Local player draws |
| `card_drawn` handler (discard) | `opponentSwapAnimation`, `opponentDiscardAnimating` | Local player draws |
| Before opponent draw animation | `opponentSwapAnimation`, `opponentDiscardAnimating` | New opponent animation starts |
| `game_started`/`round_started` | All flags | New round resets everything |
**Rule:** If you add a new animation flag, add safety clears in the `your_turn` handler and at round start.

View File

@@ -0,0 +1,327 @@
# Golf Card Game - V2 Master Plan
## Overview
Transform the current single-server Golf game into a production-ready, hostable platform with:
- **Event-sourced architecture** for full game replay and audit trails
- **User accounts** with authentication, password reset, and profile management
- **Admin tools** for moderation and system management
- **Leaderboards** with player statistics
- **Scalable hosting** options (self-hosted or cloud)
- **Export/playback** for sharing memorable games
---
## Document Structure (VDD)
This plan is split into independent vertical slices. Each document is self-contained and can be worked on by a separate agent.
| Document | Scope | Dependencies |
|----------|-------|--------------|
| `V2_01_EVENT_SOURCING.md` | Event classes, store, state rebuilding | None (foundation) |
| `V2_02_PERSISTENCE.md` | Redis cache, PostgreSQL, game recovery | 01 |
| `V2_03_USER_ACCOUNTS.md` | Registration, login, password reset, email | 02 |
| `V2_04_ADMIN_TOOLS.md` | Admin dashboard, moderation, system stats | 03 |
| `V2_05_STATS_LEADERBOARDS.md` | Stats aggregation, leaderboard API/UI | 03 |
| `V2_06_REPLAY_EXPORT.md` | Game replay, export, share links | 01, 02 |
| `V2_07_PRODUCTION.md` | Docker, deployment, monitoring, security | All |
---
## Current State (V1)
```
Client (Vanilla JS) <──WebSocket──> FastAPI Server <──> SQLite
In-memory rooms
(lost on restart)
```
**What works well:**
- Game logic is solid and well-tested
- CPU AI with 8 distinct personalities
- Flexible house rules system (15+ options)
- Real-time multiplayer via WebSockets
- Basic auth system with invite codes
**Limitations:**
- Single server, no horizontal scaling
- Game state lost on server restart
- Move logging exists but duplicates state
- No persistent player stats or leaderboards
- Limited admin capabilities
- No password reset flow
- No email integration
---
## V2 Target Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Clients │
│ (Browser / Future: Mobile) │
└───────────────────────────────┬─────────────────────────────────────┘
│ WebSocket + REST API
┌─────────────────────────────────────────────────────────────────────┐
│ FastAPI Application │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Command │ │ Event │ │ State │ │ Query │ │ Auth │ │
│ │ Handler │─► Store │─► Builder │ │ Service │ │ Service │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Admin │ │ Stats │ │ Email │ │
│ │ Service │ │ Worker │ │ Service │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└───────┬───────────────┬───────────────┬───────────────┬────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Redis │ │ PostgreSQL │ │ PostgreSQL │ │ Email │
│ (Live State) │ │ (Events) │ │ (Users/Stats)│ │ Provider │
│ (Pub/Sub) │ │ │ │ │ │ (Resend) │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
```
---
## Tech Stack
| Layer | Technology | Reasoning |
|-------|------------|-----------|
| **Web framework** | FastAPI (keep) | Already using, async, fast |
| **WebSockets** | Starlette (keep) | Built into FastAPI |
| **Live state cache** | Redis | Fast, pub/sub, TTL, battle-tested |
| **Event store** | PostgreSQL | JSONB, robust, great tooling |
| **User database** | PostgreSQL | Same instance, keep it simple |
| **Background jobs** | `arq` | Async, Redis-backed, lightweight |
| **Email** | Resend | Simple API, good free tier, reliable |
| **Containerization** | Docker | Consistent deployment |
| **Orchestration** | Docker Compose | Start simple, K8s if needed |
### New Dependencies
```txt
# requirements.txt additions
redis>=5.0.0
asyncpg>=0.29.0 # Async PostgreSQL
sqlalchemy>=2.0.0 # ORM for complex queries
alembic>=1.13.0 # Database migrations
arq>=0.26.0 # Background task queue
pydantic-settings>=2.0 # Config management
resend>=0.8.0 # Email service
python-jose[cryptography] # JWT tokens
passlib[bcrypt] # Password hashing
```
---
## Phases & Milestones
### Phase 1: Event Infrastructure (Foundation)
**Goal:** Emit events alongside current code, validate replay works
| Milestone | Description | Document |
|-----------|-------------|----------|
| Event classes defined | All gameplay events as dataclasses | 01 |
| Event store working | PostgreSQL persistence | 01 |
| Dual-write enabled | Events emitted without breaking current code | 01 |
| Replay validation | Test proves events recreate identical state | 01 |
| Rate limiting on auth | Brute force protection | 07 |
### Phase 2: Persistence & Recovery
**Goal:** Games survive server restarts
| Milestone | Description | Document |
|-----------|-------------|----------|
| Redis state cache | Live game state in Redis | 02 |
| Pub/sub ready | Multi-server WebSocket fan-out | 02 |
| Game recovery | Rebuild games from events on startup | 02 |
| Graceful shutdown | Save state before stopping | 02 |
### Phase 3a: User Accounts
**Goal:** Full user lifecycle management
| Milestone | Description | Document |
|-----------|-------------|----------|
| Email service integrated | Resend configured and tested | 03 |
| Registration with verification | Email confirmation flow | 03 |
| Password reset flow | Forgot password via email token | 03 |
| Session management | View/revoke sessions | 03 |
| Account settings | Profile, preferences, deletion | 03 |
### Phase 3b: Admin Tools
**Goal:** Moderation and system management
| Milestone | Description | Document |
|-----------|-------------|----------|
| Admin dashboard | User list, search, metrics | 04 |
| User management | Ban, unban, force password reset | 04 |
| Game moderation | View any game, end stuck games | 04 |
| System monitoring | Active games, users online, events/hour | 04 |
| Audit logging | Track admin actions | 04 |
### Phase 4: Stats & Leaderboards
**Goal:** Persistent player statistics
| Milestone | Description | Document |
|-----------|-------------|----------|
| Stats schema | PostgreSQL tables for aggregated stats | 05 |
| Stats worker | Background job processing events | 05 |
| Leaderboard API | REST endpoints | 05 |
| Leaderboard UI | Client display | 05 |
| Achievement system | Badges and milestones (stretch) | 05 |
### Phase 5: Replay & Export
**Goal:** Share and replay games
| Milestone | Description | Document |
|-----------|-------------|----------|
| Export API | Download game as JSON | 06 |
| Import/load | Upload and replay | 06 |
| Replay UI | Playback controls, scrubbing | 06 |
| Share links | Public `/replay/{id}` URLs | 06 |
### Phase 6: Production
**Goal:** Deployable, monitored, secure
| Milestone | Description | Document |
|-----------|-------------|----------|
| Dockerized | All services containerized | 07 |
| Health checks | `/health` endpoint with dependency checks | 07 |
| Metrics | Prometheus metrics | 07 |
| Error tracking | Sentry integration | 07 |
| Deployment guide | Step-by-step for VPS/cloud | 07 |
---
## File Structure (Target)
```
golfgame/
├── client/ # Frontend (enhance incrementally)
│ ├── index.html
│ ├── app.js
│ ├── components/ # New: modular UI components
│ │ ├── leaderboard.js
│ │ ├── replay-controls.js
│ │ └── admin-dashboard.js
│ └── ...
├── server/
│ ├── main.py # FastAPI app entry point
│ ├── config.py # Settings from env vars
│ ├── dependencies.py # FastAPI dependency injection
│ ├── models/
│ │ ├── events.py # Event dataclasses
│ │ ├── user.py # User model
│ │ └── game_state.py # State rebuilt from events
│ ├── stores/
│ │ ├── event_store.py # PostgreSQL event persistence
│ │ ├── state_cache.py # Redis live state
│ │ └── user_store.py # User persistence
│ ├── services/
│ │ ├── game_service.py # Command handling, event emission
│ │ ├── auth_service.py # Authentication, sessions
│ │ ├── email_service.py # Email sending
│ │ ├── admin_service.py # Admin operations
│ │ ├── stats_service.py # Leaderboard queries
│ │ └── replay_service.py # Export, import, playback
│ ├── routers/
│ │ ├── auth.py # Auth endpoints
│ │ ├── admin.py # Admin endpoints
│ │ ├── games.py # Game/replay endpoints
│ │ └── stats.py # Leaderboard endpoints
│ ├── workers/
│ │ └── stats_worker.py # Background stats aggregation
│ ├── middleware/
│ │ ├── rate_limit.py # Rate limiting
│ │ └── auth.py # Auth middleware
│ ├── ai/ # Keep existing AI code
│ │ └── ...
│ └── tests/
│ ├── test_events.py
│ ├── test_replay.py
│ ├── test_auth.py
│ └── ...
├── migrations/ # Alembic migrations
│ ├── versions/
│ └── env.py
├── docker/
│ ├── Dockerfile
│ ├── docker-compose.yml
│ └── docker-compose.prod.yml
├── docs/
│ └── v2/ # These planning documents
│ ├── V2_00_MASTER_PLAN.md
│ ├── V2_01_EVENT_SOURCING.md
│ └── ...
└── scripts/
├── migrate.py # Run migrations
├── create_admin.py # Bootstrap admin user
└── export_game.py # CLI game export
```
---
## Decision Log
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Event store DB | PostgreSQL | JSONB support, same DB as users, simpler ops |
| Email provider | Resend | Simple API, good free tier (3k/mo), reliable |
| Background jobs | arq | Async-native, Redis-backed, lightweight |
| Session storage | Redis | Fast, TTL support, already using for state |
| Password hashing | bcrypt | Industry standard, built-in work factor |
| JWT vs sessions | Both | JWT for API, sessions for WebSocket |
---
## Open Questions
1. **Guest play vs required accounts?**
- Decision: Allow guest play, prompt to register to save stats
- Guest games count for global stats but not personal leaderboards
2. **Game history retention?**
- Decision: Keep events forever (they're small, ~500 bytes each)
- Implement archival to cold storage after 1 year if needed
3. **Replay visibility?**
- Decision: Private by default, shareable via link
- Future: Public games opt-in
4. **CPU games count for leaderboards?**
- Decision: Yes, but separate "vs humans only" leaderboard later
5. **Multi-region?**
- Decision: Not for V2, single region is fine for card game latency
- Revisit if user base grows significantly
---
## How to Use These Documents
Each `V2_XX_*.md` document is designed to be:
1. **Self-contained** - Has all context needed to implement that slice
2. **Agent-ready** - Can be given to a Claude agent as the primary context
3. **Testable** - Includes acceptance criteria and test requirements
4. **Incremental** - Can be implemented and shipped independently (respecting dependencies)
**Workflow:**
1. Pick a document based on current phase
2. Start a new Claude session with that document as context
3. Implement the slice
4. Run tests specified in the document
5. PR and merge
6. Move to next slice
---
## Next Steps
1. Review all V2 documents
2. Set up PostgreSQL locally for development
3. Start with `V2_01_EVENT_SOURCING.md`
4. Implement rate limiting from `V2_07_PRODUCTION.md` early (security)

View File

@@ -0,0 +1,867 @@
# V2-01: Event Sourcing Infrastructure
## Overview
This document covers the foundational event sourcing system. All game actions will be stored as immutable events, enabling replay, audit trails, and stats aggregation.
**Dependencies:** None (this is the foundation)
**Dependents:** All other V2 documents
---
## Goals
1. Define event classes for all game actions
2. Create PostgreSQL event store
3. Implement dual-write (events + current mutations)
4. Build state rebuilder from events
5. Validate that event replay produces identical state
---
## Current State
The game currently uses direct mutation:
```python
# Current approach in game.py
def draw_card(self, player_id: str, source: str) -> Optional[Card]:
card = self.deck.pop() if source == "deck" else self.discard.pop()
self.drawn_card = card
self.phase = GamePhase.PLAY
return card
```
Move logging exists in `game_logger.py` but stores denormalized state snapshots, not replayable events.
---
## Event Design
### Base Event Class
```python
# server/models/events.py
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, Any
from enum import Enum
import uuid
class EventType(str, Enum):
# Lifecycle
GAME_CREATED = "game_created"
PLAYER_JOINED = "player_joined"
PLAYER_LEFT = "player_left"
GAME_STARTED = "game_started"
ROUND_STARTED = "round_started"
ROUND_ENDED = "round_ended"
GAME_ENDED = "game_ended"
# Gameplay
INITIAL_FLIP = "initial_flip"
CARD_DRAWN = "card_drawn"
CARD_SWAPPED = "card_swapped"
CARD_DISCARDED = "card_discarded"
CARD_FLIPPED = "card_flipped"
FLIP_SKIPPED = "flip_skipped"
FLIP_AS_ACTION = "flip_as_action"
KNOCK_EARLY = "knock_early"
@dataclass
class GameEvent:
"""Base class for all game events."""
event_type: EventType
game_id: str
sequence_num: int
timestamp: datetime = field(default_factory=datetime.utcnow)
player_id: Optional[str] = None
data: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"event_type": self.event_type.value,
"game_id": self.game_id,
"sequence_num": self.sequence_num,
"timestamp": self.timestamp.isoformat(),
"player_id": self.player_id,
"data": self.data,
}
@classmethod
def from_dict(cls, d: dict) -> "GameEvent":
return cls(
event_type=EventType(d["event_type"]),
game_id=d["game_id"],
sequence_num=d["sequence_num"],
timestamp=datetime.fromisoformat(d["timestamp"]),
player_id=d.get("player_id"),
data=d.get("data", {}),
)
```
### Lifecycle Events
```python
# Lifecycle event data structures
@dataclass
class GameCreatedData:
room_code: str
host_id: str
options: dict # GameOptions as dict
@dataclass
class PlayerJoinedData:
player_name: str
is_cpu: bool
cpu_profile: Optional[str] = None
@dataclass
class GameStartedData:
deck_seed: int # For deterministic replay
player_order: list[str] # Player IDs in turn order
num_decks: int
num_rounds: int
dealt_cards: dict[str, list[dict]] # player_id -> cards dealt
@dataclass
class RoundStartedData:
round_num: int
deck_seed: int
dealt_cards: dict[str, list[dict]]
@dataclass
class RoundEndedData:
scores: dict[str, int] # player_id -> score
winner_id: Optional[str]
final_hands: dict[str, list[dict]] # For verification
@dataclass
class GameEndedData:
final_scores: dict[str, int] # player_id -> total score
winner_id: str
rounds_won: dict[str, int]
```
### Gameplay Events
```python
# Gameplay event data structures
@dataclass
class InitialFlipData:
positions: list[int]
cards: list[dict] # The cards revealed
@dataclass
class CardDrawnData:
source: str # "deck" or "discard"
card: dict # Card drawn
@dataclass
class CardSwappedData:
position: int
new_card: dict # Card placed (was drawn)
old_card: dict # Card removed (goes to discard)
@dataclass
class CardDiscardedData:
card: dict # Card discarded
@dataclass
class CardFlippedData:
position: int
card: dict # Card revealed
@dataclass
class FlipAsActionData:
position: int
card: dict # Card revealed
@dataclass
class KnockEarlyData:
positions: list[int] # Positions flipped
cards: list[dict] # Cards revealed
```
---
## Event Store Schema
```sql
-- migrations/versions/001_create_events.sql
-- Events table (append-only log)
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
game_id UUID NOT NULL,
sequence_num INT NOT NULL,
event_type VARCHAR(50) NOT NULL,
player_id VARCHAR(50),
event_data JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Ensure events are ordered and unique per game
UNIQUE(game_id, sequence_num)
);
-- Games metadata (for queries, not source of truth)
CREATE TABLE games_v2 (
id UUID PRIMARY KEY,
room_code VARCHAR(10) NOT NULL,
status VARCHAR(20) DEFAULT 'active', -- active, completed, abandoned
created_at TIMESTAMPTZ DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
num_players INT,
num_rounds INT,
options JSONB,
winner_id VARCHAR(50),
host_id VARCHAR(50),
-- Denormalized for efficient queries
player_ids VARCHAR(50)[] DEFAULT '{}'
);
-- Indexes
CREATE INDEX idx_events_game_seq ON events(game_id, sequence_num);
CREATE INDEX idx_events_type ON events(event_type);
CREATE INDEX idx_events_player ON events(player_id) WHERE player_id IS NOT NULL;
CREATE INDEX idx_events_created ON events(created_at);
CREATE INDEX idx_games_status ON games_v2(status);
CREATE INDEX idx_games_room ON games_v2(room_code) WHERE status = 'active';
CREATE INDEX idx_games_players ON games_v2 USING GIN(player_ids);
CREATE INDEX idx_games_completed ON games_v2(completed_at) WHERE status = 'completed';
```
---
## Event Store Implementation
```python
# server/stores/event_store.py
from typing import Optional, AsyncIterator
from datetime import datetime
import asyncpg
import json
from models.events import GameEvent, EventType
class EventStore:
"""PostgreSQL-backed event store."""
def __init__(self, pool: asyncpg.Pool):
self.pool = pool
async def append(self, event: GameEvent) -> int:
"""
Append an event to the store.
Returns the event ID.
Raises if sequence_num already exists (optimistic concurrency).
"""
async with self.pool.acquire() as conn:
try:
row = await conn.fetchrow("""
INSERT INTO events (game_id, sequence_num, event_type, player_id, event_data)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
""",
event.game_id,
event.sequence_num,
event.event_type.value,
event.player_id,
json.dumps(event.data),
)
return row["id"]
except asyncpg.UniqueViolationError:
raise ConcurrencyError(
f"Event {event.sequence_num} already exists for game {event.game_id}"
)
async def append_batch(self, events: list[GameEvent]) -> list[int]:
"""Append multiple events atomically."""
async with self.pool.acquire() as conn:
async with conn.transaction():
ids = []
for event in events:
row = await conn.fetchrow("""
INSERT INTO events (game_id, sequence_num, event_type, player_id, event_data)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
""",
event.game_id,
event.sequence_num,
event.event_type.value,
event.player_id,
json.dumps(event.data),
)
ids.append(row["id"])
return ids
async def get_events(
self,
game_id: str,
from_sequence: int = 0,
to_sequence: Optional[int] = None,
) -> list[GameEvent]:
"""Get events for a game, optionally within a sequence range."""
async with self.pool.acquire() as conn:
if to_sequence is not None:
rows = await conn.fetch("""
SELECT event_type, game_id, sequence_num, player_id, event_data, created_at
FROM events
WHERE game_id = $1 AND sequence_num >= $2 AND sequence_num <= $3
ORDER BY sequence_num
""", game_id, from_sequence, to_sequence)
else:
rows = await conn.fetch("""
SELECT event_type, game_id, sequence_num, player_id, event_data, created_at
FROM events
WHERE game_id = $1 AND sequence_num >= $2
ORDER BY sequence_num
""", game_id, from_sequence)
return [
GameEvent(
event_type=EventType(row["event_type"]),
game_id=row["game_id"],
sequence_num=row["sequence_num"],
player_id=row["player_id"],
data=json.loads(row["event_data"]),
timestamp=row["created_at"],
)
for row in rows
]
async def get_latest_sequence(self, game_id: str) -> int:
"""Get the latest sequence number for a game."""
async with self.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT COALESCE(MAX(sequence_num), -1) as seq
FROM events
WHERE game_id = $1
""", game_id)
return row["seq"]
async def stream_events(
self,
game_id: str,
from_sequence: int = 0,
) -> AsyncIterator[GameEvent]:
"""Stream events for memory-efficient processing."""
async with self.pool.acquire() as conn:
async with conn.transaction():
async for row in conn.cursor("""
SELECT event_type, game_id, sequence_num, player_id, event_data, created_at
FROM events
WHERE game_id = $1 AND sequence_num >= $2
ORDER BY sequence_num
""", game_id, from_sequence):
yield GameEvent(
event_type=EventType(row["event_type"]),
game_id=row["game_id"],
sequence_num=row["sequence_num"],
player_id=row["player_id"],
data=json.loads(row["event_data"]),
timestamp=row["created_at"],
)
class ConcurrencyError(Exception):
"""Raised when optimistic concurrency check fails."""
pass
```
---
## State Rebuilder
```python
# server/models/game_state.py
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum
from models.events import GameEvent, EventType
class GamePhase(str, Enum):
WAITING = "waiting"
INITIAL_FLIP = "initial_flip"
PLAYING = "playing"
FINAL_TURN = "final_turn"
ROUND_OVER = "round_over"
GAME_OVER = "game_over"
@dataclass
class Card:
rank: str
suit: str
face_up: bool = False
def to_dict(self) -> dict:
return {"rank": self.rank, "suit": self.suit, "face_up": self.face_up}
@classmethod
def from_dict(cls, d: dict) -> "Card":
return cls(rank=d["rank"], suit=d["suit"], face_up=d.get("face_up", False))
@dataclass
class PlayerState:
id: str
name: str
cards: list[Card] = field(default_factory=list)
score: Optional[int] = None
total_score: int = 0
rounds_won: int = 0
is_cpu: bool = False
cpu_profile: Optional[str] = None
@dataclass
class RebuiltGameState:
"""Game state rebuilt from events."""
game_id: str
room_code: str = ""
phase: GamePhase = GamePhase.WAITING
players: dict[str, PlayerState] = field(default_factory=dict)
player_order: list[str] = field(default_factory=list)
current_player_idx: int = 0
deck: list[Card] = field(default_factory=list)
discard: list[Card] = field(default_factory=list)
drawn_card: Optional[Card] = None
current_round: int = 0
total_rounds: int = 9
options: dict = field(default_factory=dict)
sequence_num: int = 0
finisher_id: Optional[str] = None
def apply(self, event: GameEvent) -> "RebuiltGameState":
"""
Apply an event to produce new state.
Returns self for chaining.
"""
assert event.sequence_num == self.sequence_num + 1 or self.sequence_num == 0, \
f"Expected sequence {self.sequence_num + 1}, got {event.sequence_num}"
handler = getattr(self, f"_apply_{event.event_type.value}", None)
if handler:
handler(event)
else:
raise ValueError(f"Unknown event type: {event.event_type}")
self.sequence_num = event.sequence_num
return self
def _apply_game_created(self, event: GameEvent):
self.room_code = event.data["room_code"]
self.options = event.data.get("options", {})
self.players[event.data["host_id"]] = PlayerState(
id=event.data["host_id"],
name="Host", # Will be updated by player_joined
)
def _apply_player_joined(self, event: GameEvent):
self.players[event.player_id] = PlayerState(
id=event.player_id,
name=event.data["player_name"],
is_cpu=event.data.get("is_cpu", False),
cpu_profile=event.data.get("cpu_profile"),
)
def _apply_player_left(self, event: GameEvent):
if event.player_id in self.players:
del self.players[event.player_id]
if event.player_id in self.player_order:
self.player_order.remove(event.player_id)
def _apply_game_started(self, event: GameEvent):
self.player_order = event.data["player_order"]
self.total_rounds = event.data["num_rounds"]
self.current_round = 1
self.phase = GamePhase.INITIAL_FLIP
# Deal cards
for player_id, cards_data in event.data["dealt_cards"].items():
if player_id in self.players:
self.players[player_id].cards = [
Card.from_dict(c) for c in cards_data
]
# Rebuild deck from seed would go here for full determinism
# For now, we trust the dealt_cards data
def _apply_round_started(self, event: GameEvent):
self.current_round = event.data["round_num"]
self.phase = GamePhase.INITIAL_FLIP
self.finisher_id = None
self.drawn_card = None
for player_id, cards_data in event.data["dealt_cards"].items():
if player_id in self.players:
self.players[player_id].cards = [
Card.from_dict(c) for c in cards_data
]
self.players[player_id].score = None
def _apply_initial_flip(self, event: GameEvent):
player = self.players.get(event.player_id)
if player:
for pos, card_data in zip(event.data["positions"], event.data["cards"]):
if 0 <= pos < len(player.cards):
player.cards[pos] = Card.from_dict(card_data)
player.cards[pos].face_up = True
# Check if all players have flipped
required = self.options.get("initial_flips", 2)
all_flipped = all(
sum(1 for c in p.cards if c.face_up) >= required
for p in self.players.values()
)
if all_flipped and required > 0:
self.phase = GamePhase.PLAYING
def _apply_card_drawn(self, event: GameEvent):
card = Card.from_dict(event.data["card"])
card.face_up = True
self.drawn_card = card
if event.data["source"] == "discard" and self.discard:
self.discard.pop()
def _apply_card_swapped(self, event: GameEvent):
player = self.players.get(event.player_id)
if player and self.drawn_card:
pos = event.data["position"]
old_card = player.cards[pos]
new_card = Card.from_dict(event.data["new_card"])
new_card.face_up = True
player.cards[pos] = new_card
old_card.face_up = True
self.discard.append(old_card)
self.drawn_card = None
self._advance_turn(player)
def _apply_card_discarded(self, event: GameEvent):
if self.drawn_card:
self.discard.append(self.drawn_card)
self.drawn_card = None
player = self.players.get(event.player_id)
if player:
self._advance_turn(player)
def _apply_card_flipped(self, event: GameEvent):
player = self.players.get(event.player_id)
if player:
pos = event.data["position"]
card = Card.from_dict(event.data["card"])
card.face_up = True
player.cards[pos] = card
self._advance_turn(player)
def _apply_flip_skipped(self, event: GameEvent):
player = self.players.get(event.player_id)
if player:
self._advance_turn(player)
def _apply_flip_as_action(self, event: GameEvent):
player = self.players.get(event.player_id)
if player:
pos = event.data["position"]
card = Card.from_dict(event.data["card"])
card.face_up = True
player.cards[pos] = card
self._advance_turn(player)
def _apply_knock_early(self, event: GameEvent):
player = self.players.get(event.player_id)
if player:
for pos, card_data in zip(event.data["positions"], event.data["cards"]):
card = Card.from_dict(card_data)
card.face_up = True
player.cards[pos] = card
self._check_all_face_up(player)
self._advance_turn(player)
def _apply_round_ended(self, event: GameEvent):
self.phase = GamePhase.ROUND_OVER
for player_id, score in event.data["scores"].items():
if player_id in self.players:
self.players[player_id].score = score
self.players[player_id].total_score += score
winner_id = event.data.get("winner_id")
if winner_id and winner_id in self.players:
self.players[winner_id].rounds_won += 1
def _apply_game_ended(self, event: GameEvent):
self.phase = GamePhase.GAME_OVER
def _advance_turn(self, player: PlayerState):
"""Advance to next player's turn."""
self._check_all_face_up(player)
if self.phase == GamePhase.ROUND_OVER:
return
self.current_player_idx = (self.current_player_idx + 1) % len(self.player_order)
# Check if we've come back to finisher
if self.finisher_id:
current_id = self.player_order[self.current_player_idx]
if current_id == self.finisher_id:
self.phase = GamePhase.ROUND_OVER
def _check_all_face_up(self, player: PlayerState):
"""Check if player has all cards face up (triggers final turn)."""
if all(c.face_up for c in player.cards):
if self.phase == GamePhase.PLAYING and not self.finisher_id:
self.finisher_id = player.id
self.phase = GamePhase.FINAL_TURN
@property
def current_player_id(self) -> Optional[str]:
if self.player_order and 0 <= self.current_player_idx < len(self.player_order):
return self.player_order[self.current_player_idx]
return None
def rebuild_state(events: list[GameEvent]) -> RebuiltGameState:
"""Rebuild game state from a list of events."""
if not events:
raise ValueError("Cannot rebuild state from empty event list")
state = RebuiltGameState(game_id=events[0].game_id)
for event in events:
state.apply(event)
return state
```
---
## Dual-Write Integration
Modify existing game.py to emit events alongside mutations:
```python
# server/game.py additions
class Game:
def __init__(self):
# ... existing init ...
self._event_emitter: Optional[Callable[[GameEvent], None]] = None
self._sequence_num = 0
def set_event_emitter(self, emitter: Callable[[GameEvent], None]):
"""Set callback for event emission."""
self._event_emitter = emitter
def _emit(self, event_type: EventType, player_id: Optional[str] = None, **data):
"""Emit an event if emitter is configured."""
if self._event_emitter:
self._sequence_num += 1
event = GameEvent(
event_type=event_type,
game_id=self.game_id,
sequence_num=self._sequence_num,
player_id=player_id,
data=data,
)
self._event_emitter(event)
# Example: modify draw_card
def draw_card(self, player_id: str, source: str) -> Optional[Card]:
# ... existing validation ...
if source == "deck":
card = self.deck.pop()
else:
card = self.discard_pile.pop()
self.drawn_card = card
# NEW: Emit event
self._emit(
EventType.CARD_DRAWN,
player_id=player_id,
source=source,
card=card.to_dict(),
)
return card
```
---
## Validation Test
```python
# server/tests/test_event_replay.py
import pytest
from game import Game, GameOptions
from models.events import GameEvent, rebuild_state
class TestEventReplay:
"""Verify that event replay produces identical state."""
def test_full_game_replay(self):
"""Play a complete game and verify replay matches."""
events = []
def collect_events(event: GameEvent):
events.append(event)
# Play a real game
game = Game()
game.set_event_emitter(collect_events)
game.add_player("p1", "Alice")
game.add_player("p2", "Bob")
game.start_game(num_decks=1, num_rounds=1, options=GameOptions())
# Play through initial flips
game.flip_initial_cards("p1", [0, 1])
game.flip_initial_cards("p2", [0, 1])
# Play some turns
while game.phase not in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER):
current = game.current_player()
if not current:
break
# Simple bot: always draw from deck and discard
game.draw_card(current.id, "deck")
game.discard_drawn(current.id)
if len(events) > 100: # Safety limit
break
# Get final state
final_state = game.get_state("p1")
# Rebuild from events
rebuilt = rebuild_state(events)
# Verify key state matches
assert rebuilt.phase == game.phase
assert rebuilt.current_round == game.current_round
assert len(rebuilt.players) == len(game.players)
for player_id, player in rebuilt.players.items():
original = game.get_player(player_id)
assert player.score == original.score
assert player.total_score == original.total_score
assert len(player.cards) == len(original.cards)
for i, card in enumerate(player.cards):
orig_card = original.cards[i]
assert card.rank == orig_card.rank
assert card.suit == orig_card.suit
assert card.face_up == orig_card.face_up
def test_partial_replay(self):
"""Verify we can replay to any point in the game."""
events = []
def collect_events(event: GameEvent):
events.append(event)
game = Game()
game.set_event_emitter(collect_events)
# ... setup and play ...
# Replay only first N events
for n in range(1, len(events) + 1):
partial = rebuild_state(events[:n])
assert partial.sequence_num == n
def test_event_order_enforced(self):
"""Verify events must be applied in order."""
events = []
# ... collect some events ...
state = RebuiltGameState(game_id="test")
# Skip an event - should fail
with pytest.raises(AssertionError):
state.apply(events[1]) # Skipping events[0]
```
---
## Acceptance Criteria
1. **Event Classes Complete**
- [ ] All lifecycle events defined (created, joined, left, started, ended)
- [ ] All gameplay events defined (draw, swap, discard, flip, etc.)
- [ ] Events are serializable to/from JSON
- [ ] Events include all data needed for replay
2. **Event Store Working**
- [ ] PostgreSQL schema created via migration
- [ ] Can append single events
- [ ] Can append batches atomically
- [ ] Can retrieve events by game_id
- [ ] Can retrieve events by sequence range
- [ ] Concurrent writes to same sequence fail cleanly
3. **State Rebuilder Working**
- [ ] Can rebuild state from any event sequence
- [ ] Handles all event types
- [ ] Enforces event ordering
- [ ] Matches original game state exactly
4. **Dual-Write Enabled**
- [ ] Game class has event emitter hook
- [ ] All state-changing methods emit events
- [ ] Events don't affect existing game behavior
- [ ] Can be enabled/disabled via config
5. **Validation Tests Pass**
- [ ] Full game replay test
- [ ] Partial replay test
- [ ] Event order enforcement test
- [ ] At least 95% of games replay correctly
---
## Implementation Order
1. Create event dataclasses (`models/events.py`)
2. Create database migration for events table
3. Implement EventStore class
4. Implement RebuiltGameState class
5. Add event emitter to Game class
6. Add `_emit()` calls to all game methods
7. Write validation tests
8. Run tests until 100% pass
---
## Notes for Agent
- The existing `game.py` has good test coverage - don't break existing tests
- Start with lifecycle events, then gameplay events
- The deck seed is important for deterministic replay
- Consider edge cases: player disconnects, CPU players, house rules
- Events should be immutable - never modify after creation

View File

@@ -0,0 +1,870 @@
# V2-02: Persistence & Recovery
## Overview
This document covers the live state caching and game recovery system. Games will survive server restarts by storing live state in Redis and rebuilding from events.
**Dependencies:** V2-01 (Event Sourcing)
**Dependents:** V2-03 (User Accounts), V2-06 (Replay)
---
## Goals
1. Cache live game state in Redis
2. Implement Redis pub/sub for multi-server support
3. Enable game recovery from events on server restart
4. Implement graceful shutdown with state preservation
---
## Current State
Games are stored in-memory in `main.py`:
```python
# Current approach
rooms: dict[str, Room] = {} # Lost on restart!
```
On server restart, all active games are lost.
---
## Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ FastAPI #1 │ │ FastAPI #2 │ │ FastAPI #N │
│ (WebSocket) │ │ (WebSocket) │ │ (WebSocket) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└───────────────────────┼───────────────────────┘
┌────────────▼────────────┐
│ Redis │
│ ┌─────────────────┐ │
│ │ State Cache │ │ <- Live game state
│ │ (Hash/JSON) │ │
│ └─────────────────┘ │
│ ┌─────────────────┐ │
│ │ Pub/Sub │ │ <- Cross-server events
│ │ (Channels) │ │
│ └─────────────────┘ │
│ ┌─────────────────┐ │
│ │ Room Index │ │ <- Active room codes
│ │ (Set) │ │
│ └─────────────────┘ │
└─────────────────────────┘
┌────────────▼────────────┐
│ PostgreSQL │
│ (Event Store) │ <- Source of truth
└─────────────────────────┘
```
---
## Redis Data Model
### Key Patterns
```
golf:room:{room_code} -> Hash (room metadata)
golf:game:{game_id} -> JSON (full game state)
golf:room:{room_code}:players -> Set (connected player IDs)
golf:rooms:active -> Set (active room codes)
golf:player:{player_id}:room -> String (player's current room)
```
### Room Metadata Hash
```
golf:room:ABCD
├── game_id: "uuid-..."
├── host_id: "player-uuid"
├── created_at: "2024-01-15T10:30:00Z"
├── status: "waiting" | "playing" | "finished"
└── server_id: "server-1" # Which server owns this room
```
### Game State JSON
```json
{
"game_id": "uuid-...",
"room_code": "ABCD",
"phase": "playing",
"current_round": 3,
"total_rounds": 9,
"current_player_idx": 1,
"player_order": ["p1", "p2", "p3"],
"players": {
"p1": {
"id": "p1",
"name": "Alice",
"cards": [{"rank": "K", "suit": "hearts", "face_up": true}, ...],
"score": null,
"total_score": 15,
"rounds_won": 1,
"is_cpu": false
}
},
"deck_count": 32,
"discard_top": {"rank": "7", "suit": "clubs"},
"drawn_card": null,
"options": {...},
"sequence_num": 47
}
```
---
## State Cache Implementation
```python
# server/stores/state_cache.py
import json
from typing import Optional
from datetime import timedelta
import redis.asyncio as redis
from models.game_state import RebuiltGameState
class StateCache:
"""Redis-backed live game state cache."""
# Key patterns
ROOM_KEY = "golf:room:{room_code}"
GAME_KEY = "golf:game:{game_id}"
ROOM_PLAYERS_KEY = "golf:room:{room_code}:players"
ACTIVE_ROOMS_KEY = "golf:rooms:active"
PLAYER_ROOM_KEY = "golf:player:{player_id}:room"
# TTLs
ROOM_TTL = timedelta(hours=4) # Inactive rooms expire
GAME_TTL = timedelta(hours=4)
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
# --- Room Operations ---
async def create_room(
self,
room_code: str,
game_id: str,
host_id: str,
server_id: str,
) -> None:
"""Create a new room."""
pipe = self.redis.pipeline()
# Room metadata
pipe.hset(
self.ROOM_KEY.format(room_code=room_code),
mapping={
"game_id": game_id,
"host_id": host_id,
"status": "waiting",
"server_id": server_id,
"created_at": datetime.utcnow().isoformat(),
},
)
pipe.expire(self.ROOM_KEY.format(room_code=room_code), self.ROOM_TTL)
# Add to active rooms
pipe.sadd(self.ACTIVE_ROOMS_KEY, room_code)
# Track host's room
pipe.set(
self.PLAYER_ROOM_KEY.format(player_id=host_id),
room_code,
ex=self.ROOM_TTL,
)
await pipe.execute()
async def get_room(self, room_code: str) -> Optional[dict]:
"""Get room metadata."""
data = await self.redis.hgetall(self.ROOM_KEY.format(room_code=room_code))
if not data:
return None
return {k.decode(): v.decode() for k, v in data.items()}
async def room_exists(self, room_code: str) -> bool:
"""Check if room exists."""
return await self.redis.exists(self.ROOM_KEY.format(room_code=room_code)) > 0
async def delete_room(self, room_code: str) -> None:
"""Delete a room and all associated data."""
room = await self.get_room(room_code)
if not room:
return
pipe = self.redis.pipeline()
# Get players to clean up their mappings
players = await self.redis.smembers(
self.ROOM_PLAYERS_KEY.format(room_code=room_code)
)
for player_id in players:
pipe.delete(self.PLAYER_ROOM_KEY.format(player_id=player_id.decode()))
# Delete room data
pipe.delete(self.ROOM_KEY.format(room_code=room_code))
pipe.delete(self.ROOM_PLAYERS_KEY.format(room_code=room_code))
pipe.srem(self.ACTIVE_ROOMS_KEY, room_code)
# Delete game state if exists
if "game_id" in room:
pipe.delete(self.GAME_KEY.format(game_id=room["game_id"]))
await pipe.execute()
async def get_active_rooms(self) -> set[str]:
"""Get all active room codes."""
rooms = await self.redis.smembers(self.ACTIVE_ROOMS_KEY)
return {r.decode() for r in rooms}
# --- Player Operations ---
async def add_player_to_room(self, room_code: str, player_id: str) -> None:
"""Add a player to a room."""
pipe = self.redis.pipeline()
pipe.sadd(self.ROOM_PLAYERS_KEY.format(room_code=room_code), player_id)
pipe.set(
self.PLAYER_ROOM_KEY.format(player_id=player_id),
room_code,
ex=self.ROOM_TTL,
)
# Refresh room TTL on activity
pipe.expire(self.ROOM_KEY.format(room_code=room_code), self.ROOM_TTL)
await pipe.execute()
async def remove_player_from_room(self, room_code: str, player_id: str) -> None:
"""Remove a player from a room."""
pipe = self.redis.pipeline()
pipe.srem(self.ROOM_PLAYERS_KEY.format(room_code=room_code), player_id)
pipe.delete(self.PLAYER_ROOM_KEY.format(player_id=player_id))
await pipe.execute()
async def get_room_players(self, room_code: str) -> set[str]:
"""Get player IDs in a room."""
players = await self.redis.smembers(
self.ROOM_PLAYERS_KEY.format(room_code=room_code)
)
return {p.decode() for p in players}
async def get_player_room(self, player_id: str) -> Optional[str]:
"""Get the room a player is in."""
room = await self.redis.get(self.PLAYER_ROOM_KEY.format(player_id=player_id))
return room.decode() if room else None
# --- Game State Operations ---
async def save_game_state(self, game_id: str, state: dict) -> None:
"""Save full game state."""
await self.redis.set(
self.GAME_KEY.format(game_id=game_id),
json.dumps(state),
ex=self.GAME_TTL,
)
async def get_game_state(self, game_id: str) -> Optional[dict]:
"""Get full game state."""
data = await self.redis.get(self.GAME_KEY.format(game_id=game_id))
if not data:
return None
return json.loads(data)
async def update_game_state(self, game_id: str, updates: dict) -> None:
"""Partial update to game state (get, merge, set)."""
state = await self.get_game_state(game_id)
if state:
state.update(updates)
await self.save_game_state(game_id, state)
async def delete_game_state(self, game_id: str) -> None:
"""Delete game state."""
await self.redis.delete(self.GAME_KEY.format(game_id=game_id))
# --- Room Status ---
async def set_room_status(self, room_code: str, status: str) -> None:
"""Update room status."""
await self.redis.hset(
self.ROOM_KEY.format(room_code=room_code),
"status",
status,
)
async def refresh_room_ttl(self, room_code: str) -> None:
"""Refresh room TTL on activity."""
pipe = self.redis.pipeline()
pipe.expire(self.ROOM_KEY.format(room_code=room_code), self.ROOM_TTL)
room = await self.get_room(room_code)
if room and "game_id" in room:
pipe.expire(self.GAME_KEY.format(game_id=room["game_id"]), self.GAME_TTL)
await pipe.execute()
```
---
## Pub/Sub for Multi-Server
```python
# server/stores/pubsub.py
import asyncio
import json
from typing import Callable, Awaitable
from dataclasses import dataclass
from enum import Enum
import redis.asyncio as redis
class MessageType(str, Enum):
GAME_STATE_UPDATE = "game_state_update"
PLAYER_JOINED = "player_joined"
PLAYER_LEFT = "player_left"
ROOM_CLOSED = "room_closed"
BROADCAST = "broadcast"
@dataclass
class PubSubMessage:
type: MessageType
room_code: str
data: dict
def to_json(self) -> str:
return json.dumps({
"type": self.type.value,
"room_code": self.room_code,
"data": self.data,
})
@classmethod
def from_json(cls, raw: str) -> "PubSubMessage":
d = json.loads(raw)
return cls(
type=MessageType(d["type"]),
room_code=d["room_code"],
data=d["data"],
)
class GamePubSub:
"""Redis pub/sub for cross-server game events."""
CHANNEL_PREFIX = "golf:room:"
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
self.pubsub = redis_client.pubsub()
self._handlers: dict[str, list[Callable[[PubSubMessage], Awaitable[None]]]] = {}
self._running = False
self._task: Optional[asyncio.Task] = None
def _channel(self, room_code: str) -> str:
return f"{self.CHANNEL_PREFIX}{room_code}"
async def subscribe(
self,
room_code: str,
handler: Callable[[PubSubMessage], Awaitable[None]],
) -> None:
"""Subscribe to room events."""
channel = self._channel(room_code)
if channel not in self._handlers:
self._handlers[channel] = []
await self.pubsub.subscribe(channel)
self._handlers[channel].append(handler)
async def unsubscribe(self, room_code: str) -> None:
"""Unsubscribe from room events."""
channel = self._channel(room_code)
if channel in self._handlers:
del self._handlers[channel]
await self.pubsub.unsubscribe(channel)
async def publish(self, message: PubSubMessage) -> None:
"""Publish a message to a room's channel."""
channel = self._channel(message.room_code)
await self.redis.publish(channel, message.to_json())
async def start(self) -> None:
"""Start listening for messages."""
self._running = True
self._task = asyncio.create_task(self._listen())
async def stop(self) -> None:
"""Stop listening."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
await self.pubsub.close()
async def _listen(self) -> None:
"""Main listener loop."""
while self._running:
try:
message = await self.pubsub.get_message(
ignore_subscribe_messages=True,
timeout=1.0,
)
if message and message["type"] == "message":
channel = message["channel"].decode()
handlers = self._handlers.get(channel, [])
try:
msg = PubSubMessage.from_json(message["data"].decode())
for handler in handlers:
await handler(msg)
except Exception as e:
print(f"Error handling pubsub message: {e}")
except asyncio.CancelledError:
break
except Exception as e:
print(f"PubSub listener error: {e}")
await asyncio.sleep(1)
```
---
## Game Recovery
```python
# server/services/recovery_service.py
from typing import Optional
import asyncio
from stores.event_store import EventStore
from stores.state_cache import StateCache
from models.events import rebuild_state, EventType
class RecoveryService:
"""Recovers games from event store on startup."""
def __init__(self, event_store: EventStore, state_cache: StateCache):
self.event_store = event_store
self.state_cache = state_cache
async def recover_all_games(self) -> dict[str, any]:
"""
Recover all active games from event store.
Returns dict of recovered games.
"""
results = {
"recovered": 0,
"failed": 0,
"skipped": 0,
"games": [],
}
# Get active rooms from Redis (may be stale)
active_rooms = await self.state_cache.get_active_rooms()
for room_code in active_rooms:
room = await self.state_cache.get_room(room_code)
if not room:
results["skipped"] += 1
continue
game_id = room.get("game_id")
if not game_id:
results["skipped"] += 1
continue
try:
game = await self.recover_game(game_id)
if game:
results["recovered"] += 1
results["games"].append({
"game_id": game_id,
"room_code": room_code,
"phase": game.phase.value,
"sequence": game.sequence_num,
})
else:
results["skipped"] += 1
except Exception as e:
print(f"Failed to recover game {game_id}: {e}")
results["failed"] += 1
return results
async def recover_game(self, game_id: str) -> Optional[any]:
"""
Recover a single game from event store.
Returns the rebuilt game state.
"""
# Get all events for this game
events = await self.event_store.get_events(game_id)
if not events:
return None
# Check if game is actually active (not ended)
last_event = events[-1]
if last_event.event_type == EventType.GAME_ENDED:
return None # Game is finished, don't recover
# Rebuild state
state = rebuild_state(events)
# Save to cache
await self.state_cache.save_game_state(
game_id,
self._state_to_dict(state),
)
return state
async def recover_from_sequence(
self,
game_id: str,
cached_state: dict,
cached_sequence: int,
) -> Optional[any]:
"""
Recover game by applying only new events to cached state.
More efficient than full rebuild.
"""
# Get events after cached sequence
new_events = await self.event_store.get_events(
game_id,
from_sequence=cached_sequence + 1,
)
if not new_events:
return None # No new events
# Rebuild state from cache + new events
state = self._dict_to_state(cached_state)
for event in new_events:
state.apply(event)
# Update cache
await self.state_cache.save_game_state(
game_id,
self._state_to_dict(state),
)
return state
def _state_to_dict(self, state) -> dict:
"""Convert RebuiltGameState to dict for caching."""
return {
"game_id": state.game_id,
"room_code": state.room_code,
"phase": state.phase.value,
"current_round": state.current_round,
"total_rounds": state.total_rounds,
"current_player_idx": state.current_player_idx,
"player_order": state.player_order,
"players": {
pid: {
"id": p.id,
"name": p.name,
"cards": [c.to_dict() for c in p.cards],
"score": p.score,
"total_score": p.total_score,
"rounds_won": p.rounds_won,
"is_cpu": p.is_cpu,
"cpu_profile": p.cpu_profile,
}
for pid, p in state.players.items()
},
"deck_count": len(state.deck),
"discard_top": state.discard[-1].to_dict() if state.discard else None,
"drawn_card": state.drawn_card.to_dict() if state.drawn_card else None,
"options": state.options,
"sequence_num": state.sequence_num,
"finisher_id": state.finisher_id,
}
def _dict_to_state(self, d: dict):
"""Convert dict back to RebuiltGameState."""
# Implementation depends on RebuiltGameState structure
pass
```
---
## Graceful Shutdown
```python
# server/main.py additions
import signal
import asyncio
from contextlib import asynccontextmanager
from stores.state_cache import StateCache
from stores.event_store import EventStore
from services.recovery_service import RecoveryService
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
# Startup
print("Starting up...")
# Initialize connections
app.state.redis = await create_redis_pool()
app.state.pg_pool = await create_pg_pool()
app.state.state_cache = StateCache(app.state.redis)
app.state.event_store = EventStore(app.state.pg_pool)
app.state.recovery_service = RecoveryService(
app.state.event_store,
app.state.state_cache,
)
# Recover games
print("Recovering games from event store...")
results = await app.state.recovery_service.recover_all_games()
print(f"Recovery complete: {results['recovered']} recovered, "
f"{results['failed']} failed, {results['skipped']} skipped")
# Start pub/sub
app.state.pubsub = GamePubSub(app.state.redis)
await app.state.pubsub.start()
yield
# Shutdown
print("Shutting down...")
# Stop accepting new connections
await app.state.pubsub.stop()
# Flush any pending state to Redis
await flush_pending_states(app)
# Close connections
await app.state.redis.close()
await app.state.pg_pool.close()
print("Shutdown complete")
async def flush_pending_states(app: FastAPI):
"""Flush any in-memory state to Redis before shutdown."""
# If we have any rooms with unsaved state, save them now
for room_code, room in rooms.items():
if room.game and room.game.game_id:
try:
state = room.game.get_full_state()
await app.state.state_cache.save_game_state(
room.game.game_id,
state,
)
except Exception as e:
print(f"Error flushing state for room {room_code}: {e}")
app = FastAPI(lifespan=lifespan)
# Handle SIGTERM gracefully
def handle_sigterm(signum, frame):
"""Handle SIGTERM by initiating graceful shutdown."""
raise KeyboardInterrupt()
signal.signal(signal.SIGTERM, handle_sigterm)
```
---
## Integration with Game Service
```python
# server/services/game_service.py
from stores.state_cache import StateCache
from stores.event_store import EventStore
from stores.pubsub import GamePubSub, PubSubMessage, MessageType
class GameService:
"""
Handles game commands with event sourcing.
Coordinates between event store, state cache, and pub/sub.
"""
def __init__(
self,
event_store: EventStore,
state_cache: StateCache,
pubsub: GamePubSub,
):
self.event_store = event_store
self.state_cache = state_cache
self.pubsub = pubsub
async def handle_draw(
self,
game_id: str,
player_id: str,
source: str,
) -> dict:
"""Handle draw card command."""
# 1. Get current state from cache
state = await self.state_cache.get_game_state(game_id)
if not state:
raise GameNotFoundError(game_id)
# 2. Validate command
if state["current_player_id"] != player_id:
raise NotYourTurnError()
# 3. Execute command (get card from deck/discard)
# This uses the existing game logic
game = self._load_game_from_state(state)
card = game.draw_card(player_id, source)
if not card:
raise InvalidMoveError("Cannot draw from that source")
# 4. Create event
event = GameEvent(
event_type=EventType.CARD_DRAWN,
game_id=game_id,
sequence_num=state["sequence_num"] + 1,
player_id=player_id,
data={"source": source, "card": card.to_dict()},
)
# 5. Persist event
await self.event_store.append(event)
# 6. Update cache
new_state = game.get_full_state()
new_state["sequence_num"] = event.sequence_num
await self.state_cache.save_game_state(game_id, new_state)
# 7. Publish to other servers
await self.pubsub.publish(PubSubMessage(
type=MessageType.GAME_STATE_UPDATE,
room_code=state["room_code"],
data={"game_state": new_state},
))
return new_state
```
---
## Acceptance Criteria
1. **Redis State Cache Working**
- [ ] Can create/get/delete rooms
- [ ] Can add/remove players from rooms
- [ ] Can save/get/delete game state
- [ ] TTL expiration works correctly
- [ ] Room code uniqueness enforced
2. **Pub/Sub Working**
- [ ] Can subscribe to room channels
- [ ] Can publish messages
- [ ] Messages received by all subscribers
- [ ] Handles disconnections gracefully
- [ ] Multiple servers can communicate
3. **Game Recovery Working**
- [ ] Games recovered on startup
- [ ] State matches what was saved
- [ ] Partial recovery (from sequence) works
- [ ] Ended games not recovered
- [ ] Failed recoveries logged and skipped
4. **Graceful Shutdown Working**
- [ ] SIGTERM triggers clean shutdown
- [ ] In-flight requests complete
- [ ] State flushed to Redis
- [ ] Connections closed cleanly
- [ ] No data loss on restart
5. **Integration Tests**
- [ ] Server restart doesn't lose games
- [ ] Multi-server state sync works
- [ ] State cache matches event store
- [ ] Performance acceptable (<100ms for state ops)
---
## Implementation Order
1. Set up Redis locally (docker)
2. Implement StateCache class
3. Write StateCache tests
4. Implement GamePubSub class
5. Implement RecoveryService
6. Add lifespan handler to main.py
7. Integrate with game commands
8. Test full recovery cycle
9. Test multi-server pub/sub
---
## Docker Setup for Development
```yaml
# docker-compose.dev.yml
version: '3.8'
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: golf
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: golf
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
redis_data:
postgres_data:
```
```bash
# Start services
docker-compose -f docker-compose.dev.yml up -d
# Connect to Redis CLI
docker exec -it golfgame_redis_1 redis-cli
# Connect to PostgreSQL
docker exec -it golfgame_postgres_1 psql -U golf
```
---
## Notes for Agent
- Redis operations should use pipelines for atomicity
- Consider Redis Cluster for production (but not needed initially)
- The state cache is a cache, not source of truth (events are)
- Pub/sub is best-effort; state sync should handle missed messages
- Test with multiple server instances locally
- Use connection pooling for both Redis and PostgreSQL

File diff suppressed because it is too large Load Diff

1179
docs/v2/V2_04_ADMIN_TOOLS.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,871 @@
# V2-05: Stats & Leaderboards
## Overview
This document covers player statistics aggregation and leaderboard systems.
**Dependencies:** V2-03 (User Accounts), V2-01 (Events for aggregation)
**Dependents:** None (end feature)
---
## Goals
1. Aggregate player statistics from game events
2. Create leaderboard views (by wins, by average score, etc.)
3. Background worker for stats processing
4. Leaderboard API endpoints
5. Leaderboard UI in client
6. Achievement/badge system (stretch goal)
---
## Database Schema
```sql
-- migrations/versions/004_stats_leaderboards.sql
-- Player statistics (aggregated from events)
CREATE TABLE player_stats (
user_id UUID PRIMARY KEY REFERENCES users(id),
-- Game counts
games_played INT DEFAULT 0,
games_won INT DEFAULT 0,
games_vs_humans INT DEFAULT 0,
games_won_vs_humans INT DEFAULT 0,
-- Round stats
rounds_played INT DEFAULT 0,
rounds_won INT DEFAULT 0,
total_points INT DEFAULT 0, -- Sum of all round scores (lower is better)
-- Best/worst
best_round_score INT,
worst_round_score INT,
best_game_score INT, -- Lowest total in a game
-- Achievements
knockouts INT DEFAULT 0, -- Times going out first
perfect_rounds INT DEFAULT 0, -- Score of 0 or less
wolfpacks INT DEFAULT 0, -- Four jacks achieved
-- Streaks
current_win_streak INT DEFAULT 0,
best_win_streak INT DEFAULT 0,
-- Timestamps
first_game_at TIMESTAMPTZ,
last_game_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Stats processing queue (for background worker)
CREATE TABLE stats_queue (
id BIGSERIAL PRIMARY KEY,
game_id UUID NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ,
error_message TEXT
);
-- Leaderboard cache (refreshed periodically)
CREATE MATERIALIZED VIEW leaderboard_overall AS
SELECT
u.id as user_id,
u.username,
s.games_played,
s.games_won,
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
s.rounds_won,
ROUND(s.total_points::numeric / NULLIF(s.rounds_played, 0), 1) as avg_score,
s.best_round_score,
s.knockouts,
s.best_win_streak,
s.last_game_at
FROM player_stats s
JOIN users u ON s.user_id = u.id
WHERE s.games_played >= 5 -- Minimum games for ranking
AND u.deleted_at IS NULL
AND u.is_banned = false;
CREATE UNIQUE INDEX idx_leaderboard_overall_user ON leaderboard_overall(user_id);
CREATE INDEX idx_leaderboard_overall_wins ON leaderboard_overall(games_won DESC);
CREATE INDEX idx_leaderboard_overall_rate ON leaderboard_overall(win_rate DESC);
CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC);
-- Achievements/badges
CREATE TABLE achievements (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
icon VARCHAR(50),
category VARCHAR(50), -- games, rounds, special
threshold INT, -- e.g., 10 for "Win 10 games"
sort_order INT DEFAULT 0
);
CREATE TABLE user_achievements (
user_id UUID REFERENCES users(id),
achievement_id VARCHAR(50) REFERENCES achievements(id),
earned_at TIMESTAMPTZ DEFAULT NOW(),
game_id UUID, -- Game where it was earned (optional)
PRIMARY KEY (user_id, achievement_id)
);
-- Seed achievements
INSERT INTO achievements (id, name, description, icon, category, threshold, sort_order) VALUES
('first_win', 'First Victory', 'Win your first game', '🏆', 'games', 1, 1),
('win_10', 'Rising Star', 'Win 10 games', '', 'games', 10, 2),
('win_50', 'Veteran', 'Win 50 games', '🎖️', 'games', 50, 3),
('win_100', 'Champion', 'Win 100 games', '👑', 'games', 100, 4),
('perfect_round', 'Perfect', 'Score 0 or less in a round', '💎', 'rounds', 1, 10),
('negative_round', 'Below Zero', 'Score negative in a round', '❄️', 'rounds', 1, 11),
('knockout_10', 'Closer', 'Go out first 10 times', '🚪', 'special', 10, 20),
('wolfpack', 'Wolfpack', 'Get all 4 Jacks', '🐺', 'special', 1, 21),
('streak_5', 'Hot Streak', 'Win 5 games in a row', '🔥', 'special', 5, 30),
('streak_10', 'Unstoppable', 'Win 10 games in a row', '', 'special', 10, 31);
-- Indexes
CREATE INDEX idx_stats_queue_pending ON stats_queue(status, created_at)
WHERE status = 'pending';
CREATE INDEX idx_user_achievements_user ON user_achievements(user_id);
```
---
## Stats Service
```python
# server/services/stats_service.py
from dataclasses import dataclass
from typing import Optional, List
from datetime import datetime
import asyncpg
from stores.event_store import EventStore
from models.events import EventType
@dataclass
class PlayerStats:
user_id: str
username: str
games_played: int
games_won: int
win_rate: float
rounds_played: int
rounds_won: int
avg_score: float
best_round_score: Optional[int]
knockouts: int
best_win_streak: int
achievements: List[str]
@dataclass
class LeaderboardEntry:
rank: int
user_id: str
username: str
value: float # The metric being ranked by
games_played: int
secondary_value: Optional[float] = None
class StatsService:
"""Player statistics and leaderboards."""
def __init__(self, db_pool: asyncpg.Pool, event_store: EventStore):
self.db = db_pool
self.event_store = event_store
# --- Stats Queries ---
async def get_player_stats(self, user_id: str) -> Optional[PlayerStats]:
"""Get stats for a specific player."""
async with self.db.acquire() as conn:
row = await conn.fetchrow("""
SELECT s.*, u.username,
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
ROUND(s.total_points::numeric / NULLIF(s.rounds_played, 0), 1) as avg_score
FROM player_stats s
JOIN users u ON s.user_id = u.id
WHERE s.user_id = $1
""", user_id)
if not row:
return None
# Get achievements
achievements = await conn.fetch("""
SELECT achievement_id FROM user_achievements
WHERE user_id = $1
""", user_id)
return PlayerStats(
user_id=row["user_id"],
username=row["username"],
games_played=row["games_played"],
games_won=row["games_won"],
win_rate=float(row["win_rate"] or 0),
rounds_played=row["rounds_played"],
rounds_won=row["rounds_won"],
avg_score=float(row["avg_score"] or 0),
best_round_score=row["best_round_score"],
knockouts=row["knockouts"],
best_win_streak=row["best_win_streak"],
achievements=[a["achievement_id"] for a in achievements],
)
async def get_leaderboard(
self,
metric: str = "wins",
limit: int = 50,
offset: int = 0,
) -> List[LeaderboardEntry]:
"""
Get leaderboard by metric.
Metrics: wins, win_rate, avg_score, knockouts, streak
"""
order_map = {
"wins": ("games_won", "DESC"),
"win_rate": ("win_rate", "DESC"),
"avg_score": ("avg_score", "ASC"), # Lower is better
"knockouts": ("knockouts", "DESC"),
"streak": ("best_win_streak", "DESC"),
}
if metric not in order_map:
metric = "wins"
column, direction = order_map[metric]
async with self.db.acquire() as conn:
# Use materialized view for performance
rows = await conn.fetch(f"""
SELECT
user_id, username, games_played, games_won,
win_rate, avg_score, knockouts, best_win_streak,
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM leaderboard_overall
ORDER BY {column} {direction}
LIMIT $1 OFFSET $2
""", limit, offset)
return [
LeaderboardEntry(
rank=row["rank"],
user_id=row["user_id"],
username=row["username"],
value=float(row[column] or 0),
games_played=row["games_played"],
secondary_value=float(row["win_rate"] or 0) if metric != "win_rate" else None,
)
for row in rows
]
async def get_player_rank(self, user_id: str, metric: str = "wins") -> Optional[int]:
"""Get a player's rank on a leaderboard."""
order_map = {
"wins": ("games_won", "DESC"),
"win_rate": ("win_rate", "DESC"),
"avg_score": ("avg_score", "ASC"),
}
if metric not in order_map:
return None
column, direction = order_map[metric]
async with self.db.acquire() as conn:
row = await conn.fetchrow(f"""
SELECT rank FROM (
SELECT user_id, ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM leaderboard_overall
) ranked
WHERE user_id = $1
""", user_id)
return row["rank"] if row else None
async def refresh_leaderboard(self) -> None:
"""Refresh the materialized view."""
async with self.db.acquire() as conn:
await conn.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard_overall")
# --- Achievement Queries ---
async def get_achievements(self) -> List[dict]:
"""Get all available achievements."""
async with self.db.acquire() as conn:
rows = await conn.fetch("""
SELECT id, name, description, icon, category, threshold
FROM achievements
ORDER BY sort_order
""")
return [dict(row) for row in rows]
async def get_user_achievements(self, user_id: str) -> List[dict]:
"""Get achievements earned by a user."""
async with self.db.acquire() as conn:
rows = await conn.fetch("""
SELECT a.id, a.name, a.description, a.icon, ua.earned_at
FROM user_achievements ua
JOIN achievements a ON ua.achievement_id = a.id
WHERE ua.user_id = $1
ORDER BY ua.earned_at DESC
""", user_id)
return [dict(row) for row in rows]
# --- Stats Processing ---
async def process_game_end(self, game_id: str) -> None:
"""
Process a completed game and update player stats.
Called by background worker or directly after game ends.
"""
# Get game events
events = await self.event_store.get_events(game_id)
if not events:
return
# Extract game data from events
game_data = self._extract_game_data(events)
if not game_data:
return
async with self.db.acquire() as conn:
async with conn.transaction():
for player_id, player_data in game_data["players"].items():
# Skip CPU players (they don't have user accounts)
if player_data.get("is_cpu"):
continue
# Ensure stats row exists
await conn.execute("""
INSERT INTO player_stats (user_id)
VALUES ($1)
ON CONFLICT (user_id) DO NOTHING
""", player_id)
# Update stats
is_winner = player_id == game_data["winner_id"]
total_score = player_data["total_score"]
rounds_won = player_data["rounds_won"]
await conn.execute("""
UPDATE player_stats SET
games_played = games_played + 1,
games_won = games_won + $2,
rounds_played = rounds_played + $3,
rounds_won = rounds_won + $4,
total_points = total_points + $5,
knockouts = knockouts + $6,
best_round_score = LEAST(best_round_score, $7),
worst_round_score = GREATEST(worst_round_score, $8),
best_game_score = LEAST(best_game_score, $5),
current_win_streak = CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE 0 END,
best_win_streak = GREATEST(best_win_streak,
CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE best_win_streak END),
first_game_at = COALESCE(first_game_at, NOW()),
last_game_at = NOW(),
updated_at = NOW()
WHERE user_id = $1
""",
player_id,
1 if is_winner else 0,
game_data["num_rounds"],
rounds_won,
total_score,
player_data.get("knockouts", 0),
player_data.get("best_round", total_score),
player_data.get("worst_round", total_score),
)
# Check for new achievements
await self._check_achievements(conn, player_id, game_id, player_data, is_winner)
def _extract_game_data(self, events) -> Optional[dict]:
"""Extract game data from events."""
data = {
"players": {},
"num_rounds": 0,
"winner_id": None,
}
for event in events:
if event.event_type == EventType.PLAYER_JOINED:
data["players"][event.player_id] = {
"is_cpu": event.data.get("is_cpu", False),
"total_score": 0,
"rounds_won": 0,
"knockouts": 0,
"best_round": None,
"worst_round": None,
}
elif event.event_type == EventType.ROUND_ENDED:
data["num_rounds"] += 1
scores = event.data.get("scores", {})
winner_id = event.data.get("winner_id")
for player_id, score in scores.items():
if player_id in data["players"]:
p = data["players"][player_id]
p["total_score"] += score
if p["best_round"] is None or score < p["best_round"]:
p["best_round"] = score
if p["worst_round"] is None or score > p["worst_round"]:
p["worst_round"] = score
if player_id == winner_id:
p["rounds_won"] += 1
# Track who went out first (finisher)
# This would need to be tracked in events
elif event.event_type == EventType.GAME_ENDED:
data["winner_id"] = event.data.get("winner_id")
return data if data["num_rounds"] > 0 else None
async def _check_achievements(
self,
conn: asyncpg.Connection,
user_id: str,
game_id: str,
player_data: dict,
is_winner: bool,
) -> List[str]:
"""Check and award new achievements."""
new_achievements = []
# Get current stats
stats = await conn.fetchrow("""
SELECT games_won, knockouts, best_win_streak, current_win_streak
FROM player_stats
WHERE user_id = $1
""", user_id)
if not stats:
return []
# Get already earned achievements
earned = await conn.fetch("""
SELECT achievement_id FROM user_achievements WHERE user_id = $1
""", user_id)
earned_ids = {e["achievement_id"] for e in earned}
# Check win milestones
wins = stats["games_won"]
if wins >= 1 and "first_win" not in earned_ids:
new_achievements.append("first_win")
if wins >= 10 and "win_10" not in earned_ids:
new_achievements.append("win_10")
if wins >= 50 and "win_50" not in earned_ids:
new_achievements.append("win_50")
if wins >= 100 and "win_100" not in earned_ids:
new_achievements.append("win_100")
# Check streak achievements
streak = stats["current_win_streak"]
if streak >= 5 and "streak_5" not in earned_ids:
new_achievements.append("streak_5")
if streak >= 10 and "streak_10" not in earned_ids:
new_achievements.append("streak_10")
# Check knockout achievements
if stats["knockouts"] >= 10 and "knockout_10" not in earned_ids:
new_achievements.append("knockout_10")
# Check round-specific achievements
if player_data.get("best_round") is not None:
if player_data["best_round"] <= 0 and "perfect_round" not in earned_ids:
new_achievements.append("perfect_round")
if player_data["best_round"] < 0 and "negative_round" not in earned_ids:
new_achievements.append("negative_round")
# Award new achievements
for achievement_id in new_achievements:
await conn.execute("""
INSERT INTO user_achievements (user_id, achievement_id, game_id)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
""", user_id, achievement_id, game_id)
return new_achievements
```
---
## Background Worker
```python
# server/workers/stats_worker.py
import asyncio
from datetime import datetime, timedelta
import asyncpg
from arq import create_pool
from arq.connections import RedisSettings
from services.stats_service import StatsService
from stores.event_store import EventStore
async def process_stats_queue(ctx):
"""Process pending games in the stats queue."""
db: asyncpg.Pool = ctx["db_pool"]
stats_service: StatsService = ctx["stats_service"]
async with db.acquire() as conn:
# Get pending games
games = await conn.fetch("""
SELECT id, game_id FROM stats_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 100
""")
for game in games:
try:
# Mark as processing
await conn.execute("""
UPDATE stats_queue SET status = 'processing' WHERE id = $1
""", game["id"])
# Process
await stats_service.process_game_end(game["game_id"])
# Mark complete
await conn.execute("""
UPDATE stats_queue
SET status = 'completed', processed_at = NOW()
WHERE id = $1
""", game["id"])
except Exception as e:
# Mark failed
await conn.execute("""
UPDATE stats_queue
SET status = 'failed', error_message = $2
WHERE id = $1
""", game["id"], str(e))
async def refresh_leaderboard(ctx):
"""Refresh the materialized leaderboard view."""
stats_service: StatsService = ctx["stats_service"]
await stats_service.refresh_leaderboard()
async def cleanup_old_queue_entries(ctx):
"""Clean up old processed queue entries."""
db: asyncpg.Pool = ctx["db_pool"]
async with db.acquire() as conn:
await conn.execute("""
DELETE FROM stats_queue
WHERE status IN ('completed', 'failed')
AND processed_at < NOW() - INTERVAL '7 days'
""")
class WorkerSettings:
"""arq worker settings."""
functions = [
process_stats_queue,
refresh_leaderboard,
cleanup_old_queue_entries,
]
cron_jobs = [
# Process queue every minute
cron(process_stats_queue, minute={0, 15, 30, 45}),
# Refresh leaderboard every 5 minutes
cron(refresh_leaderboard, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}),
# Cleanup daily
cron(cleanup_old_queue_entries, hour=3, minute=0),
]
redis_settings = RedisSettings()
@staticmethod
async def on_startup(ctx):
"""Initialize worker context."""
ctx["db_pool"] = await asyncpg.create_pool(DATABASE_URL)
ctx["event_store"] = EventStore(ctx["db_pool"])
ctx["stats_service"] = StatsService(ctx["db_pool"], ctx["event_store"])
@staticmethod
async def on_shutdown(ctx):
"""Cleanup worker context."""
await ctx["db_pool"].close()
```
---
## API Endpoints
```python
# server/routers/stats.py
from fastapi import APIRouter, Depends, Query
from typing import Optional
router = APIRouter(prefix="/api/stats", tags=["stats"])
@router.get("/leaderboard")
async def get_leaderboard(
metric: str = Query("wins", regex="^(wins|win_rate|avg_score|knockouts|streak)$"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
service: StatsService = Depends(get_stats_service),
):
"""Get leaderboard by metric."""
entries = await service.get_leaderboard(metric, limit, offset)
return {
"metric": metric,
"entries": [
{
"rank": e.rank,
"user_id": e.user_id,
"username": e.username,
"value": e.value,
"games_played": e.games_played,
}
for e in entries
],
}
@router.get("/players/{user_id}")
async def get_player_stats(
user_id: str,
service: StatsService = Depends(get_stats_service),
):
"""Get stats for a specific player."""
stats = await service.get_player_stats(user_id)
if not stats:
raise HTTPException(status_code=404, detail="Player not found")
return {
"user_id": stats.user_id,
"username": stats.username,
"games_played": stats.games_played,
"games_won": stats.games_won,
"win_rate": stats.win_rate,
"rounds_played": stats.rounds_played,
"rounds_won": stats.rounds_won,
"avg_score": stats.avg_score,
"best_round_score": stats.best_round_score,
"knockouts": stats.knockouts,
"best_win_streak": stats.best_win_streak,
"achievements": stats.achievements,
}
@router.get("/players/{user_id}/rank")
async def get_player_rank(
user_id: str,
metric: str = "wins",
service: StatsService = Depends(get_stats_service),
):
"""Get player's rank on a leaderboard."""
rank = await service.get_player_rank(user_id, metric)
return {"user_id": user_id, "metric": metric, "rank": rank}
@router.get("/me")
async def get_my_stats(
user: User = Depends(get_current_user),
service: StatsService = Depends(get_stats_service),
):
"""Get current user's stats."""
stats = await service.get_player_stats(user.id)
if not stats:
return {
"games_played": 0,
"games_won": 0,
"achievements": [],
}
return stats.__dict__
@router.get("/achievements")
async def get_achievements(
service: StatsService = Depends(get_stats_service),
):
"""Get all available achievements."""
return {"achievements": await service.get_achievements()}
@router.get("/players/{user_id}/achievements")
async def get_user_achievements(
user_id: str,
service: StatsService = Depends(get_stats_service),
):
"""Get achievements earned by a player."""
return {"achievements": await service.get_user_achievements(user_id)}
```
---
## Frontend Integration
```javascript
// client/components/leaderboard.js
class LeaderboardComponent {
constructor(container) {
this.container = container;
this.metric = 'wins';
this.render();
}
async fetchLeaderboard() {
const response = await fetch(`/api/stats/leaderboard?metric=${this.metric}&limit=50`);
return response.json();
}
async render() {
const data = await this.fetchLeaderboard();
this.container.innerHTML = `
<div class="leaderboard">
<div class="leaderboard-tabs">
<button class="tab ${this.metric === 'wins' ? 'active' : ''}" data-metric="wins">Wins</button>
<button class="tab ${this.metric === 'win_rate' ? 'active' : ''}" data-metric="win_rate">Win Rate</button>
<button class="tab ${this.metric === 'avg_score' ? 'active' : ''}" data-metric="avg_score">Avg Score</button>
</div>
<table class="leaderboard-table">
<thead>
<tr>
<th>#</th>
<th>Player</th>
<th>${this.getMetricLabel()}</th>
<th>Games</th>
</tr>
</thead>
<tbody>
${data.entries.map(e => `
<tr>
<td class="rank">${this.getRankBadge(e.rank)}</td>
<td class="username">${e.username}</td>
<td class="value">${this.formatValue(e.value)}</td>
<td class="games">${e.games_played}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
// Bind tab clicks
this.container.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
this.metric = tab.dataset.metric;
this.render();
});
});
}
getMetricLabel() {
const labels = {
wins: 'Wins',
win_rate: 'Win %',
avg_score: 'Avg Score',
};
return labels[this.metric] || this.metric;
}
formatValue(value) {
if (this.metric === 'win_rate') return `${value}%`;
if (this.metric === 'avg_score') return value.toFixed(1);
return value;
}
getRankBadge(rank) {
if (rank === 1) return '🥇';
if (rank === 2) return '🥈';
if (rank === 3) return '🥉';
return rank;
}
}
```
---
## Acceptance Criteria
1. **Stats Aggregation**
- [ ] Stats calculated from game events
- [ ] Games played/won tracked
- [ ] Rounds played/won tracked
- [ ] Best/worst scores tracked
- [ ] Win streaks tracked
- [ ] Knockouts tracked
2. **Leaderboards**
- [ ] Leaderboard by wins
- [ ] Leaderboard by win rate
- [ ] Leaderboard by average score
- [ ] Minimum games requirement
- [ ] Pagination working
- [ ] Materialized view refreshes
3. **Background Worker**
- [ ] Queue processing works
- [ ] Failed jobs retried
- [ ] Leaderboard auto-refreshes
- [ ] Old entries cleaned up
4. **Achievements**
- [ ] Achievement definitions in DB
- [ ] Achievements awarded correctly
- [ ] Achievement progress tracked
- [ ] Achievement UI displays
5. **API**
- [ ] GET /leaderboard works
- [ ] GET /players/{id} works
- [ ] GET /me works
- [ ] GET /achievements works
6. **UI**
- [ ] Leaderboard displays
- [ ] Tabs switch metrics
- [ ] Player profiles show stats
- [ ] Achievements display
---
## Implementation Order
1. Create database migrations
2. Implement stats processing logic
3. Add stats queue integration
4. Set up background worker
5. Implement leaderboard queries
6. Create API endpoints
7. Build leaderboard UI
8. Add achievements system
9. Test full flow
---
## Notes
- Materialized views are great for leaderboards but need periodic refresh
- Consider caching hot leaderboard data in Redis
- Achievement checking should be efficient (batch checks)
- Stats processing is async - don't block game completion
- Consider separate "vs humans only" stats in future

View File

@@ -0,0 +1,976 @@
# V2_06: Game Replay & Export System
> **Scope**: Replay viewer, game export/import, share links, spectator mode
> **Dependencies**: V2_01 (Event Sourcing), V2_02 (Persistence), V2_03 (User Accounts)
> **Complexity**: Medium
---
## Overview
The replay system leverages our event-sourced architecture to provide:
- **Replay Viewer**: Step through any completed game move-by-move
- **Export/Import**: Download games as JSON, share with others
- **Share Links**: Generate public links to specific games
- **Spectator Mode**: Watch live games in progress
---
## 1. Database Schema
### Shared Games Table
```sql
-- Public share links for completed games
CREATE TABLE shared_games (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
game_id UUID NOT NULL REFERENCES games(id),
share_code VARCHAR(12) UNIQUE NOT NULL, -- Short shareable code
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- NULL = never expires
view_count INTEGER DEFAULT 0,
is_public BOOLEAN DEFAULT true,
title VARCHAR(100), -- Optional custom title
description TEXT -- Optional description
);
CREATE INDEX idx_shared_games_code ON shared_games(share_code);
CREATE INDEX idx_shared_games_game ON shared_games(game_id);
-- Track replay views for analytics
CREATE TABLE replay_views (
id SERIAL PRIMARY KEY,
shared_game_id UUID REFERENCES shared_games(id),
viewer_id UUID REFERENCES users(id), -- NULL for anonymous
viewed_at TIMESTAMPTZ DEFAULT NOW(),
ip_hash VARCHAR(64), -- Hashed IP for rate limiting
watch_duration_seconds INTEGER
);
```
---
## 2. Replay Service
### Core Implementation
```python
# server/replay.py
from dataclasses import dataclass
from typing import Optional
import secrets
import json
from server.events import EventStore, GameEvent
from server.game import Game, GameOptions
@dataclass
class ReplayFrame:
"""Single frame in a replay."""
event_index: int
event: GameEvent
game_state: dict # Serialized game state after event
timestamp: float
@dataclass
class GameReplay:
"""Complete replay of a game."""
game_id: str
frames: list[ReplayFrame]
total_duration_seconds: float
player_names: list[str]
final_scores: dict[str, int]
winner: Optional[str]
options: GameOptions
class ReplayService:
def __init__(self, event_store: EventStore, db_pool):
self.event_store = event_store
self.db = db_pool
async def build_replay(self, game_id: str) -> GameReplay:
"""Build complete replay from event store."""
events = await self.event_store.get_events(game_id)
if not events:
raise ValueError(f"No events found for game {game_id}")
frames = []
game = None
start_time = None
for i, event in enumerate(events):
if start_time is None:
start_time = event.timestamp
# Apply event to get state
if event.event_type == "game_started":
game = Game.from_event(event)
else:
game.apply_event(event)
frames.append(ReplayFrame(
event_index=i,
event=event,
game_state=game.to_dict(reveal_all=True),
timestamp=(event.timestamp - start_time).total_seconds()
))
return GameReplay(
game_id=game_id,
frames=frames,
total_duration_seconds=frames[-1].timestamp if frames else 0,
player_names=[p.name for p in game.players],
final_scores={p.name: p.score for p in game.players},
winner=game.winner.name if game.winner else None,
options=game.options
)
async def create_share_link(
self,
game_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
expires_days: Optional[int] = None
) -> str:
"""Generate shareable link for a game."""
share_code = secrets.token_urlsafe(8)[:12] # 12-char code
expires_at = None
if expires_days:
expires_at = f"NOW() + INTERVAL '{expires_days} days'"
async with self.db.acquire() as conn:
await conn.execute("""
INSERT INTO shared_games
(game_id, share_code, created_by, title, expires_at)
VALUES ($1, $2, $3, $4, $5)
""", game_id, share_code, user_id, title, expires_at)
return share_code
async def get_shared_game(self, share_code: str) -> Optional[dict]:
"""Retrieve shared game by code."""
async with self.db.acquire() as conn:
row = await conn.fetchrow("""
SELECT sg.*, g.room_code, g.completed_at
FROM shared_games sg
JOIN games g ON sg.game_id = g.id
WHERE sg.share_code = $1
AND sg.is_public = true
AND (sg.expires_at IS NULL OR sg.expires_at > NOW())
""", share_code)
if row:
# Increment view count
await conn.execute("""
UPDATE shared_games SET view_count = view_count + 1
WHERE share_code = $1
""", share_code)
return dict(row)
return None
async def export_game(self, game_id: str) -> dict:
"""Export game as portable JSON format."""
replay = await self.build_replay(game_id)
return {
"version": "1.0",
"exported_at": datetime.utcnow().isoformat(),
"game": {
"id": replay.game_id,
"players": replay.player_names,
"winner": replay.winner,
"final_scores": replay.final_scores,
"duration_seconds": replay.total_duration_seconds,
"options": asdict(replay.options)
},
"events": [
{
"type": f.event.event_type,
"data": f.event.data,
"timestamp": f.timestamp
}
for f in replay.frames
]
}
async def import_game(self, export_data: dict, user_id: str) -> str:
"""Import a game from exported JSON."""
if export_data.get("version") != "1.0":
raise ValueError("Unsupported export version")
# Generate new game ID for import
new_game_id = str(uuid.uuid4())
# Store events with new game ID
for event_data in export_data["events"]:
event = GameEvent(
game_id=new_game_id,
event_type=event_data["type"],
data=event_data["data"],
timestamp=datetime.fromisoformat(event_data["timestamp"])
)
await self.event_store.append(event)
# Mark as imported game
async with self.db.acquire() as conn:
await conn.execute("""
INSERT INTO games (id, imported_by, imported_at, is_imported)
VALUES ($1, $2, NOW(), true)
""", new_game_id, user_id)
return new_game_id
```
---
## 3. Spectator Mode
### Live Game Watching
```python
# server/spectator.py
from typing import Set
from fastapi import WebSocket
class SpectatorManager:
"""Manage spectators watching live games."""
def __init__(self):
# game_id -> set of spectator websockets
self.spectators: dict[str, Set[WebSocket]] = {}
async def add_spectator(self, game_id: str, ws: WebSocket):
"""Add spectator to game."""
if game_id not in self.spectators:
self.spectators[game_id] = set()
self.spectators[game_id].add(ws)
# Send current game state
game = await self.get_game_state(game_id)
await ws.send_json({
"type": "spectator_joined",
"game": game.to_dict(reveal_all=False),
"spectator_count": len(self.spectators[game_id])
})
async def remove_spectator(self, game_id: str, ws: WebSocket):
"""Remove spectator from game."""
if game_id in self.spectators:
self.spectators[game_id].discard(ws)
if not self.spectators[game_id]:
del self.spectators[game_id]
async def broadcast_to_spectators(self, game_id: str, message: dict):
"""Send update to all spectators of a game."""
if game_id not in self.spectators:
return
dead_connections = set()
for ws in self.spectators[game_id]:
try:
await ws.send_json(message)
except:
dead_connections.add(ws)
# Clean up dead connections
self.spectators[game_id] -= dead_connections
def get_spectator_count(self, game_id: str) -> int:
return len(self.spectators.get(game_id, set()))
# Integration with main game loop
async def handle_game_event(game_id: str, event: GameEvent):
"""Called after each game event to notify spectators."""
await spectator_manager.broadcast_to_spectators(game_id, {
"type": "game_update",
"event": event.to_dict(),
"timestamp": event.timestamp.isoformat()
})
```
---
## 4. API Endpoints
```python
# server/routes/replay.py
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/api/replay", tags=["replay"])
@router.get("/game/{game_id}")
async def get_replay(game_id: str, user: Optional[User] = Depends(get_current_user)):
"""Get full replay for a game."""
# Check if user has permission (played in game or game is public)
if not await can_view_game(user, game_id):
raise HTTPException(403, "Cannot view this game")
replay = await replay_service.build_replay(game_id)
return {
"game_id": replay.game_id,
"frames": [
{
"index": f.event_index,
"event_type": f.event.event_type,
"timestamp": f.timestamp,
"state": f.game_state
}
for f in replay.frames
],
"metadata": {
"players": replay.player_names,
"winner": replay.winner,
"final_scores": replay.final_scores,
"duration": replay.total_duration_seconds
}
}
@router.post("/game/{game_id}/share")
async def create_share_link(
game_id: str,
title: Optional[str] = None,
expires_days: Optional[int] = Query(None, ge=1, le=365),
user: User = Depends(require_auth)
):
"""Create shareable link for a game."""
if not await user_played_in_game(user.id, game_id):
raise HTTPException(403, "Can only share games you played in")
share_code = await replay_service.create_share_link(
game_id, user.id, title, expires_days
)
return {
"share_code": share_code,
"share_url": f"/replay/{share_code}",
"expires_days": expires_days
}
@router.get("/shared/{share_code}")
async def get_shared_replay(share_code: str):
"""Get replay via share code (public endpoint)."""
shared = await replay_service.get_shared_game(share_code)
if not shared:
raise HTTPException(404, "Shared game not found or expired")
replay = await replay_service.build_replay(shared["game_id"])
return {
"title": shared.get("title"),
"view_count": shared["view_count"],
"replay": replay
}
@router.get("/game/{game_id}/export")
async def export_game(game_id: str, user: User = Depends(require_auth)):
"""Export game as downloadable JSON."""
if not await can_view_game(user, game_id):
raise HTTPException(403, "Cannot export this game")
export_data = await replay_service.export_game(game_id)
return JSONResponse(
content=export_data,
headers={
"Content-Disposition": f'attachment; filename="golf-game-{game_id[:8]}.json"'
}
)
@router.post("/import")
async def import_game(
export_data: dict,
user: User = Depends(require_auth)
):
"""Import a game from JSON export."""
try:
new_game_id = await replay_service.import_game(export_data, user.id)
return {"game_id": new_game_id, "message": "Game imported successfully"}
except ValueError as e:
raise HTTPException(400, str(e))
# Spectator endpoints
@router.websocket("/spectate/{room_code}")
async def spectate_game(websocket: WebSocket, room_code: str):
"""WebSocket endpoint for spectating live games."""
await websocket.accept()
game_id = await get_game_id_by_room(room_code)
if not game_id:
await websocket.close(code=4004, reason="Game not found")
return
try:
await spectator_manager.add_spectator(game_id, websocket)
while True:
# Keep connection alive, handle pings
data = await websocket.receive_text()
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
pass
finally:
await spectator_manager.remove_spectator(game_id, websocket)
```
---
## 5. Frontend: Replay Viewer
### Replay Component
```javascript
// client/replay.js
class ReplayViewer {
constructor(container) {
this.container = container;
this.frames = [];
this.currentFrame = 0;
this.isPlaying = false;
this.playbackSpeed = 1.0;
this.playInterval = null;
}
async loadReplay(gameId) {
const response = await fetch(`/api/replay/game/${gameId}`);
const data = await response.json();
this.frames = data.frames;
this.metadata = data.metadata;
this.currentFrame = 0;
this.render();
this.renderControls();
}
async loadSharedReplay(shareCode) {
const response = await fetch(`/api/replay/shared/${shareCode}`);
if (!response.ok) {
this.showError("Replay not found or expired");
return;
}
const data = await response.json();
this.frames = data.replay.frames;
this.metadata = data.replay;
this.title = data.title;
this.currentFrame = 0;
this.render();
}
render() {
if (!this.frames.length) return;
const frame = this.frames[this.currentFrame];
const state = frame.state;
// Render game board at this state
this.renderBoard(state);
// Show event description
this.renderEventInfo(frame);
// Update timeline
this.updateTimeline();
}
renderBoard(state) {
// Similar to main game rendering but read-only
const boardHtml = `
<div class="replay-board">
${state.players.map(p => this.renderPlayerHand(p)).join('')}
<div class="replay-center">
<div class="deck-area">
<div class="card deck-card">
<span class="card-back"></span>
</div>
${state.discard_top ? this.renderCard(state.discard_top) : ''}
</div>
</div>
</div>
`;
this.container.querySelector('.replay-board-container').innerHTML = boardHtml;
}
renderEventInfo(frame) {
const descriptions = {
'game_started': 'Game started',
'card_drawn': `${frame.event.data.player} drew a card`,
'card_discarded': `${frame.event.data.player} discarded`,
'card_swapped': `${frame.event.data.player} swapped a card`,
'turn_ended': `${frame.event.data.player}'s turn ended`,
'round_ended': 'Round ended',
'game_ended': `Game over! ${this.metadata.winner} wins!`
};
const desc = descriptions[frame.event_type] || frame.event_type;
this.container.querySelector('.event-description').textContent = desc;
}
renderControls() {
const controls = `
<div class="replay-controls">
<button class="btn-start" title="Go to start">⏮</button>
<button class="btn-prev" title="Previous">⏪</button>
<button class="btn-play" title="Play/Pause">▶</button>
<button class="btn-next" title="Next">⏩</button>
<button class="btn-end" title="Go to end">⏭</button>
<div class="timeline">
<input type="range" min="0" max="${this.frames.length - 1}"
value="0" class="timeline-slider">
<span class="frame-counter">1 / ${this.frames.length}</span>
</div>
<div class="speed-control">
<label>Speed:</label>
<select class="speed-select">
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
<option value="4">4x</option>
</select>
</div>
</div>
`;
this.container.querySelector('.controls-container').innerHTML = controls;
this.bindControlEvents();
}
bindControlEvents() {
this.container.querySelector('.btn-start').onclick = () => this.goToFrame(0);
this.container.querySelector('.btn-end').onclick = () => this.goToFrame(this.frames.length - 1);
this.container.querySelector('.btn-prev').onclick = () => this.prevFrame();
this.container.querySelector('.btn-next').onclick = () => this.nextFrame();
this.container.querySelector('.btn-play').onclick = () => this.togglePlay();
this.container.querySelector('.timeline-slider').oninput = (e) => {
this.goToFrame(parseInt(e.target.value));
};
this.container.querySelector('.speed-select').onchange = (e) => {
this.playbackSpeed = parseFloat(e.target.value);
if (this.isPlaying) {
this.stopPlayback();
this.startPlayback();
}
};
}
goToFrame(index) {
this.currentFrame = Math.max(0, Math.min(index, this.frames.length - 1));
this.render();
}
nextFrame() {
if (this.currentFrame < this.frames.length - 1) {
this.currentFrame++;
this.render();
} else if (this.isPlaying) {
this.togglePlay(); // Stop at end
}
}
prevFrame() {
if (this.currentFrame > 0) {
this.currentFrame--;
this.render();
}
}
togglePlay() {
this.isPlaying = !this.isPlaying;
const btn = this.container.querySelector('.btn-play');
if (this.isPlaying) {
btn.textContent = '⏸';
this.startPlayback();
} else {
btn.textContent = '▶';
this.stopPlayback();
}
}
startPlayback() {
const baseInterval = 1000; // 1 second between frames
this.playInterval = setInterval(() => {
this.nextFrame();
}, baseInterval / this.playbackSpeed);
}
stopPlayback() {
if (this.playInterval) {
clearInterval(this.playInterval);
this.playInterval = null;
}
}
updateTimeline() {
const slider = this.container.querySelector('.timeline-slider');
const counter = this.container.querySelector('.frame-counter');
if (slider) slider.value = this.currentFrame;
if (counter) counter.textContent = `${this.currentFrame + 1} / ${this.frames.length}`;
}
}
```
### Replay Page HTML
```html
<!-- client/replay.html or section in index.html -->
<div id="replay-view" class="view hidden">
<header class="replay-header">
<h2 class="replay-title">Game Replay</h2>
<div class="replay-meta">
<span class="player-names"></span>
<span class="game-duration"></span>
</div>
</header>
<div class="replay-board-container">
<!-- Board renders here -->
</div>
<div class="event-description"></div>
<div class="controls-container">
<!-- Controls render here -->
</div>
<div class="replay-actions">
<button class="btn-share">Share Replay</button>
<button class="btn-export">Export JSON</button>
<button class="btn-back">Back to Menu</button>
</div>
</div>
```
### Replay Styles
```css
/* client/style.css additions */
.replay-controls {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--surface-color);
border-radius: 8px;
flex-wrap: wrap;
justify-content: center;
}
.replay-controls button {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: var(--primary-color);
color: white;
cursor: pointer;
font-size: 1.2rem;
}
.replay-controls button:hover {
background: var(--primary-dark);
}
.timeline {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.timeline-slider {
flex: 1;
height: 8px;
-webkit-appearance: none;
background: var(--border-color);
border-radius: 4px;
}
.timeline-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
}
.frame-counter {
font-family: monospace;
min-width: 80px;
text-align: right;
}
.event-description {
text-align: center;
padding: 1rem;
font-size: 1.1rem;
color: var(--text-secondary);
min-height: 3rem;
}
.speed-control {
display: flex;
align-items: center;
gap: 0.5rem;
}
.speed-select {
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
/* Spectator badge */
.spectator-count {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0,0,0,0.7);
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.spectator-count::before {
content: '👁';
}
```
---
## 6. Share Dialog
```javascript
// Share modal component
class ShareDialog {
constructor(gameId) {
this.gameId = gameId;
}
async show() {
const modal = document.createElement('div');
modal.className = 'modal share-modal';
modal.innerHTML = `
<div class="modal-content">
<h3>Share This Game</h3>
<div class="share-options">
<label>
<span>Title (optional):</span>
<input type="text" id="share-title" placeholder="Epic comeback win!">
</label>
<label>
<span>Expires in:</span>
<select id="share-expiry">
<option value="">Never</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</label>
</div>
<div class="share-result hidden">
<p>Share this link:</p>
<div class="share-link-container">
<input type="text" id="share-link" readonly>
<button class="btn-copy">Copy</button>
</div>
</div>
<div class="modal-actions">
<button class="btn-generate">Generate Link</button>
<button class="btn-cancel">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
this.bindEvents(modal);
}
async generateLink(modal) {
const title = modal.querySelector('#share-title').value || null;
const expiry = modal.querySelector('#share-expiry').value || null;
const response = await fetch(`/api/replay/game/${this.gameId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
expires_days: expiry ? parseInt(expiry) : null
})
});
const data = await response.json();
const fullUrl = `${window.location.origin}${data.share_url}`;
modal.querySelector('#share-link').value = fullUrl;
modal.querySelector('.share-result').classList.remove('hidden');
modal.querySelector('.btn-generate').classList.add('hidden');
}
}
```
---
## 7. Integration Points
### Game End Integration
```python
# In main.py after game ends
async def on_game_end(game: Game):
# Store final game state
await event_store.append(GameEvent(
game_id=game.id,
event_type="game_ended",
data={
"winner": game.winner.id,
"final_scores": {p.id: p.score for p in game.players},
"duration": game.duration_seconds
}
))
# Notify spectators
await spectator_manager.broadcast_to_spectators(game.id, {
"type": "game_ended",
"winner": game.winner.name,
"final_scores": {p.name: p.score for p in game.players}
})
```
### Navigation Links
```javascript
// Add to game history/profile
function renderGameHistory(games) {
return games.map(game => `
<div class="history-item">
<span class="game-date">${formatDate(game.played_at)}</span>
<span class="game-result">${game.won ? 'Won' : 'Lost'}</span>
<span class="game-score">${game.score} pts</span>
<a href="/replay/${game.id}" class="btn-replay">Watch Replay</a>
</div>
`).join('');
}
```
---
## 8. Validation Tests
```python
# tests/test_replay.py
async def test_build_replay():
"""Verify replay correctly reconstructs game states."""
# Create game with known moves
game_id = await create_test_game()
replay = await replay_service.build_replay(game_id)
assert len(replay.frames) > 0
assert replay.game_id == game_id
assert replay.winner is not None
# Verify each frame has valid state
for frame in replay.frames:
assert frame.game_state is not None
assert 'players' in frame.game_state
async def test_share_link_creation():
"""Test creating and accessing share links."""
game_id = await create_completed_game()
user_id = "test-user"
share_code = await replay_service.create_share_link(game_id, user_id)
assert len(share_code) == 12
# Retrieve via share code
shared = await replay_service.get_shared_game(share_code)
assert shared is not None
assert shared["game_id"] == game_id
async def test_share_link_expiry():
"""Verify expired links return None."""
game_id = await create_completed_game()
# Create link that expires in -1 days (already expired)
share_code = await create_expired_share(game_id)
shared = await replay_service.get_shared_game(share_code)
assert shared is None
async def test_export_import_roundtrip():
"""Test game can be exported and reimported."""
original_game_id = await create_completed_game()
export_data = await replay_service.export_game(original_game_id)
assert export_data["version"] == "1.0"
assert len(export_data["events"]) > 0
# Import as new game
new_game_id = await replay_service.import_game(export_data, "importer-user")
# Verify imported game matches
original_replay = await replay_service.build_replay(original_game_id)
imported_replay = await replay_service.build_replay(new_game_id)
assert len(original_replay.frames) == len(imported_replay.frames)
assert original_replay.final_scores == imported_replay.final_scores
async def test_spectator_connection():
"""Test spectator can join and receive updates."""
game_id = await create_active_game()
async with websocket_client(f"/api/replay/spectate/{game_id}") as ws:
# Should receive initial state
msg = await ws.receive_json()
assert msg["type"] == "spectator_joined"
assert "game" in msg
# Simulate game event
await trigger_game_event(game_id)
# Should receive update
update = await ws.receive_json()
assert update["type"] == "game_update"
```
---
## 9. Security Considerations
1. **Access Control**: Users can only view replays of games they played in, unless shared
2. **Rate Limiting**: Limit share link creation to prevent abuse
3. **Expired Links**: Clean up expired share links via background job
4. **Import Validation**: Validate imported JSON structure to prevent injection
5. **Spectator Limits**: Cap spectators per game to prevent resource exhaustion
---
## Summary
This document provides a complete replay and export system that:
- Leverages event sourcing for perfect game reconstruction
- Supports shareable links with optional expiry
- Enables live spectating of games in progress
- Allows game export/import for portability
- Includes frontend replay viewer with playback controls

999
docs/v2/V2_07_PRODUCTION.md Normal file
View File

@@ -0,0 +1,999 @@
# V2_07: Production Deployment & Operations
> **Scope**: Docker, deployment, health checks, monitoring, security, rate limiting
> **Dependencies**: All other V2 documents
> **Complexity**: High (DevOps/Infrastructure)
---
## Overview
Production readiness requires:
- **Containerization**: Docker images for consistent deployment
- **Health Checks**: Liveness and readiness probes
- **Monitoring**: Metrics, logging, error tracking
- **Security**: HTTPS, headers, secrets management
- **Rate Limiting**: API protection from abuse (Phase 1 priority)
- **Graceful Operations**: Zero-downtime deploys, proper shutdown
---
## 1. Docker Configuration
### Application Dockerfile
```dockerfile
# Dockerfile
FROM python:3.11-slim as base
# Set environment
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY server/ ./server/
COPY client/ ./client/
# Create non-root user
RUN useradd --create-home --shell /bin/bash appuser \
&& chown -R appuser:appuser /app
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
### Production Docker Compose
```yaml
# docker-compose.prod.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
environment:
- DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golfgame
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY=${SECRET_KEY}
- RESEND_API_KEY=${RESEND_API_KEY}
- SENTRY_DSN=${SENTRY_DSN}
- ENVIRONMENT=production
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
deploy:
replicas: 2
restart_policy:
condition: on-failure
max_attempts: 3
resources:
limits:
memory: 512M
reservations:
memory: 256M
networks:
- internal
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.golf.rule=Host(`golf.example.com`)"
- "traefik.http.routers.golf.tls=true"
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
worker:
build:
context: .
dockerfile: Dockerfile
command: python -m arq server.worker.WorkerSettings
environment:
- DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golfgame
- REDIS_URL=redis://redis:6379/0
depends_on:
- postgres
- redis
deploy:
replicas: 1
resources:
limits:
memory: 256M
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: golfgame
POSTGRES_USER: golf
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U golf -d golfgame"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
traefik:
image: traefik:v2.10
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--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
volumes:
postgres_data:
redis_data:
letsencrypt:
networks:
internal:
web:
external: true
```
---
## 2. Health Checks & Readiness
### Health Endpoint Implementation
```python
# server/health.py
from fastapi import APIRouter, Response
from datetime import datetime
import asyncpg
import redis.asyncio as redis
router = APIRouter(tags=["health"])
@router.get("/health")
async def health_check():
"""Basic liveness check - is the app running?"""
return {"status": "ok", "timestamp": datetime.utcnow().isoformat()}
@router.get("/ready")
async def readiness_check(
db: asyncpg.Pool = Depends(get_db_pool),
redis_client: redis.Redis = Depends(get_redis)
):
"""Readiness check - can the app handle requests?"""
checks = {}
overall_healthy = True
# Check database
try:
async with db.acquire() as conn:
await conn.fetchval("SELECT 1")
checks["database"] = {"status": "ok"}
except Exception as e:
checks["database"] = {"status": "error", "message": str(e)}
overall_healthy = False
# Check Redis
try:
await redis_client.ping()
checks["redis"] = {"status": "ok"}
except Exception as e:
checks["redis"] = {"status": "error", "message": str(e)}
overall_healthy = False
status_code = 200 if overall_healthy else 503
return Response(
content=json.dumps({
"status": "ok" if overall_healthy else "degraded",
"checks": checks,
"timestamp": datetime.utcnow().isoformat()
}),
status_code=status_code,
media_type="application/json"
)
@router.get("/metrics")
async def metrics(
db: asyncpg.Pool = Depends(get_db_pool),
redis_client: redis.Redis = Depends(get_redis)
):
"""Expose application metrics for monitoring."""
async with db.acquire() as conn:
active_games = await conn.fetchval(
"SELECT COUNT(*) FROM games WHERE completed_at IS NULL"
)
total_users = await conn.fetchval("SELECT COUNT(*) FROM users")
games_today = await conn.fetchval(
"SELECT COUNT(*) FROM games WHERE created_at > NOW() - INTERVAL '1 day'"
)
connected_players = await redis_client.scard("connected_players")
return {
"active_games": active_games,
"total_users": total_users,
"games_today": games_today,
"connected_players": connected_players,
"timestamp": datetime.utcnow().isoformat()
}
```
---
## 3. Rate Limiting (Phase 1 Priority)
Rate limiting is a Phase 1 priority for security. Implement early to prevent abuse.
### Rate Limiter Implementation
```python
# server/ratelimit.py
from fastapi import Request, HTTPException
from typing import Optional
import redis.asyncio as redis
import time
import hashlib
class RateLimiter:
"""Token bucket rate limiter using Redis."""
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
async def is_allowed(
self,
key: str,
limit: int,
window_seconds: int
) -> tuple[bool, dict]:
"""Check if request is allowed under rate limit.
Returns (allowed, info) where info contains:
- remaining: requests remaining in window
- reset: seconds until window resets
- limit: the limit that was applied
"""
now = int(time.time())
window_key = f"ratelimit:{key}:{now // window_seconds}"
async with self.redis.pipeline(transaction=True) as pipe:
pipe.incr(window_key)
pipe.expire(window_key, window_seconds)
results = await pipe.execute()
current_count = results[0]
remaining = max(0, limit - current_count)
reset = window_seconds - (now % window_seconds)
info = {
"remaining": remaining,
"reset": reset,
"limit": limit
}
return current_count <= limit, info
def get_client_key(self, request: Request, user_id: Optional[str] = None) -> str:
"""Generate rate limit key for client."""
if user_id:
return f"user:{user_id}"
# For anonymous users, use IP hash
client_ip = request.client.host
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
client_ip = forwarded.split(",")[0].strip()
# Hash IP for privacy
return f"ip:{hashlib.sha256(client_ip.encode()).hexdigest()[:16]}"
# Rate limit configurations per endpoint type
RATE_LIMITS = {
"api_general": (100, 60), # 100 requests per minute
"api_auth": (10, 60), # 10 auth attempts per minute
"api_create_room": (5, 60), # 5 room creations per minute
"websocket_connect": (10, 60), # 10 WS connections per minute
"email_send": (3, 300), # 3 emails per 5 minutes
}
```
### Rate Limit Middleware
```python
# server/middleware.py
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, rate_limiter: RateLimiter):
super().__init__(app)
self.limiter = rate_limiter
async def dispatch(self, request: Request, call_next):
# Determine rate limit tier based on path
path = request.url.path
if path.startswith("/api/auth"):
limit, window = RATE_LIMITS["api_auth"]
elif path == "/api/rooms":
limit, window = RATE_LIMITS["api_create_room"]
elif path.startswith("/api"):
limit, window = RATE_LIMITS["api_general"]
else:
# No rate limiting for static files
return await call_next(request)
# Get user ID if authenticated
user_id = getattr(request.state, "user_id", None)
client_key = self.limiter.get_client_key(request, user_id)
allowed, info = await self.limiter.is_allowed(
f"{path}:{client_key}", limit, window
)
# Add rate limit headers to response
response = await call_next(request) if allowed else JSONResponse(
status_code=429,
content={
"error": "Rate limit exceeded",
"retry_after": info["reset"]
}
)
response.headers["X-RateLimit-Limit"] = str(info["limit"])
response.headers["X-RateLimit-Remaining"] = str(info["remaining"])
response.headers["X-RateLimit-Reset"] = str(info["reset"])
if not allowed:
response.headers["Retry-After"] = str(info["reset"])
return response
```
### WebSocket Rate Limiting
```python
# In server/main.py
async def websocket_endpoint(websocket: WebSocket):
client_key = rate_limiter.get_client_key(websocket)
allowed, info = await rate_limiter.is_allowed(
f"ws_connect:{client_key}",
*RATE_LIMITS["websocket_connect"]
)
if not allowed:
await websocket.close(code=1008, reason="Rate limit exceeded")
return
# Also rate limit messages within the connection
message_limiter = ConnectionMessageLimiter(
max_messages=30,
window_seconds=10
)
await websocket.accept()
try:
while True:
data = await websocket.receive_text()
if not message_limiter.check():
await websocket.send_json({
"type": "error",
"message": "Slow down! Too many messages."
})
continue
await handle_message(websocket, data)
except WebSocketDisconnect:
pass
```
---
## 4. Security Headers & HTTPS
### Security Middleware
```python
# server/security.py
from starlette.middleware.base import BaseHTTPMiddleware
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
# Security headers
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
# Content Security Policy
csp = "; ".join([
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'", # For inline styles
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self' wss://*.example.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'"
])
response.headers["Content-Security-Policy"] = csp
# HSTS (only in production)
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains; preload"
)
return response
```
### CORS Configuration
```python
# server/main.py
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://golf.example.com",
"https://www.golf.example.com",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["*"],
)
```
---
## 5. Error Tracking with Sentry
### Sentry Integration
```python
# server/main.py
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from sentry_sdk.integrations.asyncpg import AsyncPGIntegration
if os.getenv("SENTRY_DSN"):
sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN"),
environment=os.getenv("ENVIRONMENT", "development"),
traces_sample_rate=0.1, # 10% of transactions for performance
profiles_sample_rate=0.1,
integrations=[
FastApiIntegration(transaction_style="endpoint"),
RedisIntegration(),
AsyncPGIntegration(),
],
# Filter out sensitive data
before_send=filter_sensitive_data,
)
def filter_sensitive_data(event, hint):
"""Remove sensitive data before sending to Sentry."""
if "request" in event:
headers = event["request"].get("headers", {})
# Remove auth headers
headers.pop("authorization", None)
headers.pop("cookie", None)
return event
```
### Custom Error Handler
```python
# server/errors.py
from fastapi import Request
from fastapi.responses import JSONResponse
import sentry_sdk
import traceback
async def global_exception_handler(request: Request, exc: Exception):
"""Handle all unhandled exceptions."""
# Log to Sentry
sentry_sdk.capture_exception(exc)
# Log locally
logger.error(f"Unhandled exception: {exc}", exc_info=True)
# Return generic error to client
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"request_id": request.state.request_id
}
)
# Register handler
app.add_exception_handler(Exception, global_exception_handler)
```
---
## 6. Structured Logging
### Logging Configuration
```python
# server/logging_config.py
import logging
import json
from datetime import datetime
class JSONFormatter(logging.Formatter):
"""Format logs as JSON for aggregation."""
def format(self, record):
log_data = {
"timestamp": datetime.utcnow().isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
# Add extra fields
if hasattr(record, "request_id"):
log_data["request_id"] = record.request_id
if hasattr(record, "user_id"):
log_data["user_id"] = record.user_id
if hasattr(record, "game_id"):
log_data["game_id"] = record.game_id
# Add exception info
if record.exc_info:
log_data["exception"] = self.formatException(record.exc_info)
return json.dumps(log_data)
def setup_logging():
"""Configure application logging."""
handler = logging.StreamHandler()
if os.getenv("ENVIRONMENT") == "production":
handler.setFormatter(JSONFormatter())
else:
handler.setFormatter(logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
))
logging.root.handlers = [handler]
logging.root.setLevel(logging.INFO)
# Reduce noise from libraries
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("websockets").setLevel(logging.WARNING)
```
### Request ID Middleware
```python
# server/middleware.py
import uuid
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
```
---
## 7. Graceful Shutdown
### Shutdown Handler
```python
# server/main.py
import signal
import asyncio
shutdown_event = asyncio.Event()
@app.on_event("startup")
async def startup():
# Register signal handlers
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, lambda: asyncio.create_task(shutdown()))
@app.on_event("shutdown")
async def shutdown():
logger.info("Shutdown initiated...")
# Stop accepting new connections
shutdown_event.set()
# Save all active games to Redis
await save_all_active_games()
# Close WebSocket connections gracefully
for ws in list(active_connections):
try:
await ws.close(code=1001, reason="Server shutting down")
except:
pass
# Wait for in-flight requests (max 30 seconds)
await asyncio.sleep(5)
# Close database pool
await db_pool.close()
# Close Redis connections
await redis_client.close()
logger.info("Shutdown complete")
async def save_all_active_games():
"""Persist all active games before shutdown."""
for game_id, game in active_games.items():
try:
await state_cache.save_game(game)
logger.info(f"Saved game {game_id}")
except Exception as e:
logger.error(f"Failed to save game {game_id}: {e}")
```
---
## 8. Secrets Management
### Environment Configuration
```python
# server/config.py
from pydantic import BaseSettings, PostgresDsn, RedisDsn
class Settings(BaseSettings):
# Database
database_url: PostgresDsn
# Redis
redis_url: RedisDsn
# Security
secret_key: str
jwt_algorithm: str = "HS256"
jwt_expiry_hours: int = 24
# Email
resend_api_key: str
email_from: str = "Golf Game <noreply@golf.example.com>"
# Monitoring
sentry_dsn: str = ""
environment: str = "development"
# Rate limiting
rate_limit_enabled: bool = True
class Config:
env_file = ".env"
case_sensitive = False
settings = Settings()
```
### Production Secrets (Example for Docker Swarm)
```yaml
# docker-compose.prod.yml
secrets:
db_password:
external: true
secret_key:
external: true
resend_api_key:
external: true
services:
app:
secrets:
- db_password
- secret_key
- resend_api_key
environment:
- DATABASE_URL=postgresql://golf@postgres:5432/golfgame?password_file=/run/secrets/db_password
```
---
## 9. Database Migrations
### Alembic Configuration
```ini
# alembic.ini
[alembic]
script_location = migrations
sqlalchemy.url = env://DATABASE_URL
[logging]
level = INFO
```
### Migration Script Template
```python
# migrations/versions/001_initial.py
"""Initial schema
Revision ID: 001
Create Date: 2024-01-01
"""
from alembic import op
import sqlalchemy as sa
revision = '001'
down_revision = None
def upgrade():
# Users table
op.create_table(
'users',
sa.Column('id', sa.UUID(), primary_key=True),
sa.Column('username', sa.String(50), unique=True, nullable=False),
sa.Column('email', sa.String(255), unique=True, nullable=False),
sa.Column('password_hash', sa.String(255), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('is_admin', sa.Boolean(), default=False),
)
# Games table
op.create_table(
'games',
sa.Column('id', sa.UUID(), primary_key=True),
sa.Column('room_code', sa.String(10), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('completed_at', sa.DateTime(timezone=True)),
)
# Events table
op.create_table(
'events',
sa.Column('id', sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column('game_id', sa.UUID(), sa.ForeignKey('games.id'), nullable=False),
sa.Column('event_type', sa.String(50), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# Indexes
op.create_index('idx_events_game_id', 'events', ['game_id'])
op.create_index('idx_users_email', 'users', ['email'])
op.create_index('idx_users_username', 'users', ['username'])
def downgrade():
op.drop_table('events')
op.drop_table('games')
op.drop_table('users')
```
### Migration Commands
```bash
# Create new migration
alembic revision --autogenerate -m "Add user sessions"
# Run migrations
alembic upgrade head
# Rollback one version
alembic downgrade -1
# Show current version
alembic current
```
---
## 10. Deployment Checklist
### Pre-deployment
- [ ] All environment variables set
- [ ] Database migrations applied
- [ ] Secrets configured in secret manager
- [ ] SSL certificates provisioned
- [ ] Rate limiting configured and tested
- [ ] Error tracking (Sentry) configured
- [ ] Logging aggregation set up
- [ ] Health check endpoints verified
- [ ] Backup strategy implemented
### Deployment
- [ ] Run database migrations
- [ ] Deploy new containers with rolling update
- [ ] Verify health checks pass
- [ ] Monitor error rates in Sentry
- [ ] Check application logs
- [ ] Verify WebSocket connections work
- [ ] Test critical user flows
### Post-deployment
- [ ] Monitor performance metrics
- [ ] Check database connection pool usage
- [ ] Verify Redis memory usage
- [ ] Review error logs
- [ ] Test graceful shutdown/restart
---
## 11. Monitoring Dashboard (Grafana)
### Key Metrics to Track
```yaml
# Example Prometheus metrics
metrics:
# Application
- http_requests_total
- http_request_duration_seconds
- websocket_connections_active
- games_active
- games_completed_total
# Infrastructure
- container_cpu_usage_seconds_total
- container_memory_usage_bytes
- pg_stat_activity_count
- redis_connected_clients
- redis_used_memory_bytes
# Business
- users_registered_total
- games_played_today
- average_game_duration_seconds
```
### Alert Rules
```yaml
# alertmanager rules
groups:
- name: golf-alerts
rules:
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.1
for: 5m
labels:
severity: critical
annotations:
summary: "High error rate detected"
- alert: DatabaseConnectionExhausted
expr: pg_stat_activity_count > 90
for: 2m
labels:
severity: warning
annotations:
summary: "Database connections near limit"
- alert: HighMemoryUsage
expr: container_memory_usage_bytes / container_spec_memory_limit_bytes > 0.9
for: 5m
labels:
severity: warning
annotations:
summary: "Container memory usage above 90%"
```
---
## 12. Backup Strategy
### Database Backups
```bash
#!/bin/bash
# backup.sh - Daily database backup
BACKUP_DIR=/backups
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/golfgame_${DATE}.sql.gz"
# Backup with pg_dump
pg_dump -h postgres -U golf golfgame | gzip > "$BACKUP_FILE"
# Upload to S3/B2/etc
aws s3 cp "$BACKUP_FILE" s3://golf-backups/
# Cleanup old local backups (keep 7 days)
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +7 -delete
# Cleanup old S3 backups (keep 30 days) via lifecycle policy
```
### Redis Persistence
```conf
# redis.conf
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
```
---
## Summary
This document covers all production deployment concerns:
1. **Docker**: Multi-stage builds, health checks, resource limits
2. **Rate Limiting**: Token bucket algorithm, per-endpoint limits (Phase 1 priority)
3. **Security**: Headers, CORS, CSP, HSTS
4. **Monitoring**: Sentry, structured logging, Prometheus metrics
5. **Operations**: Graceful shutdown, migrations, backups
6. **Deployment**: Checklist, rolling updates, health verification
Rate limiting is implemented in Phase 1 as a security priority to protect against abuse before public launch.

View File

@@ -0,0 +1,317 @@
# V2-08: Unified Game Logging
## Overview
This document covers the unified PostgreSQL game logging system that replaces
the legacy SQLite `game_log.py`. All game events and AI decisions are logged
to PostgreSQL for analysis, replay, and cloud deployment.
**Dependencies:** V2-01 (Event Sourcing), V2-02 (Persistence)
**Dependents:** Game Analyzer, Stats Dashboard
---
## Goals
1. Consolidate all game data in PostgreSQL (drop SQLite dependency)
2. Preserve AI decision context for analysis
3. Maintain compatibility with existing services (Stats, Replay, Recovery)
4. Enable efficient queries for game analysis
5. Support cloud deployment without local file dependencies
---
## Architecture
```
┌─────────────────┐
│ Game Server │
│ (main.py) │
└────────┬────────┘
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ GameLogger │ │ EventStore │ │ StatsService │
│ Service │ │ (events) │ │ ReplayService │
└───────┬───────┘ └───────────────┘ └───────────────┘
┌───────────────────────────────────────────────────┐
│ PostgreSQL │
│ ┌─────────┐ ┌───────────┐ ┌──────────────┐ │
│ │ games_v2│ │ events │ │ moves │ │
│ │ metadata│ │ (actions) │ │ (AI context) │ │
│ └─────────┘ └───────────┘ └──────────────┘ │
└───────────────────────────────────────────────────┘
```
---
## Database Schema
### moves Table (New)
```sql
CREATE TABLE IF NOT EXISTS moves (
id BIGSERIAL PRIMARY KEY,
game_id UUID NOT NULL,
sequence_num INT NOT NULL,
timestamp TIMESTAMPTZ DEFAULT NOW(),
player_id VARCHAR(50) NOT NULL,
player_name VARCHAR(100),
is_cpu BOOLEAN DEFAULT FALSE,
-- Action details
action VARCHAR(30) NOT NULL, -- draw_deck, take_discard, swap, discard, flip, etc.
card_rank VARCHAR(5),
card_suit VARCHAR(10),
position INT,
-- AI context (JSONB for flexibility)
hand_state JSONB, -- Player's hand at decision time
discard_top JSONB, -- Top of discard pile
visible_opponents JSONB, -- Face-up cards of opponents
decision_reason TEXT, -- AI reasoning
UNIQUE(game_id, sequence_num)
);
CREATE INDEX IF NOT EXISTS idx_moves_game ON moves(game_id);
CREATE INDEX IF NOT EXISTS idx_moves_action ON moves(action);
CREATE INDEX IF NOT EXISTS idx_moves_is_cpu ON moves(is_cpu);
CREATE INDEX IF NOT EXISTS idx_moves_player ON moves(player_id);
```
### Action Types
| Action | Description |
|--------|-------------|
| `draw_deck` | Player drew from deck |
| `take_discard` | Player took top of discard pile |
| `swap` | Player swapped drawn card with hand card |
| `discard` | Player discarded drawn card |
| `flip` | Player flipped a card after discarding |
| `skip_flip` | Player skipped optional flip (endgame) |
| `flip_as_action` | Player used flip-as-action house rule |
| `knock_early` | Player knocked to end round early |
---
## GameLogger Service
**Location:** `/server/services/game_logger.py`
### API
```python
class GameLogger:
"""Logs game events and moves to PostgreSQL."""
def __init__(self, event_store: EventStore):
"""Initialize with event store instance."""
def log_game_start(
self,
room_code: str,
num_players: int,
options: GameOptions,
) -> str:
"""Log game start, returns game_id."""
def log_move(
self,
game_id: str,
player: Player,
is_cpu: bool,
action: str,
card: Optional[Card] = None,
position: Optional[int] = None,
game: Optional[Game] = None,
decision_reason: Optional[str] = None,
) -> None:
"""Log a move with AI context."""
def log_game_end(self, game_id: str) -> None:
"""Mark game as ended."""
```
### Usage
```python
# In main.py lifespan
from services.game_logger import GameLogger, set_logger
_event_store = await get_event_store(config.POSTGRES_URL)
_game_logger = GameLogger(_event_store)
set_logger(_game_logger)
# In handlers
from services.game_logger import get_logger
game_logger = get_logger()
if game_logger:
game_logger.log_move(
game_id=room.game_log_id,
player=player,
is_cpu=False,
action="swap",
card=drawn_card,
position=position,
game=room.game,
decision_reason="swapped 5 into position 2",
)
```
---
## Query Patterns
### Find Suspicious Discards
```python
# Using EventStore
blunders = await event_store.find_suspicious_discards(limit=50)
```
```sql
-- Direct SQL
SELECT m.*, g.room_code
FROM moves m
JOIN games_v2 g ON m.game_id = g.id
WHERE m.action = 'discard'
AND m.card_rank IN ('A', '2', 'K')
AND m.is_cpu = TRUE
ORDER BY m.timestamp DESC
LIMIT 50;
```
### Get Player Decisions
```python
moves = await event_store.get_player_decisions(game_id, player_name)
```
```sql
SELECT * FROM moves
WHERE game_id = $1 AND player_name = $2
ORDER BY sequence_num;
```
### Recent Games with Stats
```python
games = await event_store.get_recent_games_with_stats(limit=10)
```
```sql
SELECT g.*, COUNT(m.id) as total_moves
FROM games_v2 g
LEFT JOIN moves m ON g.id = m.game_id
GROUP BY g.id
ORDER BY g.created_at DESC
LIMIT 10;
```
---
## Migration from SQLite
### Removed Files
- `/server/game_log.py` - Replaced by `/server/services/game_logger.py`
- `/server/games.db` - Data now in PostgreSQL
### Updated Files
| File | Changes |
|------|---------|
| `main.py` | Import from `services.game_logger`, init in lifespan |
| `ai.py` | Import from `services.game_logger` |
| `simulate.py` | Removed logging, uses in-memory SimulationStats only |
| `game_analyzer.py` | CLI updated for PostgreSQL, class deprecated |
| `stores/event_store.py` | Added `moves` table and query methods |
### Simulation Mode
Simulations (`simulate.py`) no longer write to the database. They use in-memory
`SimulationStats` for analysis. This keeps simulations fast and avoids flooding
the database with bulk test runs.
For simulation analysis:
```bash
python simulate.py 100 --preset baseline
# Stats printed to console
```
For production game analysis:
```bash
python game_analyzer.py blunders 20
python game_analyzer.py recent 10
```
---
## Acceptance Criteria
1. **PostgreSQL Integration**
- [x] moves table created with proper indexes
- [x] All game actions logged to PostgreSQL via GameLogger
- [x] EventStore has append_move() and query methods
2. **Service Compatibility**
- [x] StatsService still works (uses events table)
- [x] ReplayService still works (uses events table)
- [x] RecoveryService still works (uses events table)
3. **Simulation Mode**
- [x] simulate.py works without PostgreSQL
- [x] In-memory SimulationStats provides analysis
4. **SQLite Removal**
- [x] game_log.py can be deleted
- [x] games.db can be deleted
- [x] No sqlite3 imports in main game code
---
## Implementation Notes
### Async/Sync Bridging
The GameLogger provides sync methods (`log_move`, `log_game_start`) that
internally fire async tasks. This allows existing sync code paths to call
the logger without blocking:
```python
def log_move(self, game_id, ...):
if not game_id:
return
try:
loop = asyncio.get_running_loop()
asyncio.create_task(self.log_move_async(...))
except RuntimeError:
# Not in async context - skip (simulations)
pass
```
### Fire-and-Forget Logging
Move logging uses fire-and-forget async tasks to avoid blocking game logic.
This means:
- Logging failures don't crash the game
- Slight delay between action and database write is acceptable
- No acknowledgment that log succeeded
For critical data, use the events table which is the source of truth.
---
## Notes for Developers
- The `moves` table is denormalized for efficient queries
- The `events` table remains the source of truth for game replay
- GameLogger is None when PostgreSQL is not configured (no logging)
- Always check `if game_logger:` before calling methods
- For quick development testing, use simulate.py without database

522
docs/v2/V2_BUILD_PLAN.md Normal file
View File

@@ -0,0 +1,522 @@
# Golf Card Game - V2 Build Plan
## Vision
Transform the current single-server Golf game into a production-ready, hostable platform with:
- **Event-sourced architecture** for full game replay and audit trails
- **Leaderboards** with player statistics
- **Scalable hosting** options (self-hosted or cloud)
- **Export/playback** for sharing memorable games
---
## Current State (V1)
```
Client (Vanilla JS) ◄──WebSocket──► FastAPI Server ◄──► SQLite
In-memory rooms
(lost on restart)
```
**What works well:**
- Game logic is solid and well-tested
- CPU AI with multiple personalities
- House rules system is flexible
- Real-time multiplayer via WebSockets
**Limitations:**
- Single server, no horizontal scaling
- Game state lost on server restart
- Move logging exists but duplicates state
- No player accounts with persistent stats
---
## V2 Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Clients │
│ (Browser / Future: Mobile) │
└───────────────────────────────┬─────────────────────────────────────┘
│ WebSocket + REST API
┌─────────────────────────────────────────────────────────────────────┐
│ FastAPI Application │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │
│ │ Command │ │ Event │ │ State │ │ Query │ │
│ │ Handler │──► Store │──► Builder │ │ Service │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │
└───────┬───────────────────┬───────────────────┬───────────────┬─────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌─────────────┐ ┌───────────┐
│ Redis │ │ PostgreSQL │ │ PostgreSQL │ │ Postgres │
│ (Live State) │ │ (Events) │ │ (Users) │ │ (Stats) │
│ (Pub/Sub) │ │ │ │ │ │ │
└──────────────┘ └──────────────┘ └─────────────┘ └───────────┘
```
---
## Data Model
### Event Store
All game actions stored as immutable events:
```sql
-- Core event log
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
game_id UUID NOT NULL,
sequence_num INT NOT NULL,
event_type VARCHAR(50) NOT NULL,
event_data JSONB NOT NULL,
player_id VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(game_id, sequence_num)
);
-- Game metadata (denormalized for queries)
CREATE TABLE games (
id UUID PRIMARY KEY,
room_code VARCHAR(10),
status VARCHAR(20) DEFAULT 'active', -- active, completed, abandoned
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
num_players INT,
num_rounds INT,
options JSONB,
winner_id VARCHAR(50),
-- Denormalized for leaderboard queries
player_ids VARCHAR(50)[]
);
CREATE INDEX idx_events_game ON events(game_id, sequence_num);
CREATE INDEX idx_games_status ON games(status, completed_at);
CREATE INDEX idx_games_players ON games USING GIN(player_ids);
```
### Event Types
```python
@dataclass
class GameEvent:
game_id: str
sequence_num: int
event_type: str
player_id: Optional[str]
timestamp: datetime
data: dict
# Lifecycle events
GameCreated(room_code, options, host_id)
PlayerJoined(player_id, player_name, is_cpu, profile_name?)
PlayerLeft(player_id, reason)
GameStarted(deck_seed, player_order)
RoundStarted(round_num)
RoundEnded(scores: dict, winner_id)
GameEnded(final_scores: dict, winner_id)
# Gameplay events
InitialCardsFlipped(player_id, positions: list[int])
CardDrawn(player_id, source: "deck"|"discard", card: Card)
CardSwapped(player_id, position: int, new_card: Card, old_card: Card)
CardDiscarded(player_id, card: Card)
CardFlipped(player_id, position: int, card: Card)
FlipSkipped(player_id)
FlipAsAction(player_id, position: int, card: Card)
```
### User & Stats Schema
```sql
-- User accounts (expand existing auth)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE,
password_hash VARCHAR(255),
role VARCHAR(20) DEFAULT 'player',
created_at TIMESTAMPTZ DEFAULT NOW(),
last_seen_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true,
preferences JSONB DEFAULT '{}'
);
-- Player statistics (materialized from events)
CREATE TABLE player_stats (
user_id UUID PRIMARY KEY REFERENCES users(id),
games_played INT DEFAULT 0,
games_won INT DEFAULT 0,
rounds_played INT DEFAULT 0,
rounds_won INT DEFAULT 0,
total_points INT DEFAULT 0, -- Lower is better
best_round_score INT,
worst_round_score INT,
total_knockouts INT DEFAULT 0, -- Times going out first
total_blunders INT DEFAULT 0, -- From AI analyzer
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Leaderboard views
CREATE VIEW leaderboard_by_wins AS
SELECT
u.username,
s.games_played,
s.games_won,
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
s.rounds_won,
ROUND(s.total_points::numeric / NULLIF(s.rounds_played, 0), 1) as avg_score
FROM player_stats s
JOIN users u ON s.user_id = u.id
WHERE s.games_played >= 25 -- Minimum games for ranking
ORDER BY win_rate DESC, games_won DESC;
CREATE VIEW leaderboard_by_games AS
SELECT
u.username,
s.games_played,
s.games_won,
s.rounds_won
FROM player_stats s
JOIN users u ON s.user_id = u.id
ORDER BY games_played DESC;
```
---
## Components to Build
### Phase 1: Event Infrastructure (Foundation)
| Component | Description | Effort |
|-----------|-------------|--------|
| Event classes | Python dataclasses for all event types | S |
| Event store | PostgreSQL table + write functions | S |
| State rebuilder | Fold events into GameState | M |
| Dual-write migration | Emit events alongside current mutations | M |
| Event validation | Ensure events can recreate identical state | M |
### Phase 2: Persistence & Recovery
| Component | Description | Effort |
|-----------|-------------|--------|
| Redis state cache | Store live game state in Redis | M |
| Pub/sub for multi-server | Redis pub/sub for WebSocket fan-out | M |
| Game recovery | Rebuild in-progress games from events on restart | S |
| Graceful shutdown | Save state before shutdown | S |
### Phase 3: User System & Stats
| Component | Description | Effort |
|-----------|-------------|--------|
| User registration flow | Proper signup/login UI | M |
| Guest-to-user conversion | Play as guest, register to save stats | S |
| Stats aggregation worker | Process events → update player_stats | M |
| Leaderboard API | REST endpoints for leaderboards | S |
| Leaderboard UI | Display in client | M |
### Phase 4: Replay & Export
| Component | Description | Effort |
|-----------|-------------|--------|
| Export API | `GET /api/games/{id}/export` returns event JSON | S |
| Import/load | Load exported game for replay | S |
| Replay UI | Playback controls, scrubbing, speed control | L |
| Share links | `/replay/{game_id}` public URLs | S |
### Phase 5: Production Hardening
| Component | Description | Effort |
|-----------|-------------|--------|
| Rate limiting | Prevent abuse | S |
| Health checks | `/health` with dependency checks | S |
| Metrics | Prometheus metrics for monitoring | M |
| Error tracking | Sentry or similar | S |
| Backup strategy | Automated PostgreSQL backups | S |
---
## Tech Stack
### Recommended Stack
| Layer | Technology | Reasoning |
|-------|------------|-----------|
| **Web framework** | FastAPI (keep) | Already using, async, fast |
| **WebSockets** | Starlette (keep) | Built into FastAPI |
| **Live state cache** | Redis | Fast, pub/sub, TTL, battle-tested |
| **Event store** | PostgreSQL | JSONB, robust, great tooling |
| **User database** | PostgreSQL | Same instance, keep it simple |
| **Background jobs** | `arq` or `rq` | Stats aggregation, cleanup |
| **Containerization** | Docker | Consistent deployment |
| **Orchestration** | Docker Compose (small) / K8s (large) | Start simple |
### Dependencies to Add
```txt
# requirements.txt additions
redis>=5.0.0
asyncpg>=0.29.0 # Async PostgreSQL
sqlalchemy>=2.0.0 # ORM (optional, can use raw SQL)
alembic>=1.13.0 # Migrations
arq>=0.26.0 # Background tasks
pydantic-settings>=2.0 # Config management
```
---
## Hosting Options
### Option A: Single VPS (Simplest, $5-20/mo)
```
┌─────────────────────────────────────┐
│ VPS (2-4GB RAM) │
│ ┌─────────┐ ┌─────────┐ ┌───────┐ │
│ │ FastAPI │ │ Redis │ │Postgres│ │
│ │ :8000 │ │ :6379 │ │ :5432 │ │
│ └─────────┘ └─────────┘ └───────┘ │
│ Docker Compose │
└─────────────────────────────────────┘
Providers: DigitalOcean, Linode, Hetzner, Vultr
Capacity: ~100-500 concurrent users
```
**docker-compose.yml:**
```yaml
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://golf:secret@db:5432/golf
- REDIS_URL=redis://redis:6379
depends_on:
- db
- redis
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: golf
POSTGRES_PASSWORD: secret
POSTGRES_DB: golf
volumes:
- postgres_data:/var/lib/postgresql/data
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./certs:/etc/nginx/certs
volumes:
redis_data:
postgres_data:
```
### Option B: Managed Services ($20-50/mo)
```
┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Fly.io │ │ Upstash Redis │ │ Neon or │
│ App │◄───►│ (Serverless) │ │ Supabase │
│ $5-10/mo │ │ Free-$10/mo │ │ PostgreSQL │
└──────────────┘ └─────────────────┘ │ Free-$25/mo │
└──────────────┘
Alternative compute: Railway, Render, Google Cloud Run
```
**Pros:** Less ops, automatic SSL, easy scaling
**Cons:** Slightly higher latency, vendor lock-in
### Option C: Self-Hosted (Home Server / NAS)
```
┌─────────────────────────────────────┐
│ Home Server / Raspberry Pi 5 │
│ Docker Compose (same as Option A) │
└───────────────────┬─────────────────┘
┌───────────────────▼─────────────────┐
│ Cloudflare Tunnel (free) │
│ • No port forwarding needed │
│ • Free SSL │
│ • DDoS protection │
└─────────────────────────────────────┘
Domain: golf.yourdomain.com
```
### Option D: Kubernetes (Overkill Unless Scaling Big)
Only if you're expecting 5000+ concurrent users or need multi-region.
---
## Migration Strategy
### Step 1: Add Event Emission (Non-Breaking)
Keep current code working, add event logging in parallel:
```python
# In game.py or main.py
def draw_card(self, player_id: str, source: str) -> Optional[Card]:
# Existing logic
card = self._do_draw(player_id, source)
if card:
# NEW: Emit event (doesn't affect gameplay)
self.emit_event(CardDrawn(
player_id=player_id,
source=source,
card=card.to_dict()
))
return card
```
### Step 2: Validate Event Replay
Build a test that:
1. Plays a game normally
2. Captures all events
3. Replays events into fresh state
4. Asserts final state matches
```python
def test_event_replay_matches():
# Play a game, collect events
game, events = play_test_game()
final_state = game.get_state()
# Rebuild from events
rebuilt = GameState()
for event in events:
rebuilt.apply(event)
assert rebuilt == final_state
```
### Step 3: Switch to Event-Sourced
Once validation passes:
1. Commands produce events
2. Events applied to state
3. State derived, not mutated directly
### Step 4: Deploy New Infrastructure
1. Set up PostgreSQL + Redis
2. Deploy with feature flag (old vs new storage)
3. Run both in parallel, compare
4. Cut over when confident
---
## Milestones & Timeline
| Phase | Milestone | Dependencies |
|-------|-----------|--------------|
| **1** | Events emitting alongside current code | None |
| **1** | Event replay test passing | Events emitting |
| **2** | Redis state cache working | None |
| **2** | Server survives restart (games recover) | Events + Redis |
| **3** | User accounts with persistent stats | PostgreSQL |
| **3** | Leaderboards displaying | Stats aggregation |
| **4** | Export API working | Events stored |
| **4** | Replay UI functional | Export API |
| **5** | Dockerized deployment | All above |
| **5** | Production deployment | Docker + hosting |
---
## Open Questions
1. **Guest play vs required accounts?**
- Recommendation: Allow guest play, prompt to register to save stats
2. **Game history retention?**
- Keep all events forever? Or archive after 90 days?
- Events are small (~500 bytes each), storage is cheap
3. **Replay visibility?**
- All games public? Only if shared? Privacy setting per game?
4. **CPU games count for leaderboards?**
- Recommendation: Yes, but flag them. Separate "vs humans" stats later.
5. **i18n approach?**
- Client-side translation files (JSON)
- Server messages are mostly game state, not text
---
## Appendix: File Structure (Proposed)
```
golfgame/
├── client/ # Frontend (keep as-is for now)
│ ├── index.html
│ ├── app.js
│ └── ...
├── server/
│ ├── main.py # FastAPI app, WebSocket handlers
│ ├── config.py # Settings (env vars)
│ ├── models/
│ │ ├── events.py # Event dataclasses
│ │ ├── game_state.py # State rebuilt from events
│ │ └── user.py # User model
│ ├── stores/
│ │ ├── event_store.py # PostgreSQL event persistence
│ │ ├── state_cache.py # Redis live state
│ │ └── user_store.py # User/auth persistence
│ ├── services/
│ │ ├── game_service.py # Command handling, event emission
│ │ ├── replay_service.py # Export, import, playback
│ │ ├── stats_service.py # Leaderboard queries
│ │ └── auth_service.py # Authentication
│ ├── workers/
│ │ └── stats_worker.py # Background stats aggregation
│ ├── ai/
│ │ ├── profiles.py # CPU personalities
│ │ └── decisions.py # AI logic
│ └── tests/
│ ├── test_events.py
│ ├── test_replay.py
│ └── ...
├── migrations/ # Alembic migrations
├── docker-compose.yml
├── Dockerfile
└── V2_BUILD_PLAN.md # This file
```
---
## Next Steps
1. **Review this plan** - Any adjustments to scope or priorities?
2. **Set up PostgreSQL locally** - For development
3. **Define event classes** - Start with Phase 1
4. **Add event emission** - Non-breaking change to current code
Ready to start building when you are.

View File

@@ -0,0 +1,291 @@
# Golf Card Game - V3 Master Plan
## Overview
Transform the current Golf card game into a more natural, physical-feeling experience through enhanced animations, visual feedback, and gameplay flow improvements. The goal is to make the digital game feel as satisfying as playing with real cards.
**Theme:** "Make it feel like a real card game"
---
## Document Structure (VDD)
This plan is split into independent vertical slices ordered by priority and impact. Each document is self-contained and can be worked on by a separate agent.
| Document | Scope | Priority | Effort | Dependencies |
|----------|-------|----------|--------|--------------|
| `V3_01_DEALER_ROTATION.md` | Rotate dealer/first player each round | High | Low | None (server change) |
| `V3_02_DEALING_ANIMATION.md` | Animated card dealing at round start | High | Medium | 01 |
| `V3_03_ROUND_END_REVEAL.md` | Dramatic sequential card reveal | High | Medium | None |
| `V3_04_COLUMN_PAIR_CELEBRATION.md` | Visual feedback for matching pairs | High | Low | None |
| `V3_05_FINAL_TURN_URGENCY.md` | Enhanced final turn visual tension | High | Low | None |
| `V3_06_OPPONENT_THINKING.md` | Visible opponent consideration phase | Medium | Low | None |
| `V3_07_SCORE_TALLYING.md` | Animated score counting | Medium | Medium | 03 |
| `V3_08_CARD_HOVER_SELECTION.md` | Enhanced card selection preview | Medium | Low | None |
| `V3_09_KNOCK_EARLY_DRAMA.md` | Dramatic knock early presentation | Medium | Low | None |
| `V3_10_COLUMN_PAIR_INDICATOR.md` | Visual connector for paired columns | Medium | Low | 04 |
| `V3_11_SWAP_ANIMATION_IMPROVEMENTS.md` | More physical swap motion | Medium | Medium | None |
| `V3_12_DRAW_SOURCE_DISTINCTION.md` | Visual distinction deck vs discard draw | Low | Low | None |
| `V3_13_CARD_VALUE_TOOLTIPS.md` | Long-press card value display | Low | Medium | None |
| `V3_14_ACTIVE_RULES_CONTEXT.md` | Contextual rule highlighting | Low | Low | None |
| `V3_15_DISCARD_PILE_HISTORY.md` | Show recent discards fanned | Low | Medium | None |
| `V3_16_REALISTIC_CARD_SOUNDS.md` | Improved audio feedback | Nice | Medium | None |
| `V3_17_MOBILE_PORTRAIT_LAYOUT.md` | Full mobile portrait layout + animation fixes | High | High | 02, 11 |
---
## Current State (V2)
```
Client (Vanilla JS)
├── app.js - Main game logic (2500+ lines)
├── card-manager.js - DOM card element management (3D flip structure)
├── animation-queue.js - Sequential animation processing
├── card-animations.js - Unified anime.js animation system (replaces draw-animations.js)
├── state-differ.js - State change detection
├── timing-config.js - Centralized animation timing + anime.js easing config
├── anime.min.js - Anime.js library for all animations
└── style.css - Minimal CSS, mostly layout
```
**What works well:**
- **Unified anime.js system** - All card animations use `window.cardAnimations` (CardAnimations class)
- State diffing detects changes and triggers appropriate animations
- Animation queue ensures sequential, non-overlapping animations
- Centralized timing config with anime.js easing presets (`TIMING.anime.easing`)
- Sound effects via Web Audio API
- CardAnimations provides: draw, flip, swap, discard, ambient loops (turn pulse, CPU thinking)
- Opponent turn visibility with CPU action announcements
**Limitations:**
- Cards appear instantly at round start (no dealing animation)
- Round end reveals all cards simultaneously
- No visual celebration for column pairs
- Final turn phase lacks urgency/tension
- Swap animation uses crossfade rather than physical motion
- Limited feedback during card selection
- Discard pile shows only top card
---
## V3 Target Experience
### Physical Card Game Feel Checklist
| Aspect | Physical Game | Current Digital | V3 Target |
|--------|---------------|-----------------|-----------|
| **Dealer Rotation** | Deal passes clockwise each round | Always starts with host | Rotating dealer/first player |
| **Dealing** | Cards dealt one at a time | Cards appear instantly | Animated dealing sequence |
| **Drawing** | Card lifts, player considers | Card pops in | Source-appropriate pickup |
| **Swapping** | Old card slides out, new slides in | Teleport swap | Cross-over motion |
| **Pairing** | "Nice!" moment when match noticed | No feedback | Visual celebration |
| **Round End** | Dramatic reveal, one player at a time | All cards flip at once | Staggered reveal |
| **Scoring** | Count card by card | Score appears | Animated tally |
| **Final Turn** | Tension in the room | Badge shows | Visual urgency |
| **Sounds** | Shuffle, flip, slap | Synth beeps | Realistic card sounds |
---
## Tech Approach
### Animation Strategy
All **card animations** use the unified `CardAnimations` class (`card-animations.js`):
- **Anime.js timelines** for all card animations (flip, swap, draw, discard)
- **CardAnimations methods** - `animateDrawDeck()`, `animateFlip()`, `animateSwap()`, etc.
- **Ambient loops** - `startTurnPulse()`, `startCpuThinking()`, `startInitialFlipPulse()`
- **One-shot effects** - `pulseDiscard()`, `pulseSwap()`, `popIn()`
- **Animation queue** for sequencing multi-step animations
- **State differ** to trigger animations on state changes
**When to use CSS vs anime.js:**
- **Anime.js (CardAnimations)**: Card movements, flips, swaps, draws - anything involving card elements
- **CSS keyframes/transitions**: Simple UI feedback (button hover, badge entrance, status message fades) - non-card elements
**General rule:** If it moves a card, use anime.js. If it's UI chrome, CSS is fine.
### Timing Philosophy
From `timing-config.js`:
```javascript
// Current values - animations are smooth but quick
card: {
flip: 400, // Card flip duration
move: 400, // Card movement
},
pause: {
afterFlip: 0, // No pause - flow into next action
betweenAnimations: 0, // No gaps
},
// Anime.js easing presets
anime: {
easing: {
flip: 'easeInOutQuad',
move: 'easeOutCubic',
lift: 'easeOutQuad',
pulse: 'easeInOutSine',
},
loop: {
turnPulse: { duration: 2000 },
cpuThinking: { duration: 1500 },
initialFlipGlow: { duration: 1500 },
}
}
```
V3 will introduce **optional pauses for drama** without slowing normal gameplay:
- Quick pauses at key moments (pair formed, round end)
- Staggered timing for dealing/reveal (perceived faster than actual)
- User preference for animation speed (future consideration)
### Sound Strategy
Current sounds are oscillator-based (Web Audio API synthesis). V3 options:
1. **Enhanced synthesis** - More realistic waveforms, envelopes
2. **Audio sprites** - Short recordings of real card sounds
3. **Hybrid** - Synthesis for some, samples for others
Recommendation: Start with enhanced synthesis (no asset loading), consider audio sprites later.
---
## Phases & Milestones
### Phase 1: Core Feel (High Priority)
**Goal:** Make the game feel noticeably more physical
| Item | Description | Document |
|------|-------------|----------|
| Dealer rotation | First player rotates each round (like real cards) | 01 |
| Dealing animation | Cards dealt sequentially at round start | 02 |
| Round end reveal | Dramatic staggered flip at round end | 03 |
| Column pair celebration | Glow/pulse when pairs form | 04 |
| Final turn urgency | Visual tension enhancement | 05 |
### Phase 2: Turn Polish (Medium Priority)
**Goal:** Improve the feel of individual turns
| Item | Description | Document |
|------|-------------|----------|
| Opponent thinking | Visible consideration phase | 06 |
| Score tallying | Animated counting | 07 |
| Card hover/selection | Better swap preview | 08 |
| Knock early drama | Dramatic knock presentation | 09 |
| Column pair indicator | Visual pair connector | 10 |
| Swap improvements | Physical swap motion | 11 |
### Phase 3: Polish & Extras (Low Priority)
**Goal:** Nice-to-have improvements
| Item | Description | Document |
|------|-------------|----------|
| Draw distinction | Deck vs discard visual difference | 12 |
| Card value tooltips | Long-press to see points | 13 |
| Active rules context | Highlight relevant rules | 14 |
| Discard history | Show fanned recent cards | 15 |
| Realistic sounds | Better audio feedback | 16 |
---
## File Structure (Changes)
```
server/
├── game.py # Add dealer rotation logic (V3_01)
client/
├── app.js # Enhance existing methods
├── timing-config.js # Add new timing values + anime.js config
├── card-animations.js # Extend with new animation methods
├── animation-queue.js # Add new animation types
├── style.css # Minimal additions (mostly layout)
└── sounds/ # OPTIONAL: Audio sprites
├── shuffle.mp3
├── deal.mp3
└── flip.mp3
```
**Note:** All new animations should be added to `CardAnimations` class in `card-animations.js`. Do not add CSS keyframe animations for card movements.
---
## Acceptance Criteria (V3 Complete)
1. **Dealer rotates properly** - First player advances clockwise each round
2. **Dealing feels physical** - Cards dealt one by one with shuffle sound
3. **Round end is dramatic** - Staggered reveal with tension pause
4. **Pairs are satisfying** - Visual celebration when columns match
5. **Final turn has urgency** - Clear visual indication of tension
6. **Swaps look natural** - Cards appear to exchange positions
7. **No performance regression** - Animations run at 60fps on mobile
8. **Timing is tunable** - All values in timing-config.js
---
## Design Principles
### 1. Enhance, Don't Slow Down
Animations should make the game feel better without making it slower. Use perceived timing tricks:
- Start next animation before previous fully completes
- Stagger start times, not end times
- Quick movements with slight ease-out
### 2. Respect the Player's Time
- First-time experience: full animations
- Repeat plays: consider faster mode option
- Never block input unnecessarily
### 3. Clear Visual Hierarchy
- Active player highlighted
- Current action obvious
- Next expected action hinted
### 4. Consistent Feedback
- Same action = same animation
- Similar duration for similar actions
- Predictable timing helps player flow
### 5. Graceful Degradation
- Animations enhance but aren't required
- State updates should work without animations
- Handle animation interruption gracefully
---
## How to Use These Documents
Each `V3_XX_*.md` document is designed to be:
1. **Self-contained** - Has all context needed to implement that feature
2. **Agent-ready** - Can be given to a Claude agent as the primary context
3. **Testable** - Includes visual verification criteria
4. **Incremental** - Can be implemented and shipped independently
**Workflow:**
1. Pick a document based on current priority
2. Start a new Claude session with that document as context
3. Implement the feature
4. Verify against acceptance criteria
5. Test on mobile and desktop
6. Merge and move to next
---
## Notes for Implementation
- **Don't break existing functionality** - All current animations must still work
- **Use existing infrastructure** - Build on animation-queue, timing-config
- **Test on mobile** - Animations must run smoothly on phones
- **Consider reduced motion** - Respect `prefers-reduced-motion` media query
- **Keep it vanilla** - No new frameworks, Anime.js is sufficient
---
## Success Metrics
After V3 implementation, the game should:
- Feel noticeably more satisfying to play
- Get positive feedback on "polish" or "feel"
- Not feel slower despite more animations
- Work smoothly on all devices
- Be easy to tune timing via config

View File

@@ -0,0 +1,286 @@
# V3-01: Dealer/Starting Player Rotation
## Overview
In physical card games, the deal rotates clockwise after each hand. The player who deals also typically plays last (or the player to their left plays first). Currently, our game always starts with the host/first player each round.
**Dependencies:** None (server-side foundation)
**Dependents:** V3_02 (Dealing Animation needs to know who is dealing)
---
## Goals
1. Track the current dealer position across rounds
2. Rotate dealer clockwise after each round
3. First player to act is to the left of the dealer (next in order)
4. Communicate dealer position to clients
5. Visual indicator of current dealer (client-side, prep for V3_02)
---
## Current State
From `server/game.py`, round start logic:
```python
def start_next_round(self):
"""Start the next round."""
self.current_round += 1
# ... deal cards ...
# Current player is always index 0 (host/first joiner)
self.current_player_idx = 0
```
The `player_order` list is set once at game start and never changes. The first player is always `player_order[0]`.
---
## Design
### Server Changes
#### New State Fields
```python
# In Game class __init__
self.dealer_idx = 0 # Index into player_order of current dealer
```
#### Round Start Logic
```python
def start_next_round(self):
"""Start the next round."""
self.current_round += 1
# Rotate dealer clockwise (next player in order)
if self.current_round > 1:
self.dealer_idx = (self.dealer_idx + 1) % len(self.player_order)
# First player is to the LEFT of dealer (next after dealer)
self.current_player_idx = (self.dealer_idx + 1) % len(self.player_order)
# ... rest of dealing logic ...
```
#### Game State Response
Add dealer info to the game state sent to clients:
```python
def get_state(self, for_player_id: str) -> dict:
return {
# ... existing fields ...
"dealer_id": self.player_order[self.dealer_idx] if self.player_order else None,
"dealer_idx": self.dealer_idx,
# current_player_id already exists
}
```
### Client Changes
#### State Handling
In `app.js`, the `gameState` will now include:
- `dealer_id` - The player ID of the current dealer
- `dealer_idx` - Index for ordering
#### Visual Indicator
Add a dealer chip/badge to the current dealer's area:
```javascript
// In renderGame() or opponent rendering
const isDealer = player.id === this.gameState.dealer_id;
if (isDealer) {
div.classList.add('is-dealer');
// Add dealer chip element
}
```
#### CSS
```css
/* Dealer indicator */
.is-dealer::before {
content: "D";
position: absolute;
top: -8px;
left: -8px;
width: 20px;
height: 20px;
background: #f4a460;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
color: #1a1a2e;
border: 2px solid #fff;
z-index: 10;
}
/* Or use a chip emoji/icon */
.dealer-chip {
position: absolute;
top: -10px;
right: -10px;
font-size: 1.2em;
}
```
---
## Edge Cases
### Player Leaves Mid-Game
If the current dealer leaves:
- Dealer position should stay at the same index
- If that index is now out of bounds, wrap to 0
- The show must go on
```python
def remove_player(self, player_id: str):
# ... existing removal logic ...
# Adjust dealer_idx if needed
if self.dealer_idx >= len(self.player_order):
self.dealer_idx = 0
```
### 2-Player Game
With 2 players, dealer alternates each round:
- Round 1: Player A deals, Player B plays first
- Round 2: Player B deals, Player A plays first
- This works naturally with the modulo logic
### Game Start (Round 1)
For round 1:
- Dealer is the host (player_order[0])
- First player is player_order[1] (or player_order[0] in solo/test)
Option: Could randomize initial dealer, but host-as-first-dealer is traditional.
---
## Test Cases
```python
# server/tests/test_dealer_rotation.py
def test_dealer_starts_as_host():
"""First round dealer is the host (first player)."""
game = create_game_with_players(["Alice", "Bob", "Carol"])
game.start_game()
assert game.dealer_idx == 0
assert game.get_dealer_id() == "Alice"
# First player is to dealer's left
assert game.current_player_idx == 1
assert game.get_current_player_id() == "Bob"
def test_dealer_rotates_each_round():
"""Dealer advances clockwise after each round."""
game = create_game_with_players(["Alice", "Bob", "Carol"])
game.start_game()
# Round 1: Alice deals, Bob plays first
assert game.dealer_idx == 0
complete_round(game)
game.start_next_round()
# Round 2: Bob deals, Carol plays first
assert game.dealer_idx == 1
assert game.current_player_idx == 2
complete_round(game)
game.start_next_round()
# Round 3: Carol deals, Alice plays first
assert game.dealer_idx == 2
assert game.current_player_idx == 0
def test_dealer_wraps_around():
"""Dealer wraps to first player after last player deals."""
game = create_game_with_players(["Alice", "Bob"])
game.start_game()
# Round 1: Alice deals
assert game.dealer_idx == 0
complete_round(game)
game.start_next_round()
# Round 2: Bob deals
assert game.dealer_idx == 1
complete_round(game)
game.start_next_round()
# Round 3: Back to Alice
assert game.dealer_idx == 0
def test_dealer_adjustment_on_player_leave():
"""Dealer index adjusts when players leave."""
game = create_game_with_players(["Alice", "Bob", "Carol"])
game.start_game()
complete_round(game)
game.start_next_round()
# Bob is now dealer (idx 1)
game.remove_player("Carol") # Remove last player
# Dealer idx should still be valid
assert game.dealer_idx == 1
assert game.dealer_idx < len(game.player_order)
def test_state_includes_dealer_info():
"""Game state includes dealer information."""
game = create_game_with_players(["Alice", "Bob"])
game.start_game()
state = game.get_state("Alice")
assert "dealer_id" in state
assert state["dealer_id"] == "Alice"
```
---
## Implementation Order
1. Add `dealer_idx` field to Game class
2. Modify `start_game()` to set initial dealer
3. Modify `start_next_round()` to rotate dealer
4. Modify `get_state()` to include dealer info
5. Handle edge case: player leaves
6. Add tests for dealer rotation
7. Client: Add dealer visual indicator
8. Client: Style the dealer chip/badge
---
## Acceptance Criteria
- [ ] Round 1 dealer is the host (first player in order)
- [ ] Dealer rotates clockwise after each round
- [ ] First player to act is always left of dealer
- [ ] Dealer info included in game state sent to clients
- [ ] Dealer position survives player departure
- [ ] Visual indicator shows current dealer
- [ ] All existing tests still pass
---
## Notes for Agent
- The `player_order` list is established at game start and defines clockwise order
- Keep backward compatibility - games in progress shouldn't break
- The dealer indicator is prep work for V3_02 (dealing animation)
- Consider: Should dealer deal to themselves last? (Traditional, but not gameplay-affecting)
- The visual dealer chip will become important when dealing animation shows cards coming FROM the dealer

View File

@@ -0,0 +1,406 @@
# V3-02: Dealing Animation
## Overview
In physical card games, cards are dealt one at a time from the dealer to each player in turn. Currently, cards appear instantly when a round starts. This feature adds an animated dealing sequence that mimics the physical ritual.
**Dependencies:** V3_01 (Dealer Rotation - need to know who is dealing)
**Dependents:** None
---
## Goals
1. Animate cards being dealt from a central deck position
2. Deal one card at a time to each player in clockwise order
3. Play shuffle sound before dealing begins
4. Play card sound as each card lands
5. Maintain quick perceived pace (stagger start times, not end times)
6. Show dealing from dealer's position (or center as fallback)
---
## Current State
From `app.js`, when `game_started` or `round_started` message received:
```javascript
case 'game_started':
case 'round_started':
this.gameState = data.game_state;
this.playSound('shuffle');
this.showGameScreen();
this.renderGame(); // Cards appear instantly
break;
```
Cards are rendered immediately via `renderGame()` which populates the card grids.
---
## Design
### Animation Sequence
```
1. Shuffle sound plays
2. Brief pause (300ms) - deck appears to shuffle
3. Deal round 1: One card to each player (clockwise from dealer's left)
4. Deal round 2-6: Repeat until all 6 cards dealt to each player
5. Flip discard pile top card
6. Initial flip phase begins (or game starts if initial_flips=0)
```
### Visual Flow
```
[Deck]
|
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
[Opponent 1] [Opponent 2] [Opponent 3]
|
[Local Player]
```
Cards fly from deck position to each player's card slot, face-down.
### Timing
```javascript
// New timing values in timing-config.js
dealing: {
shufflePause: 400, // Pause after shuffle sound
cardFlyTime: 150, // Time for card to fly to destination
cardStagger: 80, // Delay between cards (overlap for speed)
roundPause: 50, // Brief pause between deal rounds
discardFlipDelay: 200, // Pause before flipping discard
}
```
Total time for 4-player game (24 cards):
- 400ms shuffle + 24 cards × 80ms stagger + 200ms discard = ~2.5 seconds
This feels unhurried but not slow.
### Implementation Approach
#### Option A: Overlay Animation (Recommended)
Create temporary card elements that animate from deck to destinations, then remove them and show the real cards.
Pros:
- Clean separation from game state
- Easy to skip/interrupt
- No complex state management
Cons:
- Brief flash when swapping to real cards (mitigate with timing)
#### Option B: Animate Real Cards
Start with cards at deck position, animate to final positions.
Pros:
- No element swap
- More "real"
Cons:
- Complex coordination with renderGame()
- State management issues
**Recommendation:** Option A - overlay animation
---
## Implementation
### Add to `card-animations.js`
Add the dealing animation as a method on the existing `CardAnimations` class:
```javascript
// Add to CardAnimations class in card-animations.js
/**
* Run the dealing animation using anime.js timelines
* @param {Object} gameState - The game state with players and their cards
* @param {Function} getPlayerRect - Function(playerId, cardIdx) => {left, top, width, height}
* @param {Function} onComplete - Callback when animation completes
*/
async animateDealing(gameState, getPlayerRect, onComplete) {
const T = window.TIMING?.dealing || {
shufflePause: 400,
cardFlyTime: 150,
cardStagger: 80,
roundPause: 50,
discardFlipDelay: 200,
};
const deckRect = this.getDeckRect();
const discardRect = this.getDiscardRect();
if (!deckRect) {
if (onComplete) onComplete();
return;
}
// Get player order starting from dealer's left
const dealerIdx = gameState.dealer_idx || 0;
const playerOrder = this.getDealOrder(gameState.players, dealerIdx);
// Create container for animation cards
const container = document.createElement('div');
container.className = 'deal-animation-container';
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
document.body.appendChild(container);
// Shuffle sound and pause
this.playSound('shuffle');
await this.delay(T.shufflePause);
// Deal 6 rounds of cards using anime.js
const allCards = [];
for (let cardIdx = 0; cardIdx < 6; cardIdx++) {
for (const player of playerOrder) {
const targetRect = getPlayerRect(player.id, cardIdx);
if (!targetRect) continue;
// Create card at deck position
const deckColor = this.getDeckColor();
const card = this.createAnimCard(deckRect, true, deckColor);
card.classList.add('deal-anim-card');
container.appendChild(card);
allCards.push({ card, targetRect });
// Animate using anime.js
anime({
targets: card,
left: targetRect.left,
top: targetRect.top,
width: targetRect.width,
height: targetRect.height,
duration: T.cardFlyTime,
easing: this.getEasing('move'),
});
this.playSound('card');
await this.delay(T.cardStagger);
}
// Brief pause between rounds
if (cardIdx < 5) {
await this.delay(T.roundPause);
}
}
// Wait for last cards to land
await this.delay(T.cardFlyTime);
// Flip discard pile card
if (discardRect && gameState.discard_top) {
await this.delay(T.discardFlipDelay);
this.playSound('flip');
}
// Clean up
container.remove();
if (onComplete) onComplete();
}
getDealOrder(players, dealerIdx) {
// Rotate so dealing starts to dealer's left
const order = [...players];
const startIdx = (dealerIdx + 1) % order.length;
return [...order.slice(startIdx), ...order.slice(0, startIdx)];
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
```
### CSS for Deal Animation
```css
/* In style.css - minimal, anime.js handles all animation */
/* Deal animation container */
.deal-animation-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1000;
}
/* Deal cards inherit from .draw-anim-card (already exists in card-animations.js) */
.deal-anim-card {
/* Uses same structure as createAnimCard() */
}
```
### Integration in app.js
```javascript
// In handleMessage, game_started/round_started case:
case 'game_started':
case 'round_started':
this.clearNextHoleCountdown();
this.nextRoundBtn.classList.remove('waiting');
this.roundWinnerNames = new Set();
this.gameState = data.game_state;
this.previousState = JSON.parse(JSON.stringify(data.game_state));
this.locallyFlippedCards = new Set();
this.selectedCards = [];
this.animatingPositions = new Set();
this.opponentSwapAnimation = null;
this.showGameScreen();
// NEW: Run deal animation using CardAnimations
this.runDealAnimation(() => {
this.renderGame();
});
break;
// New method using CardAnimations
runDealAnimation(onComplete) {
// Hide cards initially
this.playerCards.style.visibility = 'hidden';
this.opponentsRow.style.visibility = 'hidden';
// Use the global cardAnimations instance
window.cardAnimations.animateDealing(
this.gameState,
(playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx),
() => {
// Show real cards
this.playerCards.style.visibility = 'visible';
this.opponentsRow.style.visibility = 'visible';
onComplete();
}
);
}
// Helper to get card slot position
getCardSlotRect(playerId, cardIdx) {
if (playerId === this.playerId) {
// Local player
const cards = this.playerCards.querySelectorAll('.card');
return cards[cardIdx]?.getBoundingClientRect();
} else {
// Opponent
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
for (const area of opponentAreas) {
if (area.dataset.playerId === playerId) {
const cards = area.querySelectorAll('.card');
return cards[cardIdx]?.getBoundingClientRect();
}
}
}
return null;
}
```
---
## Timing Tuning
### Perceived Speed Tricks
1. **Overlap card flights** - Start next card before previous lands
2. **Ease-out timing** - Cards decelerate into position (feels snappier)
3. **Batch by round** - 6 deal rounds feels rhythmic
4. **Quick stagger** - 80ms between cards feels like rapid dealing
### Accessibility
```javascript
// Respect reduced motion preference
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// Skip animation, just show cards
this.renderGame();
return;
}
```
---
## Edge Cases
### Animation Interrupted
If player disconnects or game state changes during dealing:
- Cancel animation
- Show cards immediately
- Continue with normal game flow
### Varying Player Counts
2-6 players supported:
- Fewer players = faster deal (fewer cards per round)
- 2 players: 12 cards total, ~1.5 seconds
- 6 players: 36 cards total, ~3.5 seconds
### Opponent Areas Not Ready
If opponent areas haven't rendered yet:
- Fall back to animating to center positions
- Or skip animation for that player
---
## Test Scenarios
1. **2-player game** - Dealing alternates correctly
2. **6-player game** - All players receive cards in order
3. **Quick tap through** - Animation can be interrupted
4. **Round 2+** - Dealing starts from correct dealer position
5. **Mobile** - Animation runs smoothly at 60fps
6. **Reduced motion** - Animation skipped appropriately
---
## Acceptance Criteria
- [ ] Cards animate from deck to player positions
- [ ] Deal order follows clockwise from dealer's left
- [ ] Shuffle sound plays before dealing
- [ ] Card sound plays as each card lands
- [ ] Animation completes in < 4 seconds for 6 players
- [ ] Real cards appear after animation (no flash)
- [ ] Reduced motion preference respected
- [ ] Works on mobile (60fps)
- [ ] Can be interrupted without breaking game
---
## Implementation Order
1. Add timing values to `timing-config.js`
2. Create `deal-animation.js` with DealAnimation class
3. Add CSS for deal animation cards
4. Add `data-player-id` to opponent areas for targeting
5. Add `getCardSlotRect()` helper method
6. Integrate animation in game_started/round_started handler
7. Test with various player counts
8. Add reduced motion support
9. Tune timing for best feel
---
## Notes for Agent
- Add `animateDealing()` as a method on the existing `CardAnimations` class
- Use `createAnimCard()` to create deal cards (already exists, handles 3D structure)
- Use anime.js for all card movements, not CSS transitions
- The existing `CardManager` handles persistent cards - don't modify it
- Timing values should all be in `timing-config.js` under `dealing` key
- Consider: Show dealer's hands actually dealing? (complex, skip for V3)
- The shuffle sound already exists - reuse it via `playSound('shuffle')`
- Cards should deal face-down (use `createAnimCard(rect, true, deckColor)`)

View File

@@ -0,0 +1,532 @@
# V3-03: Round End Dramatic Reveal
## Overview
When a round ends, all face-down cards must be revealed for scoring. In physical games, this is a dramatic moment - each player flips their hidden cards one at a time while others watch. Currently, all cards flip simultaneously which lacks drama.
**Dependencies:** None
**Dependents:** V3_07 (Score Tallying can follow the reveal)
---
## Goals
1. Reveal cards sequentially, one player at a time
2. Within each player, reveal cards with slight stagger
3. Pause briefly between players for dramatic effect
4. Start with the player who triggered final turn (the "knocker")
5. End with visible score tally moment
6. Play flip sounds for each reveal
---
## Current State
When round ends, the server sends a `round_over` message and clients receive a `game_state` update where all cards are now `face_up: true`. The state differ detects the changes but doesn't sequence the animations - they happen together.
From `showScoreboard()` in app.js:
```javascript
showScoreboard(scores, isFinal, rankings) {
// Cards are already revealed by state update
// Scoreboard appears immediately
}
```
---
## Design
### Reveal Sequence
```
1. Round ends - "Hole Complete!" message
2. VOLUNTARY FLIP WINDOW (4 seconds):
- Players can tap their own face-down cards to peek/flip
- Countdown timer shows remaining time
- "Tap to reveal your cards" prompt
3. AUTO-REVEAL (after timeout or all flipped):
- Knocker's cards reveal first (they went out)
- For each other player (clockwise from knocker):
a. Player area highlights
b. Face-down cards flip with stagger (100ms between)
c. Brief pause to see the reveal (400ms)
4. Score tallying animation (see V3_07)
5. Scoreboard appears
```
### Voluntary Flip Window
Before the dramatic reveal sequence, players get a chance to flip their own hidden cards:
- **Duration:** 4 seconds (configurable)
- **Purpose:** Let players see their own cards before everyone else does
- **UI:** Countdown timer, "Tap your cards to reveal" message
- **Skip:** If all players flip their cards, proceed immediately
### Visual Flow
```
Timeline:
0ms - Round ends, pause
500ms - Knocker highlight, first card flips
600ms - Knocker second card flips (if any)
700ms - Knocker third card flips (if any)
1100ms - Pause to see knocker's hand
1500ms - Player 2 highlight
1600ms - Player 2 cards flip...
...continue for all players...
Final - Scoreboard appears
```
### Timing Configuration
```javascript
// In timing-config.js
reveal: {
voluntaryWindow: 4000, // Time for players to flip their own cards
initialPause: 500, // Pause before auto-reveals start
cardStagger: 100, // Between cards in same hand
playerPause: 400, // Pause after each player's reveal
highlightDuration: 200, // Player area highlight fade-in
}
```
---
## Implementation
### Approach: Intercept State Update
Instead of letting `renderGame()` show all cards instantly, intercept the round_over state and run a reveal sequence.
```javascript
// In handleMessage, game_state case:
case 'game_state':
const oldState = this.gameState;
const newState = data.game_state;
// Check for round end transition
const roundJustEnded = oldState?.phase !== 'round_over' &&
newState.phase === 'round_over';
if (roundJustEnded) {
// Don't update state yet - run reveal animation first
this.runRoundEndReveal(oldState, newState, () => {
this.gameState = newState;
this.renderGame();
});
return;
}
// Normal state update
this.gameState = newState;
this.renderGame();
break;
```
### Voluntary Flip Window Implementation
```javascript
async runVoluntaryFlipWindow(oldState, newState) {
const T = window.TIMING?.reveal || {};
const windowDuration = T.voluntaryWindow || 4000;
// Find which of MY cards need flipping
const myOldCards = oldState?.players?.find(p => p.id === this.playerId)?.cards || [];
const myNewCards = newState?.players?.find(p => p.id === this.playerId)?.cards || [];
const myHiddenPositions = [];
for (let i = 0; i < 6; i++) {
if (!myOldCards[i]?.face_up && myNewCards[i]?.face_up) {
myHiddenPositions.push(i);
}
}
// If I have no hidden cards, skip window
if (myHiddenPositions.length === 0) {
return;
}
// Show prompt and countdown
this.showRevealPrompt(windowDuration);
// Enable clicking on my hidden cards
this.voluntaryFlipMode = true;
this.voluntaryFlipPositions = new Set(myHiddenPositions);
this.renderGame(); // Re-render to make cards clickable
// Wait for timeout or all cards flipped
return new Promise(resolve => {
const checkComplete = () => {
if (this.voluntaryFlipPositions.size === 0) {
this.hideRevealPrompt();
this.voluntaryFlipMode = false;
resolve();
}
};
// Set up interval to check completion
const checkInterval = setInterval(checkComplete, 100);
// Timeout after window duration
setTimeout(() => {
clearInterval(checkInterval);
this.hideRevealPrompt();
this.voluntaryFlipMode = false;
resolve();
}, windowDuration);
});
}
showRevealPrompt(duration) {
// Create countdown overlay
const overlay = document.createElement('div');
overlay.id = 'reveal-prompt';
overlay.className = 'reveal-prompt';
overlay.innerHTML = `
<div class="reveal-prompt-text">Tap your cards to reveal</div>
<div class="reveal-prompt-countdown">${Math.ceil(duration / 1000)}</div>
`;
document.body.appendChild(overlay);
// Countdown timer
const countdownEl = overlay.querySelector('.reveal-prompt-countdown');
let remaining = duration;
this.countdownInterval = setInterval(() => {
remaining -= 100;
countdownEl.textContent = Math.ceil(remaining / 1000);
if (remaining <= 0) {
clearInterval(this.countdownInterval);
}
}, 100);
}
hideRevealPrompt() {
clearInterval(this.countdownInterval);
const overlay = document.getElementById('reveal-prompt');
if (overlay) {
overlay.classList.add('fading');
setTimeout(() => overlay.remove(), 300);
}
}
// Modify handleCardClick to handle voluntary flips
handleCardClick(position) {
// ... existing code ...
// Voluntary flip during reveal window
if (this.voluntaryFlipMode && this.voluntaryFlipPositions?.has(position)) {
const myData = this.getMyPlayerData();
const card = myData?.cards[position];
if (card) {
this.playSound('flip');
this.fireLocalFlipAnimation(position, card);
this.voluntaryFlipPositions.delete(position);
// Update local state to show card flipped
this.locallyFlippedCards.add(position);
this.renderGame();
}
return;
}
// ... rest of existing code ...
}
```
### Reveal Animation Method
```javascript
async runRoundEndReveal(oldState, newState, onComplete) {
const T = window.TIMING?.reveal || {};
// STEP 1: Voluntary flip window - let players peek at their own cards
this.setStatus('Reveal your hidden cards!', 'reveal-window');
await this.runVoluntaryFlipWindow(oldState, newState);
// STEP 2: Auto-reveal remaining hidden cards
// Recalculate what needs flipping (some may have been voluntarily revealed)
const revealsByPlayer = this.getCardsToReveal(oldState, newState);
// Get reveal order: knocker first, then clockwise
const knockerId = newState.finisher_id;
const revealOrder = this.getRevealOrder(newState.players, knockerId);
// Initial dramatic pause before auto-reveals
this.setStatus('Revealing cards...', 'reveal');
await this.delay(T.initialPause || 500);
// Reveal each player's cards
for (const player of revealOrder) {
const cardsToFlip = revealsByPlayer.get(player.id) || [];
if (cardsToFlip.length === 0) continue;
// Highlight player area
this.highlightPlayerArea(player.id, true);
await this.delay(T.highlightDuration || 200);
// Flip each card with stagger
for (const { position, card } of cardsToFlip) {
this.animateRevealFlip(player.id, position, card);
await this.delay(T.cardStagger || 100);
}
// Wait for last flip to complete + pause
await this.delay(400 + (T.playerPause || 400));
// Remove highlight
this.highlightPlayerArea(player.id, false);
}
// All revealed
onComplete();
}
getCardsToReveal(oldState, newState) {
const reveals = new Map();
for (const newPlayer of newState.players) {
const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
if (!oldPlayer) continue;
const cardsToFlip = [];
for (let i = 0; i < 6; i++) {
const wasHidden = !oldPlayer.cards[i]?.face_up;
const nowVisible = newPlayer.cards[i]?.face_up;
if (wasHidden && nowVisible) {
cardsToFlip.push({
position: i,
card: newPlayer.cards[i]
});
}
}
if (cardsToFlip.length > 0) {
reveals.set(newPlayer.id, cardsToFlip);
}
}
return reveals;
}
getRevealOrder(players, knockerId) {
// Knocker first
const knocker = players.find(p => p.id === knockerId);
const others = players.filter(p => p.id !== knockerId);
// Others in clockwise order (already sorted by player_order)
if (knocker) {
return [knocker, ...others];
}
return others;
}
highlightPlayerArea(playerId, highlight) {
if (playerId === this.playerId) {
this.playerArea.classList.toggle('revealing', highlight);
} else {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${playerId}"]`
);
if (area) {
area.classList.toggle('revealing', highlight);
}
}
}
animateRevealFlip(playerId, position, cardData) {
// Reuse existing flip animation
if (playerId === this.playerId) {
this.fireLocalFlipAnimation(position, cardData);
} else {
this.fireFlipAnimation(playerId, position, cardData);
}
}
```
### CSS for Reveal Prompt and Highlights
```css
/* Voluntary reveal prompt */
.reveal-prompt {
position: fixed;
top: 20%;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #f4a460 0%, #d4845a 100%);
color: white;
padding: 15px 30px;
border-radius: 12px;
text-align: center;
z-index: 200;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: prompt-entrance 0.3s ease-out;
}
.reveal-prompt.fading {
animation: prompt-fade 0.3s ease-out forwards;
}
@keyframes prompt-entrance {
0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
}
@keyframes prompt-fade {
0% { opacity: 1; }
100% { opacity: 0; }
}
.reveal-prompt-text {
font-size: 1.1em;
margin-bottom: 8px;
}
.reveal-prompt-countdown {
font-size: 2em;
font-weight: bold;
}
/* Cards clickable during voluntary reveal */
.player-area.voluntary-flip .card.can-flip {
cursor: pointer;
animation: flip-hint 0.8s ease-in-out infinite;
}
@keyframes flip-hint {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
/* Player area highlight during reveal */
.player-area.revealing,
.opponent-area.revealing {
animation: reveal-highlight 0.3s ease-out;
}
@keyframes reveal-highlight {
0% {
box-shadow: 0 0 0 0 rgba(244, 164, 96, 0);
}
50% {
box-shadow: 0 0 20px 10px rgba(244, 164, 96, 0.4);
}
100% {
box-shadow: 0 0 10px 5px rgba(244, 164, 96, 0.2);
}
}
/* Keep highlight while revealing */
.player-area.revealing,
.opponent-area.revealing {
box-shadow: 0 0 10px 5px rgba(244, 164, 96, 0.2);
}
```
---
## Special Cases
### All Cards Already Face-Up
If a player has no face-down cards (they knocked or flipped everything):
- Skip their reveal in the sequence
- Don't highlight their area
### Player Disconnected
If a player left before round end:
- Their cards still need to reveal for scoring
- Handle missing player areas gracefully
### Single Player (Debug/Test)
If only one player remains:
- Still do the reveal animation for their cards
- Feels consistent
### Quick Mode (Future)
Consider a setting to skip reveal animation:
```javascript
if (this.settings.quickMode) {
this.gameState = newState;
this.renderGame();
return;
}
```
---
## Timing Tuning
The reveal should feel dramatic but not tedious:
| Scenario | Cards to Reveal | Approximate Duration |
|----------|----------------|---------------------|
| 2 players, 2 hidden each | 4 cards | ~2 seconds |
| 4 players, 3 hidden each | 12 cards | ~4 seconds |
| 6 players, 4 hidden each | 24 cards | ~7 seconds |
If too slow, reduce:
- `cardStagger`: 100ms → 60ms
- `playerPause`: 400ms → 250ms
---
## Test Scenarios
1. **Normal round end** - Knocker reveals first, others follow
2. **Knocker has no hidden cards** - Skip knocker, start with next player
3. **All players have hidden cards** - Full reveal sequence
4. **Some players have no hidden cards** - Skip them gracefully
5. **Player disconnected** - Handle gracefully
6. **2-player game** - Both players reveal in order
7. **Quick succession** - Multiple round ends don't overlap
---
## Acceptance Criteria
- [ ] **Voluntary flip window:** 4-second window for players to flip their own cards
- [ ] Countdown timer shows remaining time
- [ ] Players can tap their face-down cards to reveal early
- [ ] Auto-reveal starts after timeout (or if all cards flipped)
- [ ] Cards reveal sequentially during auto-reveal, not all at once
- [ ] Knocker (finisher) reveals first
- [ ] Other players reveal clockwise after knocker
- [ ] Cards within a hand have slight stagger
- [ ] Pause between players for drama
- [ ] Player area highlights during their reveal
- [ ] Flip sound plays for each card
- [ ] Reveal completes before scoreboard appears
- [ ] Handles players with no hidden cards
- [ ] Animation can be interrupted if needed
---
## Implementation Order
1. Add reveal timing to `timing-config.js`
2. Add `data-player-id` to opponent areas (if not done in V3_02)
3. Implement `getCardsToReveal()` method
4. Implement `getRevealOrder()` method
5. Implement `highlightPlayerArea()` method
6. Implement `runRoundEndReveal()` method
7. Intercept round_over state transition
8. Add reveal highlight CSS
9. Test with various player counts and card states
10. Tune timing for best dramatic effect
---
## Notes for Agent
- Use `window.cardAnimations.animateFlip()` or `animateOpponentFlip()` for reveals
- The existing CardAnimations class has all flip animation methods ready
- Don't forget to set `finisher_id` in game state (server may already do this)
- The reveal order should match the physical clockwise order
- Consider: Add a "drum roll" sound before reveals? (Nice to have)
- The scoreboard should NOT appear until all reveals complete
- State update is deferred until animation completes - ensure no race conditions
- All animations use anime.js timelines internally - no CSS keyframes needed

View File

@@ -0,0 +1,354 @@
# V3-04: Column Pair Celebration
## Overview
Matching cards in a column (positions 0+3, 1+4, or 2+5) score 0 points - a key strategic mechanic. In physical games, players often exclaim when they make a pair. Currently, there's no visual feedback when a pair is formed, missing a satisfying moment.
**Dependencies:** None
**Dependents:** V3_10 (Column Pair Indicator builds on this)
---
## Goals
1. Detect when a swap creates a new column pair
2. Play satisfying visual celebration on both cards
3. Play a distinct "pair matched" sound
4. Brief but noticeable - shouldn't slow gameplay
5. Works for both local player and opponent swaps
---
## Current State
Column pairs are calculated during scoring but there's no visual indication when a pair forms during play.
From the rules (RULES.md):
```
Column 0: positions (0, 3)
Column 1: positions (1, 4)
Column 2: positions (2, 5)
```
A pair is formed when both cards in a column are face-up and have the same rank.
---
## Design
### Detection
After any swap or flip, check if a new pair was formed:
```javascript
function detectNewPair(oldCards, newCards) {
const columns = [[0, 3], [1, 4], [2, 5]];
for (const [top, bottom] of columns) {
const wasPaired = isPaired(oldCards, top, bottom);
const nowPaired = isPaired(newCards, top, bottom);
if (!wasPaired && nowPaired) {
return { column: columns.indexOf([top, bottom]), positions: [top, bottom] };
}
}
return null;
}
function isPaired(cards, pos1, pos2) {
const card1 = cards[pos1];
const card2 = cards[pos2];
return card1?.face_up && card2?.face_up &&
card1?.rank && card2?.rank &&
card1.rank === card2.rank;
}
```
### Celebration Animation
When a pair forms:
```
1. Both cards pulse/glow simultaneously
2. Brief sparkle effect (optional)
3. "Pair!" sound plays
4. Animation lasts ~400ms
5. Cards return to normal
```
### Visual Effect Options
**Option A: Anime.js Glow Pulse** (Recommended - matches existing animation system)
```javascript
// Add to CardAnimations class
celebratePair(cardElement1, cardElement2) {
this.playSound('pair');
const duration = window.TIMING?.celebration?.pairDuration || 400;
[cardElement1, cardElement2].forEach(el => {
anime({
targets: el,
boxShadow: [
'0 0 0 0 rgba(255, 215, 0, 0)',
'0 0 15px 8px rgba(255, 215, 0, 0.5)',
'0 0 0 0 rgba(255, 215, 0, 0)'
],
scale: [1, 1.05, 1],
duration: duration,
easing: 'easeOutQuad'
});
});
}
```
**Option B: Scale Bounce**
```javascript
anime({
targets: [cardElement1, cardElement2],
scale: [1, 1.1, 1],
duration: 400,
easing: 'easeOutQuad'
});
```
**Option C: Connecting Line**
Draw a brief line connecting the paired cards (more complex).
**Recommendation:** Option A - anime.js glow pulse matches the existing animation system.
---
## Implementation
### Timing Configuration
```javascript
// In timing-config.js
celebration: {
pairDuration: 400, // Celebration animation length
pairDelay: 50, // Slight delay before celebration (let swap settle)
}
```
### Sound
Add a new sound type for pairs:
```javascript
// In playSound() method
} else if (type === 'pair') {
// Two-tone "ding-ding" for pair match
const osc1 = ctx.createOscillator();
const osc2 = ctx.createOscillator();
const gain = ctx.createGain();
osc1.connect(gain);
osc2.connect(gain);
gain.connect(ctx.destination);
osc1.frequency.setValueAtTime(880, ctx.currentTime); // A5
osc2.frequency.setValueAtTime(1108, ctx.currentTime); // C#6
gain.gain.setValueAtTime(0.1, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
osc1.start(ctx.currentTime);
osc2.start(ctx.currentTime);
osc1.stop(ctx.currentTime + 0.3);
osc2.stop(ctx.currentTime + 0.3);
}
```
### Detection Integration
In the state differ or after swap animations:
```javascript
// In triggerAnimationsForStateChange() or after swap completes
checkForNewPairs(oldState, newState, playerId) {
const oldPlayer = oldState?.players?.find(p => p.id === playerId);
const newPlayer = newState?.players?.find(p => p.id === playerId);
if (!oldPlayer || !newPlayer) return;
const columns = [[0, 3], [1, 4], [2, 5]];
for (const [top, bottom] of columns) {
const wasPaired = this.isPaired(oldPlayer.cards, top, bottom);
const nowPaired = this.isPaired(newPlayer.cards, top, bottom);
if (!wasPaired && nowPaired) {
// New pair formed!
setTimeout(() => {
this.celebratePair(playerId, top, bottom);
}, window.TIMING?.celebration?.pairDelay || 50);
}
}
}
isPaired(cards, pos1, pos2) {
const c1 = cards[pos1];
const c2 = cards[pos2];
return c1?.face_up && c2?.face_up && c1?.rank === c2?.rank;
}
celebratePair(playerId, pos1, pos2) {
const cards = this.getCardElements(playerId, pos1, pos2);
if (cards.length === 0) return;
// Use CardAnimations to animate (or add method to CardAnimations)
window.cardAnimations.celebratePair(cards[0], cards[1]);
}
// Add to CardAnimations class in card-animations.js:
celebratePair(cardElement1, cardElement2) {
this.playSound('pair');
const duration = window.TIMING?.celebration?.pairDuration || 400;
[cardElement1, cardElement2].forEach(el => {
if (!el) return;
// Temporarily raise z-index so glow shows above adjacent cards
el.style.zIndex = '10';
anime({
targets: el,
boxShadow: [
'0 0 0 0 rgba(255, 215, 0, 0)',
'0 0 15px 8px rgba(255, 215, 0, 0.5)',
'0 0 0 0 rgba(255, 215, 0, 0)'
],
scale: [1, 1.05, 1],
duration: duration,
easing: 'easeOutQuad',
complete: () => {
el.style.zIndex = '';
}
});
});
}
getCardElements(playerId, ...positions) {
const elements = [];
if (playerId === this.playerId) {
const cards = this.playerCards.querySelectorAll('.card');
for (const pos of positions) {
if (cards[pos]) elements.push(cards[pos]);
}
} else {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${playerId}"]`
);
if (area) {
const cards = area.querySelectorAll('.card');
for (const pos of positions) {
if (cards[pos]) elements.push(cards[pos]);
}
}
}
return elements;
}
```
### CSS
No CSS keyframes needed - all animation is handled by anime.js in `CardAnimations.celebratePair()`.
The animation temporarily sets `z-index: 10` on cards during celebration to ensure the glow shows above adjacent cards. For opponent pairs, you can pass a different color parameter:
```javascript
// Optional: Different color for opponent pairs
celebratePair(cardElement1, cardElement2, isOpponent = false) {
const color = isOpponent
? 'rgba(100, 200, 255, 0.4)' // Blue for opponents
: 'rgba(255, 215, 0, 0.5)'; // Gold for local player
// ... anime.js animation with color ...
}
```
---
## Edge Cases
### Pair Broken Then Reformed
If a swap breaks one pair and creates another:
- Only celebrate the new pair
- Don't mourn the broken pair (no negative feedback)
### Multiple Pairs in One Move
Theoretically possible (swap creates pairs in adjacent columns):
- Celebrate all new pairs simultaneously
- Same sound, same animation on all involved cards
### Pair at Round Start (Initial Flip)
If initial flip creates a pair:
- Yes, celebrate it! Early luck deserves recognition
### Negative Card Pairs (2s, Jokers)
Pairing 2s or Jokers is strategically bad (wastes -2 value), but:
- Still celebrate the pair (it's mechanically correct)
- Player will learn the strategy over time
- Consider: different sound/color for "bad" pairs? (Too complex for V3)
---
## Test Scenarios
1. **Local player creates pair** - Both cards glow, sound plays
2. **Opponent creates pair** - Their cards glow, sound plays
3. **Initial flip creates pair** - Celebration after flip animation
4. **Swap breaks one pair, creates another** - Only new pair celebrates
5. **No pair formed** - No celebration
6. **Face-down card in column** - No false celebration
---
## Acceptance Criteria
- [ ] Swap that creates a pair triggers celebration
- [ ] Flip that creates a pair triggers celebration
- [ ] Both paired cards animate simultaneously
- [ ] Distinct "pair" sound plays
- [ ] Animation is brief (~400ms)
- [ ] Works for local player and opponents
- [ ] No celebration when pair isn't formed
- [ ] No celebration for already-existing pairs
- [ ] Animation doesn't block gameplay
---
## Implementation Order
1. Add `pair` sound to `playSound()` method
2. Add celebration timing to `timing-config.js`
3. Implement `isPaired()` helper method
4. Implement `checkForNewPairs()` method
5. Implement `celebratePair()` method
6. Implement `getCardElements()` helper
7. Add CSS animation for pair celebration
8. Integrate into state change detection
9. Test all pair formation scenarios
10. Tune sound and timing for satisfaction
---
## Notes for Agent
- Add `celebratePair()` method to the existing `CardAnimations` class
- Use anime.js for all animation - no CSS keyframes
- Keep the celebration brief - shouldn't slow down fast players
- The glow color (gold) suggests "success" - matches golf scoring concept
- Consider accessibility: animation should be visible but not overwhelming
- The existing swap animation completes before pair check runs
- Don't celebrate pairs that already existed before the action
- Opponent celebration can use slightly different color (optional parameter)

View File

@@ -0,0 +1,411 @@
# V3-05: Final Turn Urgency
## Overview
When a player reveals all their cards, the round enters "final turn" phase - each other player gets one last turn. This is a tense moment in physical games. Currently, only a small badge shows "Final Turn" which lacks urgency.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Create visual tension when final turn begins
2. Show who triggered final turn (the knocker)
3. Indicate how many players still need to act
4. Make each remaining turn feel consequential
5. Countdown feeling as players take their last turns
---
## Current State
From `app.js`:
```javascript
// Final turn badge exists but is minimal
if (isFinalTurn) {
this.finalTurnBadge.classList.remove('hidden');
} else {
this.finalTurnBadge.classList.add('hidden');
}
```
The badge just shows "FINAL TURN" text - no countdown, no urgency indicator.
---
## Design
### Visual Elements
1. **Pulsing Border** - Game area gets subtle pulsing red/orange border
2. **Enhanced Badge** - Larger badge with countdown
3. **Knocker Indicator** - Show who triggered final turn
4. **Turn Counter** - "2 players remaining" style indicator
### Badge Enhancement
```
Current: [FINAL TURN]
Enhanced: [⚠️ FINAL TURN]
[Player 2 of 3]
```
Or more dramatic:
```
[🔔 LAST CHANCE!]
[2 turns left]
```
### Color Scheme
- Normal play: Green felt background
- Final turn: Subtle warm/orange tint or border pulse
- Not overwhelming, but noticeable shift
---
## Implementation
### Enhanced Final Turn Badge
```html
<!-- Enhanced badge structure -->
<div id="final-turn-badge" class="hidden">
<div class="final-turn-icon"></div>
<div class="final-turn-text">FINAL TURN</div>
<div class="final-turn-remaining">2 turns left</div>
</div>
```
### CSS Enhancements
```css
/* Enhanced final turn badge */
#final-turn-badge {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
color: white;
padding: 12px 24px;
border-radius: 12px;
text-align: center;
z-index: 100;
box-shadow: 0 4px 20px rgba(214, 48, 49, 0.4);
animation: final-turn-pulse 1.5s ease-in-out infinite;
}
#final-turn-badge.hidden {
display: none;
}
.final-turn-icon {
font-size: 1.5em;
margin-bottom: 4px;
}
.final-turn-text {
font-weight: bold;
font-size: 1.2em;
letter-spacing: 0.1em;
}
.final-turn-remaining {
font-size: 0.9em;
opacity: 0.9;
margin-top: 4px;
}
@keyframes final-turn-pulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
box-shadow: 0 4px 20px rgba(214, 48, 49, 0.4);
}
50% {
transform: translate(-50%, -50%) scale(1.02);
box-shadow: 0 4px 30px rgba(214, 48, 49, 0.6);
}
}
/* Game area border pulse during final turn */
#game-screen.final-turn-active {
animation: game-area-urgency 2s ease-in-out infinite;
}
@keyframes game-area-urgency {
0%, 100% {
box-shadow: inset 0 0 0 0 rgba(255, 107, 53, 0);
}
50% {
box-shadow: inset 0 0 30px 0 rgba(255, 107, 53, 0.15);
}
}
/* Knocker highlight */
.player-area.is-knocker,
.opponent-area.is-knocker {
border: 2px solid #ff6b35;
}
.knocker-badge {
position: absolute;
top: -10px;
right: -10px;
background: #ff6b35;
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.7em;
font-weight: bold;
}
```
### JavaScript Updates
```javascript
// In renderGame() or dedicated method
updateFinalTurnDisplay() {
const isFinalTurn = this.gameState?.phase === 'final_turn';
const finisherId = this.gameState?.finisher_id;
// Toggle game area class
this.gameScreen.classList.toggle('final-turn-active', isFinalTurn);
if (isFinalTurn) {
// Calculate remaining turns
const remaining = this.countRemainingTurns();
// Update badge content
this.finalTurnBadge.querySelector('.final-turn-remaining').textContent =
remaining === 1 ? '1 turn left' : `${remaining} turns left`;
// Show badge with entrance animation
this.finalTurnBadge.classList.remove('hidden');
this.finalTurnBadge.classList.add('entering');
setTimeout(() => {
this.finalTurnBadge.classList.remove('entering');
}, 300);
// Mark knocker
this.markKnocker(finisherId);
// Play alert sound on first appearance
if (!this.finalTurnAnnounced) {
this.playSound('alert');
this.finalTurnAnnounced = true;
}
} else {
this.finalTurnBadge.classList.add('hidden');
this.gameScreen.classList.remove('final-turn-active');
this.finalTurnAnnounced = false;
this.clearKnockerMark();
}
}
countRemainingTurns() {
if (!this.gameState || this.gameState.phase !== 'final_turn') return 0;
const finisherId = this.gameState.finisher_id;
const currentIdx = this.gameState.players.findIndex(
p => p.id === this.gameState.current_player_id
);
const finisherIdx = this.gameState.players.findIndex(
p => p.id === finisherId
);
if (currentIdx === -1 || finisherIdx === -1) return 0;
// Count players between current and finisher (not including finisher)
let count = 0;
let idx = currentIdx;
const numPlayers = this.gameState.players.length;
while (idx !== finisherIdx) {
count++;
idx = (idx + 1) % numPlayers;
}
return count;
}
markKnocker(knockerId) {
// Add knocker badge to the player who triggered final turn
this.clearKnockerMark();
if (!knockerId) return;
if (knockerId === this.playerId) {
this.playerArea.classList.add('is-knocker');
// Add badge element
const badge = document.createElement('div');
badge.className = 'knocker-badge';
badge.textContent = 'OUT';
this.playerArea.appendChild(badge);
} else {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${knockerId}"]`
);
if (area) {
area.classList.add('is-knocker');
const badge = document.createElement('div');
badge.className = 'knocker-badge';
badge.textContent = 'OUT';
area.appendChild(badge);
}
}
}
clearKnockerMark() {
// Remove all knocker indicators
document.querySelectorAll('.is-knocker').forEach(el => {
el.classList.remove('is-knocker');
});
document.querySelectorAll('.knocker-badge').forEach(el => {
el.remove();
});
}
```
### Alert Sound
```javascript
// In playSound() method
} else if (type === 'alert') {
// Attention-getting sound for final turn
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'triangle';
osc.frequency.setValueAtTime(523, ctx.currentTime); // C5
osc.frequency.setValueAtTime(659, ctx.currentTime + 0.1); // E5
osc.frequency.setValueAtTime(784, ctx.currentTime + 0.2); // G5
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.4);
}
```
---
## Entrance Animation
When final turn starts, badge should appear dramatically:
```css
#final-turn-badge.entering {
animation: badge-entrance 0.3s ease-out;
}
@keyframes badge-entrance {
0% {
transform: translate(-50%, -50%) scale(0.5);
opacity: 0;
}
70% {
transform: translate(-50%, -50%) scale(1.1);
}
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
```
---
## Turn Countdown Update
Each time a player takes their final turn, update the counter:
```javascript
// In state change detection
if (newState.phase === 'final_turn') {
const oldRemaining = this.lastRemainingTurns;
const newRemaining = this.countRemainingTurns();
if (oldRemaining !== newRemaining) {
this.updateFinalTurnCounter(newRemaining);
this.lastRemainingTurns = newRemaining;
// Pulse the badge on update
this.finalTurnBadge.classList.add('counter-updated');
setTimeout(() => {
this.finalTurnBadge.classList.remove('counter-updated');
}, 200);
}
}
```
```css
#final-turn-badge.counter-updated {
animation: counter-pulse 0.2s ease-out;
}
@keyframes counter-pulse {
0% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-50%, -50%) scale(1.05); }
100% { transform: translate(-50%, -50%) scale(1); }
}
```
---
## Test Scenarios
1. **Enter final turn** - Badge appears with animation, sound plays
2. **Turn counter decrements** - Shows "2 turns left" → "1 turn left"
3. **Last turn** - Shows "1 turn left", extra urgency
4. **Round ends** - Badge disappears, border pulse stops
5. **Knocker marked** - OUT badge on player who triggered
6. **Multiple rounds** - Badge resets between rounds
---
## Acceptance Criteria
- [ ] Final turn badge appears when phase is `final_turn`
- [ ] Badge shows remaining turns count
- [ ] Count updates as players take turns
- [ ] Game area has subtle urgency visual
- [ ] Knocker is marked with badge
- [ ] Alert sound plays when final turn starts
- [ ] Badge has entrance animation
- [ ] All visuals reset when round ends
- [ ] Not overwhelming - tension without annoyance
---
## Implementation Order
1. Update HTML structure for enhanced badge
2. Add CSS for badge, urgency border, knocker indicator
3. Implement `countRemainingTurns()` method
4. Implement `updateFinalTurnDisplay()` method
5. Implement `markKnocker()` and `clearKnockerMark()`
6. Add alert sound to `playSound()`
7. Integrate into `renderGame()` or state change handler
8. Add entrance animation
9. Add counter update pulse
10. Test all scenarios
---
## Notes for Agent
- The urgency should enhance tension, not frustrate players
- Keep the pulsing subtle - not distracting during play
- The knocker badge helps players understand game state
- Consider mobile: badge should fit on small screens
- The remaining turns count helps players plan their last move
- Reset all state between rounds (finalTurnAnnounced flag)

View File

@@ -0,0 +1,376 @@
# V3-06: Opponent Thinking Phase
## Overview
In physical card games, you watch opponents pick up a card, consider it, and decide. Currently, CPU turns happen quickly with minimal visual indication that they're "thinking." This feature adds visible consideration time.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Show when an opponent is considering their move
2. Highlight which pile they're considering (deck vs discard)
3. Add brief thinking pause before CPU actions
4. Make CPU feel more like a real player
5. Human opponents should also show consideration state
---
## Current State
From `app.js` and `card-animations.js`:
```javascript
// In app.js
updateCpuConsideringState() {
const currentPlayer = this.gameState.players.find(
p => p.id === this.gameState.current_player_id
);
const isCpuTurn = currentPlayer && currentPlayer.is_cpu;
const hasNotDrawn = !this.gameState.has_drawn_card;
if (isCpuTurn && hasNotDrawn) {
this.discard.classList.add('cpu-considering');
} else {
this.discard.classList.remove('cpu-considering');
}
}
// CardAnimations already has CPU thinking glow:
startCpuThinking(element) {
anime({
targets: element,
boxShadow: [
'0 4px 12px rgba(0,0,0,0.3)',
'0 4px 12px rgba(0,0,0,0.3), 0 0 18px rgba(59, 130, 246, 0.5)',
'0 4px 12px rgba(0,0,0,0.3)'
],
duration: 1500,
easing: 'easeInOutSine',
loop: true
});
}
```
The existing `startCpuThinking()` method in CardAnimations provides a looping glow animation. This feature enhances visibility further.
---
## Design
### Enhanced Consideration Display
1. **Opponent area highlight** - Active player's area glows
2. **"Thinking" indicator** - Small animation near their name
3. **Deck/discard highlight** - Show which pile they're eyeing
4. **Held card consideration** - After draw, show they're deciding
### States
```
1. WAITING_TO_DRAW
- Player area highlighted
- Deck and discard both subtly available
- Brief pause before action (CPU)
2. CONSIDERING_DISCARD
- Player looks at discard pile
- Discard pile pulses brighter
- "Eye" indicator on discard
3. DREW_CARD
- Held card visible (existing)
- Player area still highlighted
4. CONSIDERING_SWAP
- Player deciding which card to swap
- Their hand cards subtly indicate options
```
### Timing (CPU only)
```javascript
// In timing-config.js
cpuThinking: {
beforeDraw: 800, // Pause before CPU draws
discardConsider: 400, // Extra pause when looking at discard
beforeSwap: 500, // Pause before CPU swaps
beforeDiscard: 300, // Pause before CPU discards drawn card
}
```
Human players don't need artificial pauses - their actual thinking provides the delay.
---
## Implementation
### Thinking Indicator
Add a small animated indicator near the current player's name:
```html
<!-- In opponent area -->
<div class="opponent-area" data-player-id="...">
<h4>
<span class="thinking-indicator hidden">🤔</span>
<span class="opponent-name">Sofia</span>
...
</h4>
</div>
```
### CSS and Animations
Most animations should use anime.js via CardAnimations class for consistency:
```javascript
// In CardAnimations class - the startCpuThinking method already exists
// Add similar methods for other thinking states:
startOpponentThinking(opponentArea) {
const id = `opponentThinking-${opponentArea.dataset.playerId}`;
this.stopOpponentThinking(opponentArea);
anime({
targets: opponentArea,
boxShadow: [
'0 0 15px rgba(244, 164, 96, 0.4)',
'0 0 25px rgba(244, 164, 96, 0.6)',
'0 0 15px rgba(244, 164, 96, 0.4)'
],
duration: 1500,
easing: 'easeInOutSine',
loop: true
});
}
stopOpponentThinking(opponentArea) {
anime.remove(opponentArea);
opponentArea.style.boxShadow = '';
}
```
Minimal CSS for layout only:
```css
/* Thinking indicator - simple show/hide */
.thinking-indicator {
display: inline-block;
margin-right: 4px;
}
.thinking-indicator.hidden {
display: none;
}
/* Current turn highlight base (animation handled by anime.js) */
.opponent-area.current-turn {
border-color: #f4a460;
}
/* Eye indicator positioning */
.pile-eye-indicator {
position: absolute;
top: -15px;
right: -10px;
font-size: 1.2em;
}
```
For the thinking indicator bobbing, use anime.js:
```javascript
// Animate emoji indicator
startThinkingIndicator(element) {
anime({
targets: element,
translateY: [0, -3, 0],
duration: 800,
easing: 'easeInOutSine',
loop: true
});
}
```
### JavaScript Updates
```javascript
// Enhanced consideration state management
updateConsiderationState() {
const currentPlayer = this.gameState?.players?.find(
p => p.id === this.gameState.current_player_id
);
if (!currentPlayer || currentPlayer.id === this.playerId) {
this.clearConsiderationState();
return;
}
const hasDrawn = this.gameState.has_drawn_card;
const isCpu = currentPlayer.is_cpu;
// Find opponent area
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${currentPlayer.id}"]`
);
if (!area) return;
// Show thinking indicator for CPUs
const indicator = area.querySelector('.thinking-indicator');
if (indicator) {
indicator.classList.toggle('hidden', !isCpu || hasDrawn);
}
// Add thinking class to area
area.classList.toggle('thinking', !hasDrawn);
// Show which pile they might be considering
if (!hasDrawn && isCpu) {
// CPU AI hint: check if discard is attractive
const discardValue = this.getDiscardValue();
if (discardValue !== null && discardValue <= 4) {
this.discard.classList.add('being-considered');
this.deck.classList.remove('being-considered');
} else {
this.deck.classList.add('being-considered');
this.discard.classList.remove('being-considered');
}
} else {
this.deck.classList.remove('being-considered');
this.discard.classList.remove('being-considered');
}
}
clearConsiderationState() {
// Remove all consideration indicators
this.opponentsRow.querySelectorAll('.thinking-indicator').forEach(el => {
el.classList.add('hidden');
});
this.opponentsRow.querySelectorAll('.opponent-area').forEach(el => {
el.classList.remove('thinking');
});
this.deck.classList.remove('being-considered');
this.discard.classList.remove('being-considered');
}
getDiscardValue() {
const card = this.gameState?.discard_top;
if (!card) return null;
const values = this.gameState?.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
};
return values[card.rank] ?? 10;
}
```
### Server-Side CPU Thinking Delay
The server should add pauses for CPU thinking (or the client can delay rendering):
```python
# In ai.py or game.py, after CPU makes decision
async def cpu_take_turn(self, game, player_id):
thinking_time = self.profile.get_thinking_time() # 500-1500ms based on profile
# Pre-draw consideration
await asyncio.sleep(thinking_time * 0.5)
# Make draw decision
source = self.decide_draw_source(game, player_id)
# Broadcast "considering" state
await self.broadcast_cpu_considering(game, player_id, source)
await asyncio.sleep(thinking_time * 0.3)
# Execute draw
game.draw_card(player_id, source)
# Post-draw consideration
await asyncio.sleep(thinking_time * 0.4)
# Make swap/discard decision
...
```
Alternatively, handle all delays on the client side by adding pauses before rendering CPU actions.
---
## CPU Personality Integration
Different AI profiles could have different thinking patterns:
```javascript
// Thinking time variance by personality (from ai.py profiles)
const thinkingProfiles = {
'Sofia': { baseTime: 1200, variance: 200 }, // Calculated & Patient
'Maya': { baseTime: 600, variance: 100 }, // Aggressive Closer
'Priya': { baseTime: 1000, variance: 300 }, // Pair Hunter (considers more)
'Marcus': { baseTime: 800, variance: 150 }, // Steady Eddie
'Kenji': { baseTime: 500, variance: 200 }, // Risk Taker (quick)
'Diego': { baseTime: 700, variance: 400 }, // Chaotic Gambler (variable)
'River': { baseTime: 900, variance: 250 }, // Adaptive Strategist
'Sage': { baseTime: 1100, variance: 150 }, // Sneaky Finisher
};
```
---
## Test Scenarios
1. **CPU turn starts** - Area highlights, thinking indicator shows
2. **CPU considering discard** - Discard pile glows if valuable card
3. **CPU draws** - Thinking indicator changes to held card state
4. **CPU swaps** - Brief consideration before swap
5. **Human opponent turn** - Area highlights but no thinking indicator
6. **Local player turn** - No consideration UI (they know what they're doing)
---
## Acceptance Criteria
- [ ] Current opponent's area highlights during their turn
- [ ] CPU players show thinking indicator (emoji)
- [ ] Deck/discard shows which pile CPU is considering
- [ ] Brief pause before CPU actions (feels like thinking)
- [ ] Different CPU personalities have different timing
- [ ] Human opponents highlight without thinking indicator
- [ ] All indicators clear when turn ends
- [ ] Doesn't slow down the game significantly
---
## Implementation Order
1. Add thinking indicator element to opponent areas
2. Add CSS for thinking animations
3. Implement `updateConsiderationState()` method
4. Implement `clearConsiderationState()` method
5. Add pile consideration highlighting
6. Integrate CPU thinking delays (server or client)
7. Test with various CPU profiles
8. Tune timing for natural feel
---
## Notes for Agent
- Use existing CardAnimations methods: `startCpuThinking()`, `stopCpuThinking()`
- Add new methods to CardAnimations for opponent area glow
- Use anime.js for all looping animations, not CSS keyframes
- Keep thinking pauses short enough to not frustrate players
- The goal is to make CPUs feel more human, not slow
- Different profiles should feel distinct in their play speed
- Human players don't need artificial delays
- Consider: Option to speed up CPU thinking? (Future setting)
- The "being considered" pile indicator is a subtle hint at AI logic
- Track animations in `activeAnimations` for proper cleanup

View File

@@ -0,0 +1,484 @@
# V3-07: Animated Score Tallying
## Overview
In physical card games, scoring involves counting cards one by one, noting pairs, and calculating the total. Currently, scores just appear in the scoreboard. This feature adds animated score counting that highlights each card's contribution.
**Dependencies:** V3_03 (Round End Reveal should complete before tallying)
**Dependents:** None
---
## Goals
1. Animate score counting card-by-card
2. Highlight each card as its value is added
3. Show column pairs canceling to zero
4. Running total builds up visibly
5. Special effect for negative cards and pairs
6. Satisfying "final score" reveal
---
## Current State
From `showScoreboard()` in app.js:
```javascript
showScoreboard(scores, isFinal, rankings) {
// Scores appear instantly in table
// No animation of how score was calculated
}
```
The server calculates scores and sends them. The client just displays them.
---
## Design
### Tally Sequence
```
1. Round end reveal completes (V3_03)
2. Brief pause (300ms)
3. For each player (starting with knocker):
a. Highlight player area
b. Count through each column:
- Highlight top card, show value
- Highlight bottom card, show value
- If pair: show "PAIR! +0" effect
- If not pair: add values to running total
c. Show final score with flourish
d. Move to next player
4. Scoreboard slides in with all scores
```
### Visual Elements
- **Card value overlay** - Temporary badge showing card's point value
- **Running total** - Animated counter near player area
- **Pair effect** - Special animation when column pair cancels
- **Final score** - Large number with celebration effect
### Timing
```javascript
// In timing-config.js
tally: {
initialPause: 300, // After reveal, before tally
cardHighlight: 200, // Duration to show each card value
columnPause: 150, // Between columns
pairCelebration: 400, // Pair cancel effect
playerPause: 500, // Between players
finalScoreReveal: 600, // Final score animation
}
```
---
## Implementation
### Card Value Overlay
```javascript
// Create temporary overlay showing card value
showCardValue(cardElement, value, isNegative) {
const overlay = document.createElement('div');
overlay.className = 'card-value-overlay';
if (isNegative) overlay.classList.add('negative');
if (value === 0) overlay.classList.add('zero');
const sign = value > 0 ? '+' : '';
overlay.textContent = `${sign}${value}`;
// Position over the card
const rect = cardElement.getBoundingClientRect();
overlay.style.left = `${rect.left + rect.width / 2}px`;
overlay.style.top = `${rect.top + rect.height / 2}px`;
document.body.appendChild(overlay);
// Animate in
overlay.classList.add('visible');
return overlay;
}
hideCardValue(overlay) {
overlay.classList.remove('visible');
setTimeout(() => overlay.remove(), 200);
}
```
### CSS for Overlays
```css
/* Card value overlay */
.card-value-overlay {
position: fixed;
transform: translate(-50%, -50%) scale(0.5);
background: rgba(30, 30, 46, 0.9);
color: white;
padding: 8px 14px;
border-radius: 8px;
font-size: 1.4em;
font-weight: bold;
opacity: 0;
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
z-index: 200;
pointer-events: none;
}
.card-value-overlay.visible {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
.card-value-overlay.negative {
background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%);
color: white;
}
.card-value-overlay.zero {
background: linear-gradient(135deg, #f4a460 0%, #d4845a 100%);
}
/* Running total */
.running-total {
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 12px;
border-radius: 15px;
font-size: 1.2em;
font-weight: bold;
}
.running-total.updating {
animation: total-bounce 0.2s ease-out;
}
@keyframes total-bounce {
0% { transform: translateX(-50%) scale(1); }
50% { transform: translateX(-50%) scale(1.1); }
100% { transform: translateX(-50%) scale(1); }
}
/* Pair cancel effect */
.pair-cancel-overlay {
position: fixed;
transform: translate(-50%, -50%);
font-size: 1.2em;
font-weight: bold;
color: #f4a460;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
animation: pair-cancel 0.6s ease-out forwards;
z-index: 200;
pointer-events: none;
}
@keyframes pair-cancel {
0% {
transform: translate(-50%, -50%) scale(0.5);
opacity: 0;
}
30% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 1;
}
100% {
transform: translate(-50%, -60%) scale(1);
opacity: 0;
}
}
/* Card highlight during tally */
.card.tallying {
box-shadow: 0 0 15px rgba(244, 164, 96, 0.6);
transform: scale(1.05);
transition: box-shadow 0.1s, transform 0.1s;
}
/* Final score reveal */
.final-score-overlay {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: white;
padding: 20px 40px;
border-radius: 15px;
text-align: center;
z-index: 250;
animation: final-score-reveal 0.6s ease-out forwards;
}
.final-score-overlay .player-name {
font-size: 1em;
opacity: 0.8;
margin-bottom: 5px;
}
.final-score-overlay .score-value {
font-size: 3em;
font-weight: bold;
}
.final-score-overlay .score-value.negative {
color: #27ae60;
}
@keyframes final-score-reveal {
0% {
transform: translate(-50%, -50%) scale(0);
}
60% {
transform: translate(-50%, -50%) scale(1.1);
}
100% {
transform: translate(-50%, -50%) scale(1);
}
}
```
### Main Tally Logic
```javascript
async runScoreTally(players, onComplete) {
const T = window.TIMING?.tally || {};
// Initial pause after reveal
await this.delay(T.initialPause || 300);
// Get card values from game state
const cardValues = this.gameState?.card_values || this.getDefaultCardValues();
// Tally each player
for (const player of players) {
const area = this.getPlayerArea(player.id);
if (!area) continue;
// Highlight player area
area.classList.add('tallying-player');
// Create running total display
const runningTotal = document.createElement('div');
runningTotal.className = 'running-total';
runningTotal.textContent = '0';
area.appendChild(runningTotal);
let total = 0;
const cards = area.querySelectorAll('.card');
// Process each column
const columns = [[0, 3], [1, 4], [2, 5]];
for (const [topIdx, bottomIdx] of columns) {
const topCard = cards[topIdx];
const bottomCard = cards[bottomIdx];
const topData = player.cards[topIdx];
const bottomData = player.cards[bottomIdx];
// Highlight top card
topCard.classList.add('tallying');
const topValue = cardValues[topData.rank] ?? 0;
const topOverlay = this.showCardValue(topCard, topValue, topValue < 0);
await this.delay(T.cardHighlight || 200);
// Highlight bottom card
bottomCard.classList.add('tallying');
const bottomValue = cardValues[bottomData.rank] ?? 0;
const bottomOverlay = this.showCardValue(bottomCard, bottomValue, bottomValue < 0);
await this.delay(T.cardHighlight || 200);
// Check for pair
if (topData.rank === bottomData.rank) {
// Pair! Show cancel effect
this.hideCardValue(topOverlay);
this.hideCardValue(bottomOverlay);
this.showPairCancel(topCard, bottomCard);
await this.delay(T.pairCelebration || 400);
} else {
// Add values to total
total += topValue + bottomValue;
this.updateRunningTotal(runningTotal, total);
this.hideCardValue(topOverlay);
this.hideCardValue(bottomOverlay);
}
// Clear card highlights
topCard.classList.remove('tallying');
bottomCard.classList.remove('tallying');
await this.delay(T.columnPause || 150);
}
// Show final score for this player
await this.showFinalScore(player.name, total);
await this.delay(T.finalScoreReveal || 600);
// Clean up
runningTotal.remove();
area.classList.remove('tallying-player');
await this.delay(T.playerPause || 500);
}
onComplete();
}
showPairCancel(card1, card2) {
// Position between the two cards
const rect1 = card1.getBoundingClientRect();
const rect2 = card2.getBoundingClientRect();
const centerX = (rect1.left + rect1.right + rect2.left + rect2.right) / 4;
const centerY = (rect1.top + rect1.bottom + rect2.top + rect2.bottom) / 4;
const overlay = document.createElement('div');
overlay.className = 'pair-cancel-overlay';
overlay.textContent = 'PAIR! +0';
overlay.style.left = `${centerX}px`;
overlay.style.top = `${centerY}px`;
document.body.appendChild(overlay);
// Pulse both cards
card1.classList.add('pair-matched');
card2.classList.add('pair-matched');
setTimeout(() => {
overlay.remove();
card1.classList.remove('pair-matched');
card2.classList.remove('pair-matched');
}, 600);
this.playSound('pair');
}
updateRunningTotal(element, value) {
element.textContent = value >= 0 ? value : value;
element.classList.add('updating');
setTimeout(() => element.classList.remove('updating'), 200);
}
async showFinalScore(playerName, score) {
const overlay = document.createElement('div');
overlay.className = 'final-score-overlay';
overlay.innerHTML = `
<div class="player-name">${playerName}</div>
<div class="score-value ${score < 0 ? 'negative' : ''}">${score}</div>
`;
document.body.appendChild(overlay);
this.playSound(score < 0 ? 'success' : 'card');
await this.delay(800);
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.3s';
await this.delay(300);
overlay.remove();
}
getDefaultCardValues() {
return {
'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
};
}
```
---
## Integration with Round End
```javascript
// In runRoundEndReveal completion callback
async runRoundEndReveal(oldState, newState, onComplete) {
// ... existing reveal logic ...
// After all reveals complete
await this.runScoreTally(newState.players, () => {
// Now show the scoreboard
onComplete();
});
}
```
---
## Simplified Mode
For faster games, offer a simplified tally that just shows final scores:
```javascript
if (this.settings.quickTally) {
// Just flash the final scores, skip card-by-card
for (const player of players) {
const score = this.calculateScore(player.cards);
await this.showFinalScore(player.name, score);
await this.delay(400);
}
onComplete();
return;
}
```
---
## Test Scenarios
1. **Normal hand** - Values add up correctly
2. **Paired column** - Shows "PAIR! +0" effect
3. **All pairs** - Total is 0, multiple pair celebrations
4. **Negative cards** - Green highlight, reduces total
5. **Multiple players** - Tallies sequentially
6. **Various scores** - Positive, negative, zero
---
## Acceptance Criteria
- [ ] Cards highlight as they're counted
- [ ] Point values show as temporary overlays
- [ ] Running total updates with each card
- [ ] Paired columns show cancel effect
- [ ] Final score has celebration animation
- [ ] Tally order: knocker first, then clockwise
- [ ] Sound effects enhance the experience
- [ ] Total time under 10 seconds for 4 players
- [ ] Scoreboard appears after tally completes
---
## Implementation Order
1. Add tally timing to `timing-config.js`
2. Create CSS for all overlays and animations
3. Implement `showCardValue()` and `hideCardValue()`
4. Implement `showPairCancel()`
5. Implement `updateRunningTotal()`
6. Implement `showFinalScore()`
7. Implement main `runScoreTally()` method
8. Integrate with round end reveal
9. Test various scoring scenarios
10. Add quick tally option
---
## Notes for Agent
- **CSS vs anime.js**: Use CSS for UI overlays (value badges, running total). Use anime.js for card highlight effects.
- Card highlighting can use `window.cardAnimations` methods or simple anime.js calls
- The tally should feel satisfying, not tedious
- Keep individual card highlight times short
- Pair cancellation is a highlight moment - give it emphasis
- Consider accessibility: values should be readable
- The running total helps players follow the math
- Don't forget to handle house rules affecting card values (use `gameState.card_values`)

View File

@@ -0,0 +1,343 @@
# V3-08: Card Hover/Selection Enhancement
## Overview
When holding a drawn card, players must choose which card to swap with. Currently, clicking a card immediately swaps. This feature adds better hover feedback showing the potential swap before committing.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Clear visual preview of the swap before clicking
2. Show where the held card will go
3. Show where the hand card will go (discard)
4. Distinct hover states for face-up vs face-down cards
5. Mobile-friendly (no hover, but clear tap targets)
---
## Current State
From `app.js`:
```javascript
handleCardClick(position) {
// ... if holding drawn card ...
if (this.drawnCard) {
this.animateSwap(position); // Immediately swaps
return;
}
}
```
Cards have basic hover effects in CSS but no swap preview.
---
## Design
### Desktop Hover Preview
When hovering over a hand card while holding a drawn card:
```
1. Hovered card lifts slightly and dims
2. Ghost of held card appears in that slot (semi-transparent)
3. Arrow or line hints at the swap direction
4. "Click to swap" tooltip (optional)
```
### Mobile Tap Preview
Since mobile has no hover:
- First tap = select/highlight the card
- Second tap = confirm swap
- Or: long-press shows preview, release to swap
**Recommendation:** Immediate swap on tap (current behavior) is fine for mobile. Focus on desktop hover preview.
---
## Implementation
### CSS Hover Enhancements
```css
/* Card hover when holding drawn card */
.player-area.can-swap .card {
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
}
.player-area.can-swap .card:hover {
transform: translateY(-5px) scale(1.02);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
}
/* Dimmed state showing "this will be replaced" */
.player-area.can-swap .card:hover::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
border-radius: inherit;
pointer-events: none;
}
/* Ghost preview of incoming card */
.card-ghost-preview {
position: absolute;
opacity: 0.6;
pointer-events: none;
transform: scale(0.95);
z-index: 5;
border: 2px dashed rgba(244, 164, 96, 0.8);
}
/* Swap indicator arrow */
.swap-indicator {
position: absolute;
pointer-events: none;
z-index: 10;
opacity: 0;
transition: opacity 0.15s;
}
.player-area.can-swap .card:hover ~ .swap-indicator {
opacity: 1;
}
/* Different highlight for face-down cards */
.player-area.can-swap .card.card-back:hover {
box-shadow: 0 8px 20px rgba(244, 164, 96, 0.4);
}
/* "Unknown" indicator for face-down hover */
.card.card-back:hover::before {
content: '?';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 2em;
color: rgba(255, 255, 255, 0.5);
}
```
### JavaScript Implementation
```javascript
// Add swap preview functionality
setupSwapPreview() {
this.ghostPreview = document.createElement('div');
this.ghostPreview.className = 'card-ghost-preview hidden';
this.playerCards.appendChild(this.ghostPreview);
}
// Call during render when player is holding a card
updateSwapPreviewState() {
const canSwap = this.drawnCard && this.isMyTurn();
this.playerArea.classList.toggle('can-swap', canSwap);
if (!canSwap) {
this.ghostPreview?.classList.add('hidden');
return;
}
// Set up ghost preview content
if (this.drawnCard && this.ghostPreview) {
this.ghostPreview.className = 'card-ghost-preview card card-front hidden';
if (this.drawnCard.rank === '★') {
this.ghostPreview.classList.add('joker');
} else if (this.isRedSuit(this.drawnCard.suit)) {
this.ghostPreview.classList.add('red');
} else {
this.ghostPreview.classList.add('black');
}
this.ghostPreview.innerHTML = this.renderCardContent(this.drawnCard);
}
}
// Bind hover events to cards
bindCardHoverEvents() {
const cards = this.playerCards.querySelectorAll('.card');
cards.forEach((card, index) => {
card.addEventListener('mouseenter', () => {
if (!this.drawnCard || !this.isMyTurn()) return;
this.showSwapPreview(card, index);
});
card.addEventListener('mouseleave', () => {
this.hideSwapPreview();
});
});
}
showSwapPreview(targetCard, position) {
if (!this.ghostPreview) return;
// Position ghost at target card location
const rect = targetCard.getBoundingClientRect();
const containerRect = this.playerCards.getBoundingClientRect();
this.ghostPreview.style.left = `${rect.left - containerRect.left}px`;
this.ghostPreview.style.top = `${rect.top - containerRect.top}px`;
this.ghostPreview.style.width = `${rect.width}px`;
this.ghostPreview.style.height = `${rect.height}px`;
this.ghostPreview.classList.remove('hidden');
// Highlight target card
targetCard.classList.add('swap-target');
// Show what will happen
this.setStatus(`Swap with position ${position + 1}`, 'swap-preview');
}
hideSwapPreview() {
this.ghostPreview?.classList.add('hidden');
// Remove target highlight
this.playerCards.querySelectorAll('.card').forEach(card => {
card.classList.remove('swap-target');
});
// Restore normal status
this.updateStatusFromGameState();
}
```
### Card Position Labels (Optional Enhancement)
Show position numbers on cards during swap selection:
```css
.player-area.can-swap .card::before {
content: attr(data-position);
position: absolute;
top: -8px;
left: -8px;
width: 18px;
height: 18px;
background: rgba(0, 0, 0, 0.7);
color: white;
border-radius: 50%;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
}
```
```javascript
// In renderGame, add data-position to cards
const cards = this.playerCards.querySelectorAll('.card');
cards.forEach((card, i) => {
card.dataset.position = i + 1;
});
```
---
## Visual Preview Options
### Option A: Ghost Card (Recommended)
Semi-transparent copy of the held card appears over the target slot.
### Option B: Arrow Indicator
Arrow from held card to target slot, and from target to discard.
### Option C: Split Preview
Show both cards side-by-side with swap arrows.
**Recommendation:** Option A is simplest and most intuitive.
---
## Face-Down Card Interaction
When swapping with a face-down card, player is taking a risk:
- Show "?" indicator to emphasize unknown
- Maybe show estimated value range? (Too complex for V3)
- Different hover color (orange = warning)
```css
.player-area.can-swap .card.card-back:hover {
border: 2px solid #f4a460;
}
.player-area.can-swap .card.card-back:hover::after {
content: 'Unknown';
position: absolute;
bottom: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 0.7em;
color: #f4a460;
white-space: nowrap;
}
```
---
## Test Scenarios
1. **Hover over face-up card** - Shows preview, card lifts
2. **Hover over face-down card** - Shows warning styling
3. **Move between cards** - Preview updates smoothly
4. **Mouse leaves card area** - Preview disappears
5. **Not holding card** - No special hover effects
6. **Not my turn** - No hover effects
7. **Mobile tap** - Works without preview (existing behavior)
---
## Acceptance Criteria
- [ ] Cards lift on hover when holding drawn card
- [ ] Ghost preview shows incoming card
- [ ] Face-down cards have distinct hover (unknown warning)
- [ ] Preview disappears on mouse leave
- [ ] No effects when not holding card
- [ ] No effects when not your turn
- [ ] Mobile tap still works normally
- [ ] Smooth transitions, no jank
---
## Implementation Order
1. Add `can-swap` class toggle to player area
2. Add CSS for hover lift effect
3. Create ghost preview element
4. Implement `showSwapPreview()` method
5. Implement `hideSwapPreview()` method
6. Bind mouseenter/mouseleave events
7. Add face-down card distinct styling
8. Test on desktop and mobile
9. Optional: Add position labels
---
## Notes for Agent
- **CSS vs anime.js**: CSS is appropriate for simple hover effects (performant, no JS overhead)
- Keep hover effects performant (CSS transforms preferred)
- Don't break existing click-to-swap behavior
- Mobile should work exactly as before (immediate swap)
- Consider reduced motion preferences
- The ghost preview should match the actual card appearance
- Position labels help new players understand the grid

View File

@@ -0,0 +1,451 @@
# V3-09: Knock Early Drama
## Overview
The "Knock Early" house rule lets players flip all remaining face-down cards (if 2 or fewer) to immediately trigger final turn. This is a high-risk, high-reward move that deserves dramatic presentation.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Make knock early feel dramatic and consequential
2. Show confirmation dialog (optional - it's risky!)
3. Dramatic animation when knock happens
4. Clear feedback showing the decision
5. Other players see "Player X knocked early!"
---
## Current State
From `app.js`:
```javascript
knockEarly() {
if (!this.gameState || !this.gameState.knock_early) return;
this.send({ type: 'knock_early' });
this.hideToast();
}
```
The knock early button exists but there's no special visual treatment.
---
## Design
### Knock Early Flow
```
1. Player clicks "Knock Early" button
2. Confirmation prompt: "Reveal your hidden cards and go out?"
3. If confirmed:
a. Dramatic sound effect
b. Player's hidden cards flip rapidly in sequence
c. "KNOCK!" banner appears
d. Final turn badge triggers
4. Other players see announcement
```
### Visual Elements
- **Confirmation dialog** - "Are you sure?" with preview
- **Rapid flip animation** - Cards flip faster than normal
- **"KNOCK!" banner** - Large dramatic announcement
- **Screen shake** (subtle) - Impact feeling
---
## Implementation
### Confirmation Dialog
```javascript
knockEarly() {
if (!this.gameState || !this.gameState.knock_early) return;
// Count hidden cards
const myData = this.getMyPlayerData();
const hiddenCards = myData.cards.filter(c => !c.face_up);
if (hiddenCards.length === 0 || hiddenCards.length > 2) {
return; // Can't knock
}
// Show confirmation
this.showKnockConfirmation(hiddenCards.length, () => {
this.executeKnockEarly();
});
}
showKnockConfirmation(hiddenCount, onConfirm) {
// Create modal
const modal = document.createElement('div');
modal.className = 'knock-confirm-modal';
modal.innerHTML = `
<div class="knock-confirm-content">
<div class="knock-confirm-icon">⚡</div>
<h3>Knock Early?</h3>
<p>You'll reveal ${hiddenCount} hidden card${hiddenCount > 1 ? 's' : ''} and trigger final turn.</p>
<p class="knock-warning">This cannot be undone!</p>
<div class="knock-confirm-buttons">
<button class="btn btn-secondary knock-cancel">Cancel</button>
<button class="btn btn-primary knock-confirm">Knock!</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Bind events
modal.querySelector('.knock-cancel').addEventListener('click', () => {
this.playSound('click');
modal.remove();
});
modal.querySelector('.knock-confirm').addEventListener('click', () => {
this.playSound('click');
modal.remove();
onConfirm();
});
// Click outside to cancel
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
async executeKnockEarly() {
// Play dramatic sound
this.playSound('knock');
// Get positions of hidden cards
const myData = this.getMyPlayerData();
const hiddenPositions = myData.cards
.map((card, i) => ({ card, position: i }))
.filter(({ card }) => !card.face_up)
.map(({ position }) => position);
// Start rapid flip animation
await this.animateKnockFlips(hiddenPositions);
// Show KNOCK banner
this.showKnockBanner();
// Send to server
this.send({ type: 'knock_early' });
this.hideToast();
}
async animateKnockFlips(positions) {
// Rapid sequential flips
const flipDelay = 150; // Faster than normal
for (const position of positions) {
const myData = this.getMyPlayerData();
const card = myData.cards[position];
this.fireLocalFlipAnimation(position, card);
this.playSound('flip');
await this.delay(flipDelay);
}
// Wait for last flip
await this.delay(300);
}
showKnockBanner() {
const banner = document.createElement('div');
banner.className = 'knock-banner';
banner.innerHTML = '<span>KNOCK!</span>';
document.body.appendChild(banner);
// Screen shake effect
document.body.classList.add('screen-shake');
// Remove after animation
setTimeout(() => {
banner.classList.add('fading');
document.body.classList.remove('screen-shake');
}, 800);
setTimeout(() => {
banner.remove();
}, 1100);
}
```
### CSS
```css
/* Knock confirmation modal */
.knock-confirm-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
animation: modal-fade-in 0.2s ease-out;
}
@keyframes modal-fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
.knock-confirm-content {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
padding: 30px;
border-radius: 15px;
text-align: center;
max-width: 320px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
animation: modal-scale-in 0.2s ease-out;
}
@keyframes modal-scale-in {
0% { transform: scale(0.9); }
100% { transform: scale(1); }
}
.knock-confirm-icon {
font-size: 3em;
margin-bottom: 10px;
}
.knock-confirm-content h3 {
margin: 0 0 15px;
color: #f4a460;
}
.knock-confirm-content p {
margin: 0 0 10px;
color: rgba(255, 255, 255, 0.8);
}
.knock-warning {
color: #e74c3c !important;
font-size: 0.9em;
}
.knock-confirm-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.knock-confirm-buttons .btn {
flex: 1;
}
/* KNOCK banner */
.knock-banner {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
z-index: 400;
pointer-events: none;
animation: knock-banner-in 0.3s ease-out forwards;
}
.knock-banner span {
display: block;
font-size: 4em;
font-weight: 900;
color: #f4a460;
text-shadow:
0 0 20px rgba(244, 164, 96, 0.8),
0 0 40px rgba(244, 164, 96, 0.4),
2px 2px 0 #1a1a2e;
letter-spacing: 0.2em;
}
@keyframes knock-banner-in {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 0;
}
50% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
.knock-banner.fading {
animation: knock-banner-out 0.3s ease-out forwards;
}
@keyframes knock-banner-out {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 0;
}
}
/* Screen shake effect */
@keyframes screen-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-3px); }
40% { transform: translateX(3px); }
60% { transform: translateX(-2px); }
80% { transform: translateX(2px); }
}
body.screen-shake {
animation: screen-shake 0.3s ease-out;
}
/* Enhanced knock early button */
#knock-early-btn {
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
animation: knock-btn-pulse 2s ease-in-out infinite;
}
@keyframes knock-btn-pulse {
0%, 100% {
box-shadow: 0 2px 10px rgba(214, 48, 49, 0.3);
}
50% {
box-shadow: 0 2px 20px rgba(214, 48, 49, 0.5);
}
}
```
### Knock Sound
```javascript
// In playSound() method
} else if (type === 'knock') {
// Dramatic "knock" sound - low thud
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(80, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.4, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.2);
// Secondary impact
setTimeout(() => {
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(ctx.destination);
osc2.type = 'sine';
osc2.frequency.setValueAtTime(60, ctx.currentTime);
gain2.gain.setValueAtTime(0.2, ctx.currentTime);
gain2.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
osc2.start(ctx.currentTime);
osc2.stop(ctx.currentTime + 0.1);
}, 100);
}
```
### Opponent Sees Knock
When another player knocks, show announcement:
```javascript
// In state change detection or game_state handler
if (newState.phase === 'final_turn' && oldState?.phase !== 'final_turn') {
const knocker = newState.players.find(p => p.id === newState.finisher_id);
if (knocker && knocker.id !== this.playerId) {
// Someone else knocked
this.showOpponentKnockAnnouncement(knocker.name);
}
}
showOpponentKnockAnnouncement(playerName) {
this.playSound('alert');
const banner = document.createElement('div');
banner.className = 'opponent-knock-banner';
banner.innerHTML = `<span>${playerName} knocked!</span>`;
document.body.appendChild(banner);
setTimeout(() => {
banner.classList.add('fading');
}, 1500);
setTimeout(() => {
banner.remove();
}, 1800);
}
```
---
## Test Scenarios
1. **Knock with 1 hidden card** - Single flip, then knock banner
2. **Knock with 2 hidden cards** - Rapid double flip
3. **Cancel confirmation** - Modal closes, no action
4. **Opponent knocks** - See announcement
5. **Can't knock (3+ hidden)** - Button disabled
6. **Can't knock (all face-up)** - Button disabled
---
## Acceptance Criteria
- [ ] Confirmation dialog appears before knock
- [ ] Dialog shows number of cards to reveal
- [ ] Cancel button works
- [ ] Knock triggers rapid flip animation
- [ ] "KNOCK!" banner appears with fanfare
- [ ] Subtle screen shake effect
- [ ] Other players see announcement
- [ ] Final turn triggers after knock
- [ ] Sound effects enhance the drama
---
## Implementation Order
1. Add knock sound to `playSound()`
2. Implement `showKnockConfirmation()` method
3. Implement `executeKnockEarly()` method
4. Implement `animateKnockFlips()` method
5. Implement `showKnockBanner()` method
6. Add CSS for modal and banner
7. Implement opponent knock announcement
8. Add screen shake effect
9. Test all scenarios
10. Tune timing for maximum drama
---
## Notes for Agent
- **CSS vs anime.js**: CSS is fine for modal/button animations (UI chrome). Screen shake can use anime.js for precision.
- The confirmation prevents accidental knocks (it's irreversible)
- Keep animation fast - drama without delay
- The screen shake should be subtle (accessibility)
- Consider: skip confirmation option for experienced players?
- Make sure knock works even if animations fail

View File

@@ -0,0 +1,394 @@
# V3-10: Column Pair Indicator
## Overview
When two cards in a column match (forming a pair that scores 0), there's currently no persistent visual indicator. This feature adds a subtle connector showing paired columns at a glance.
**Dependencies:** V3_04 (Column Pair Celebration - this builds on that)
**Dependents:** None
---
## Goals
1. Show which columns are currently paired
2. Visual connector between paired cards
3. Score indicator showing "+0" or "locked"
4. Don't clutter the interface
5. Help new players understand pairing
---
## Current State
After V3_04 (celebration), pairs get a brief animation when formed. But after that animation, there's no indication which columns are paired. Players must remember or scan visually.
---
## Design
### Visual Options
**Option A: Connecting Line**
Draw a subtle line or bracket connecting paired cards.
**Option B: Shared Glow**
Both cards have a subtle shared glow color.
**Option C: Zero Badge**
Small "0" badge on the column.
**Option D: Lock Icon**
Small lock icon indicating "locked in" pair.
**Recommendation:** Option A (line) + Option C (badge) - clear and informative.
### Visual Treatment
```
Normal columns: Paired column:
┌───┐ ┌───┐ ┌───┐ ─┐
│ K │ │ 7 │ │ 5 │ │ [0]
└───┘ └───┘ └───┘ │
┌───┐ ┌───┐ ┌───┐ ─┘
│ Q │ │ 3 │ │ 5 │
└───┘ └───┘ └───┘
```
---
## Implementation
### Detecting Pairs
```javascript
getColumnPairs(cards) {
const pairs = [];
const columns = [[0, 3], [1, 4], [2, 5]];
for (let i = 0; i < columns.length; i++) {
const [top, bottom] = columns[i];
const topCard = cards[top];
const bottomCard = cards[bottom];
if (topCard?.face_up && bottomCard?.face_up &&
topCard?.rank && topCard.rank === bottomCard?.rank) {
pairs.push({
column: i,
topPosition: top,
bottomPosition: bottom,
rank: topCard.rank
});
}
}
return pairs;
}
```
### Rendering Pair Indicators
```javascript
renderPairIndicators(playerId, cards) {
const pairs = this.getColumnPairs(cards);
const container = this.getPairIndicatorContainer(playerId);
// Clear existing indicators
container.innerHTML = '';
if (pairs.length === 0) return;
const cardElements = this.getCardElements(playerId);
for (const pair of pairs) {
const topCard = cardElements[pair.topPosition];
const bottomCard = cardElements[pair.bottomPosition];
if (!topCard || !bottomCard) continue;
// Create connector line
const connector = this.createPairConnector(topCard, bottomCard, pair.column);
container.appendChild(connector);
// Add paired class to cards
topCard.classList.add('paired');
bottomCard.classList.add('paired');
}
}
createPairConnector(topCard, bottomCard, columnIndex) {
const connector = document.createElement('div');
connector.className = 'pair-connector';
connector.dataset.column = columnIndex;
// Calculate position
const topRect = topCard.getBoundingClientRect();
const bottomRect = bottomCard.getBoundingClientRect();
const containerRect = topCard.closest('.card-grid').getBoundingClientRect();
// Position connector to the right of the column
const x = topRect.right - containerRect.left + 5;
const y = topRect.top - containerRect.top;
const height = bottomRect.bottom - topRect.top;
connector.style.cssText = `
left: ${x}px;
top: ${y}px;
height: ${height}px;
`;
// Add zero badge
const badge = document.createElement('div');
badge.className = 'pair-badge';
badge.textContent = '0';
connector.appendChild(badge);
return connector;
}
getPairIndicatorContainer(playerId) {
// Get or create indicator container
const area = playerId === this.playerId
? this.playerCards
: this.opponentsRow.querySelector(`[data-player-id="${playerId}"] .card-grid`);
if (!area) return document.createElement('div'); // Fallback
let container = area.querySelector('.pair-indicators');
if (!container) {
container = document.createElement('div');
container.className = 'pair-indicators';
area.style.position = 'relative';
area.appendChild(container);
}
return container;
}
```
### CSS
```css
/* Pair indicators container */
.pair-indicators {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 5;
}
/* Connector line */
.pair-connector {
position: absolute;
width: 3px;
background: linear-gradient(180deg,
rgba(244, 164, 96, 0.6) 0%,
rgba(244, 164, 96, 0.8) 50%,
rgba(244, 164, 96, 0.6) 100%
);
border-radius: 2px;
}
/* Bracket style alternative */
.pair-connector::before,
.pair-connector::after {
content: '';
position: absolute;
left: 0;
width: 8px;
height: 3px;
background: rgba(244, 164, 96, 0.6);
}
.pair-connector::before {
top: 0;
border-radius: 2px 0 0 0;
}
.pair-connector::after {
bottom: 0;
border-radius: 0 0 0 2px;
}
/* Zero badge */
.pair-badge {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #f4a460;
color: #1a1a2e;
font-size: 0.7em;
font-weight: bold;
padding: 2px 6px;
border-radius: 10px;
white-space: nowrap;
}
/* Paired card subtle highlight */
.card.paired {
box-shadow: 0 0 8px rgba(244, 164, 96, 0.3);
}
/* Opponent paired cards - smaller/subtler */
.opponent-area .pair-connector {
width: 2px;
}
.opponent-area .pair-badge {
font-size: 0.6em;
padding: 1px 4px;
}
.opponent-area .card.paired {
box-shadow: 0 0 5px rgba(244, 164, 96, 0.2);
}
```
### Integration with renderGame
```javascript
// In renderGame(), after rendering cards
renderGame() {
// ... existing rendering ...
// Update pair indicators for all players
for (const player of this.gameState.players) {
this.renderPairIndicators(player.id, player.cards);
}
}
```
### Handling Window Resize
Pair connectors are positioned absolutely, so they need updating on resize:
```javascript
constructor() {
// ... existing constructor ...
// Debounced resize handler for pair indicators
window.addEventListener('resize', this.debounce(() => {
if (this.gameState) {
for (const player of this.gameState.players) {
this.renderPairIndicators(player.id, player.cards);
}
}
}, 100));
}
debounce(fn, delay) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), delay);
};
}
```
---
## Alternative: CSS-Only Approach
Simpler approach using only CSS classes:
```javascript
// In renderGame, just add classes
for (const player of this.gameState.players) {
const pairs = this.getColumnPairs(player.cards);
const cards = this.getCardElements(player.id);
// Clear previous
cards.forEach(c => c.classList.remove('paired', 'pair-top', 'pair-bottom'));
for (const pair of pairs) {
cards[pair.topPosition]?.classList.add('paired', 'pair-top');
cards[pair.bottomPosition]?.classList.add('paired', 'pair-bottom');
}
}
```
```css
/* CSS-only pair indication */
.card.pair-top {
border-bottom: 3px solid #f4a460;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.card.pair-bottom {
border-top: 3px solid #f4a460;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.card.paired::after {
content: '';
position: absolute;
right: -10px;
top: 0;
bottom: 0;
width: 3px;
background: rgba(244, 164, 96, 0.5);
}
.card.pair-bottom::after {
top: -100%; /* Extend up to connect */
}
```
**Recommendation:** Start with CSS-only approach. Add connector elements if more visual clarity needed.
---
## Test Scenarios
1. **Single pair** - One column shows indicator
2. **Multiple pairs** - Multiple indicators (rare but possible)
3. **No pairs** - No indicators
4. **Pair broken** - Indicator disappears
5. **Pair formed** - Indicator appears (after celebration)
6. **Face-down card in column** - No indicator
7. **Opponent pairs** - Smaller indicators visible
---
## Acceptance Criteria
- [ ] Paired columns show visual connector
- [ ] "0" badge indicates the score contribution
- [ ] Indicators update when cards change
- [ ] Works for local player and opponents
- [ ] Smaller/subtler for opponents
- [ ] Handles window resize
- [ ] Doesn't clutter interface
- [ ] Helps new players understand pairing
---
## Implementation Order
1. Implement `getColumnPairs()` method
2. Choose approach: CSS-only or connector elements
3. If connector: implement `createPairConnector()`
4. Add CSS for indicators
5. Integrate into `renderGame()`
6. Add resize handling
7. Test various pair scenarios
8. Adjust styling for opponents
---
## Notes for Agent
- **CSS vs anime.js**: CSS is appropriate for static indicators (not animated elements)
- Keep indicators subtle - informative not distracting
- Opponent indicators should be smaller/lighter
- CSS-only approach is simpler to maintain
- The badge helps players learning the scoring system
- Consider: toggle option to hide indicators? (For experienced players)
- Make sure indicators don't overlap cards on mobile

View File

@@ -0,0 +1,317 @@
# V3-11: Swap Animation Improvements
## Overview
When swapping a drawn card with a hand card, the current animation uses a "flip in place + teleport" approach. Physical card games have cards that slide past each other. This feature improves the swap animation to feel more physical.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Cards visibly exchange positions (not teleport)
2. Old card slides toward discard
3. New card slides into hand slot
4. Brief "crossing" moment visible
5. Smooth, performant animation
6. Works for both face-up and face-down swaps
---
## Current State
From `card-animations.js` (CardAnimations class):
```javascript
// Current swap uses anime.js with pulse effect for face-up swaps
// and flip animation for face-down swaps
animateSwap(position, oldCard, newCard, handCardElement, onComplete) {
if (isAlreadyFaceUp) {
// Face-up swap: subtle pulse, no flip needed
this._animateFaceUpSwap(handCardElement, onComplete);
} else {
// Face-down swap: flip reveal then swap
this._animateFaceDownSwap(position, oldCard, handCardElement, onComplete);
}
}
_animateFaceUpSwap(handCardElement, onComplete) {
anime({
targets: handCardElement,
scale: [1, 0.92, 1.08, 1],
filter: ['brightness(1)', 'brightness(0.85)', 'brightness(1.15)', 'brightness(1)'],
duration: 400,
easing: 'easeOutQuad'
});
}
```
The current animation uses a pulse effect for face-up swaps and a flip reveal for face-down swaps. It works but lacks the physical feeling of cards moving past each other.
---
## Design
### Animation Sequence
```
1. If face-down: Flip hand card to reveal (existing)
2. Lift both cards slightly (z-index, shadow)
3. Hand card arcs toward discard pile
4. Held card arcs toward hand slot
5. Cards cross paths visually (middle of arc)
6. Cards land at destinations
7. Landing pulse effect
```
### Arc Paths
Instead of straight lines, cards follow curved paths:
```
Hand card path
╭─────────────────╮
│ │
[Hand] [Discard]
│ │
╰─────────────────╯
Held card path
```
The curves create a visual "exchange" moment.
---
## Implementation
### Enhanced Swap Animation (Add to CardAnimations class)
```javascript
// In card-animations.js - enhance the existing animateSwap method
async animatePhysicalSwap(handCardEl, heldCardEl, handRect, discardRect, holdingRect, onComplete) {
const T = window.TIMING?.swap || {
lift: 80,
arc: 280,
settle: 60,
};
// Create animation elements that will travel
const travelingHandCard = this.createTravelingCard(handCardEl);
const travelingHeldCard = this.createTravelingCard(heldCardEl);
document.body.appendChild(travelingHandCard);
document.body.appendChild(travelingHeldCard);
// Position at start
this.positionAt(travelingHandCard, handRect);
this.positionAt(travelingHeldCard, holdingRect || discardRect);
// Hide originals
handCardEl.style.visibility = 'hidden';
heldCardEl.style.visibility = 'hidden';
this.playSound('card');
// Use anime.js timeline for coordinated arc movement
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
travelingHandCard.remove();
travelingHeldCard.remove();
handCardEl.style.visibility = 'visible';
heldCardEl.style.visibility = 'visible';
this.pulseDiscard();
if (onComplete) onComplete();
}
});
// Calculate arc midpoints
const midY1 = (handRect.top + discardRect.top) / 2 - 40; // Arc up
const midY2 = ((holdingRect || discardRect).top + handRect.top) / 2 + 40; // Arc down
// Step 1: Lift both cards with shadow increase
timeline.add({
targets: [travelingHandCard, travelingHeldCard],
translateY: -10,
boxShadow: '0 8px 30px rgba(0, 0, 0, 0.5)',
scale: 1.02,
duration: T.lift,
easing: this.getEasing('lift')
});
// Step 2: Hand card arcs to discard
timeline.add({
targets: travelingHandCard,
left: discardRect.left,
top: [
{ value: midY1, duration: T.arc / 2 },
{ value: discardRect.top, duration: T.arc / 2 }
],
rotate: [0, -5, 0],
duration: T.arc,
}, `-=${T.lift / 2}`);
// Held card arcs to hand (in parallel)
timeline.add({
targets: travelingHeldCard,
left: handRect.left,
top: [
{ value: midY2, duration: T.arc / 2 },
{ value: handRect.top, duration: T.arc / 2 }
],
rotate: [0, 5, 0],
duration: T.arc,
}, `-=${T.arc + T.lift / 2}`);
// Step 3: Settle
timeline.add({
targets: [travelingHandCard, travelingHeldCard],
translateY: 0,
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.3)',
scale: 1,
duration: T.settle,
});
this.activeAnimations.set('physicalSwap', timeline);
}
createTravelingCard(sourceCard) {
const clone = sourceCard.cloneNode(true);
clone.className = 'traveling-card';
clone.style.position = 'fixed';
clone.style.pointerEvents = 'none';
clone.style.zIndex = '1000';
clone.style.borderRadius = '6px';
return clone;
}
positionAt(element, rect) {
element.style.left = `${rect.left}px`;
element.style.top = `${rect.top}px`;
element.style.width = `${rect.width}px`;
element.style.height = `${rect.height}px`;
}
```
### CSS for Traveling Cards
Minimal CSS needed - anime.js handles all animation properties including box-shadow and scale:
```css
/* Traveling card during swap - base styles only */
.traveling-card {
position: fixed;
border-radius: 6px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
/* All animation handled by anime.js */
}
```
### Timing Configuration
```javascript
// In timing-config.js
swap: {
lift: 80, // Time to lift cards
arc: 280, // Time for arc travel
settle: 60, // Time to settle into place
// Total: ~420ms (similar to current)
}
```
### Note on Animation Approach
All swap animations use anime.js timelines, not CSS transitions or Web Animations API. This provides:
- Better coordination between multiple elements
- Consistent with rest of animation system
- Easier timing control via `window.TIMING`
- Proper animation cancellation via `activeAnimations` tracking
---
## Integration Points
### For Local Player Swap
```javascript
// In animateSwap() method
animateSwap(position) {
const cardElements = this.playerCards.querySelectorAll('.card');
const handCardEl = cardElements[position];
// Get positions
const handRect = handCardEl.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
const holdingRect = this.getHoldingRect();
// If face-down, flip first (existing logic)
// ...
// Then do physical swap
this.animatePhysicalSwap(
handCardEl,
this.heldCardFloating,
handRect,
discardRect,
holdingRect
);
}
```
### For Opponent Swap
The opponent swap animation in `fireSwapAnimation()` can use similar arc logic for the visible card traveling to discard.
---
## Test Scenarios
1. **Swap face-up card** - Direct arc exchange
2. **Swap face-down card** - Flip first, then arc
3. **Fast repeated swaps** - No animation overlap
4. **Mobile** - Animation performs at 60fps
5. **Different screen sizes** - Arcs scale appropriately
---
## Acceptance Criteria
- [ ] Cards visibly travel to new positions (not teleport)
- [ ] Arc paths create "crossing" visual
- [ ] Lift and settle effects enhance physicality
- [ ] Animation total time ~400ms (not slower than current)
- [ ] Works for face-up and face-down cards
- [ ] Performant on mobile (60fps)
- [ ] Landing effect on discard pile
- [ ] Opponent swaps also improved
---
## Implementation Order
1. Add swap timing to `timing-config.js`
2. Implement `createTravelingCard()` helper
3. Implement `animateArc()` with Web Animations API
4. Implement `animatePhysicalSwap()` method
5. Add CSS for traveling cards
6. Integrate with local player swap
7. Integrate with opponent swap animation
8. Test on various devices
9. Tune arc height and timing
---
## Notes for Agent
- Add `animatePhysicalSwap()` to the existing CardAnimations class
- Use anime.js timelines for coordinated multi-element animation
- Arc height should scale with card distance
- The "crossing" moment is the key visual improvement
- Keep total animation time similar to current (~400ms)
- Track animation in `activeAnimations` for proper cancellation
- Consider: option for "fast mode" with simpler animations?
- Make sure sound timing aligns with visual (card leaving hand)
- Existing `animateSwap()` can call this new method internally

View File

@@ -0,0 +1,279 @@
# V3-12: Draw Source Distinction
## Overview
Drawing from the deck (face-down, unknown) vs discard (face-up, known) should feel different. Currently both animations are similar. This feature enhances the visual distinction.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Deck draw: Card emerges face-down, then flips
2. Discard draw: Card lifts straight up (already visible)
3. Different sound for each source
4. Visual hint about the strategic difference
5. Help new players understand the two options
---
## Current State
From `card-animations.js` (CardAnimations class):
```javascript
// Deck draw: suspenseful pause + flip reveal
animateDrawDeck(cardData, onComplete) {
// Pulse deck, lift card face-down, move to holding, suspense pause, flip
timeline.add({ targets: inner, rotateY: 0, duration: 245 });
}
// Discard draw: quick decisive grab
animateDrawDiscard(cardData, onComplete) {
// Pulse discard, quick lift, direct move to holding (no flip needed)
timeline.add({ targets: animCard, translateY: -12, scale: 1.05, duration: 42 });
}
```
The distinction exists and is already fairly pronounced. This feature enhances it further with:
- More distinct sounds for each source
- Visual "shuffleDeckVisual" effect when drawing from deck
- Better timing contrast
---
## Design
### Deck Draw (Unknown)
```
1. Deck "shuffles" slightly (optional)
2. Top card lifts off deck
3. Card floats to holding position (face-down)
4. Brief suspense pause
5. Card flips to reveal
6. Sound: "mysterious" flip sound
```
### Discard Draw (Known)
```
1. Card lifts directly (quick)
2. No flip needed - already visible
3. Moves to holding position
4. "Picked up" visual on discard pile
5. Sound: quick "pick" sound
```
### Visual Distinction
| Aspect | Deck Draw | Discard Draw |
|--------|-----------|--------------|
| Card state | Face-down → Face-up | Face-up entire time |
| Motion | Float + flip | Direct lift |
| Sound | Suspenseful flip | Quick pick |
| Duration | Longer (suspense) | Shorter (decisive) |
| Deck visual | Cards shuffle | N/A |
| Discard visual | N/A | "Picked up" state |
---
## Implementation
### Enhanced Deck Draw
The existing `animateDrawDeck()` in `card-animations.js` already has most of this functionality. Enhancements to add:
```javascript
// In card-animations.js - enhance existing animateDrawDeck
// The current implementation already:
// - Pulses deck before drawing (startDrawPulse)
// - Lifts card with wobble
// - Adds suspense pause before flip
// - Flips to reveal with sound
// Add distinct sound for deck draws:
animateDrawDeck(cardData, onComplete) {
// ... existing code ...
// Change sound from 'card' to 'draw-deck' for more mysterious feel
this.playSound('draw-deck'); // Instead of 'card'
// ... rest of existing code ...
}
// The shuffleDeckVisual already exists as startDrawPulse:
startDrawPulse(element) {
if (!element) return;
element.classList.add('draw-pulse');
setTimeout(() => {
element.classList.remove('draw-pulse');
}, 450);
}
```
**Key existing features:**
- `startDrawPulse()` - gold ring pulse effect
- Suspense pause of 200ms before flip
- Flip duration 245ms with `easeInOutQuad` easing
### Enhanced Discard Draw
The existing `animateDrawDiscard()` in `card-animations.js` already has quick, decisive animation:
```javascript
// Current implementation already does:
// - Pulses discard before picking up (startDrawPulse)
// - Quick lift (42ms) with scale
// - Direct move (126ms) - much faster than deck draw
// - No flip needed (card already face-up)
// Enhancement: Add distinct sound for discard draws
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
// ... existing code ...
// Change sound from 'card' to 'draw-discard' for decisive feel
this.playSound('draw-discard'); // Instead of 'card'
// ... rest of existing code ...
}
```
**Current timing comparison (already implemented):**
| Phase | Deck Draw | Discard Draw |
|-------|-----------|--------------|
| Pulse delay | 250ms | 200ms |
| Lift | 105ms | 42ms |
| Travel | 175ms | 126ms |
| Suspense | 200ms | 0ms |
| Flip | 245ms | 0ms |
| Settle | 150ms | 80ms |
| **Total** | **~1125ms** | **~448ms** |
The distinction is already pronounced - discard draw is ~2.5x faster.
### Deck Visual Effects
The `draw-pulse` class already exists with a CSS animation (gold ring expanding). For additional deck depth effect, use CSS only:
```css
/* Deck "depth" visual - multiple card shadows */
#deck {
box-shadow:
1px 1px 0 0 rgba(0, 0, 0, 0.1),
2px 2px 0 0 rgba(0, 0, 0, 0.1),
3px 3px 0 0 rgba(0, 0, 0, 0.1),
4px 4px 8px rgba(0, 0, 0, 0.3);
}
/* Existing draw-pulse animation handles the visual feedback */
.draw-pulse {
/* Already defined in style.css */
}
```
### Distinct Sounds
```javascript
// In playSound() method
} else if (type === 'draw-deck') {
// Mysterious "what's this?" sound
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'triangle';
osc.frequency.setValueAtTime(300, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(500, ctx.currentTime + 0.1);
osc.frequency.exponentialRampToValueAtTime(350, ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.08, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.2);
} else if (type === 'draw-discard') {
// Quick decisive "grab" sound
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'square';
osc.frequency.setValueAtTime(600, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + 0.05);
gain.gain.setValueAtTime(0.08, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.06);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.06);
}
```
---
## Timing Comparison
| Phase | Deck Draw | Discard Draw |
|-------|-----------|--------------|
| Lift | 150ms | 80ms |
| Travel | 250ms | 200ms |
| Suspense | 200ms | 0ms |
| Flip | 350ms | 0ms |
| Settle | 150ms | 80ms |
| **Total** | **~1100ms** | **~360ms** |
Deck draw is intentionally longer to build suspense.
---
## Test Scenarios
1. **Draw from deck** - Longer animation with flip
2. **Draw from discard** - Quick decisive grab
3. **Rapid alternating draws** - Animations don't conflict
4. **CPU draws** - Same visual distinction
---
## Acceptance Criteria
- [ ] Deck draw has suspenseful pause before flip
- [ ] Discard draw is quick and direct
- [ ] Different sounds for each source
- [ ] Deck shows visual "dealing" effect
- [ ] Timing difference is noticeable but not tedious
- [ ] Both animations complete cleanly
- [ ] Works for both local player and opponents
---
## Implementation Order
1. Add distinct sounds to `playSound()`
2. Enhance `animateDrawDeck()` with suspense
3. Enhance `animateDrawDiscard()` for quick grab
4. Add deck visual effects (CSS)
5. Add `shuffleDeckVisual()` method
6. Test both draw types
7. Tune timing for feel
---
## Notes for Agent
- Most of this is already implemented in `card-animations.js`
- Main enhancement is adding distinct sounds (`draw-deck` vs `draw-discard`)
- The existing timing difference (1125ms vs 448ms) is already significant
- Deck draw suspense shouldn't be annoying, just noticeable
- Discard draw being faster reflects the strategic advantage (you know what you're getting)
- Consider: Show deck count visual changing? (Nice to have)
- Sound design matters here - different tones communicate different meanings
- Mobile performance should still be smooth

View File

@@ -0,0 +1,399 @@
# V3-13: Card Value Tooltips
## Overview
New players often forget card values, especially special cards (2=-2, K=0, Joker=-2). This feature adds tooltips showing card point values on long-press or hover.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Show card point value on long-press (mobile) or hover (desktop)
2. Especially helpful for special value cards
3. Show house rule modified values if active
4. Don't interfere with normal gameplay
5. Optional: disable for experienced players
---
## Current State
No card value tooltips exist. Players must remember:
- Standard values: A=1, 2-10=face, J/Q=10, K=0
- Special values: 2=-2, Joker=-2
- House rules: super_kings=-2, ten_penny=1, etc.
---
## Design
### Tooltip Content
```
┌─────────┐
│ K │ ← Normal card display
│ ♠ │
└─────────┘
┌───────┐
│ 0 pts │ ← Tooltip on hover/long-press
└───────┘
```
For special cards:
```
┌────────────┐
│ -2 pts │
│ (negative!)│
└────────────┘
```
### Activation
- **Desktop:** Hover for 500ms (not instant to avoid cluttering)
- **Mobile:** Long-press (300ms threshold)
- **Dismiss:** Mouse leave / touch release
---
## Implementation
### JavaScript
```javascript
// Card tooltip system
initCardTooltips() {
this.tooltip = document.createElement('div');
this.tooltip.className = 'card-value-tooltip hidden';
document.body.appendChild(this.tooltip);
this.tooltipTimeout = null;
this.currentTooltipTarget = null;
}
bindCardTooltipEvents(cardElement, cardData) {
// Desktop hover
cardElement.addEventListener('mouseenter', () => {
this.scheduleTooltip(cardElement, cardData);
});
cardElement.addEventListener('mouseleave', () => {
this.hideCardTooltip();
});
// Mobile long-press
let pressTimer = null;
cardElement.addEventListener('touchstart', (e) => {
pressTimer = setTimeout(() => {
this.showCardTooltip(cardElement, cardData);
// Prevent triggering card click
e.preventDefault();
}, 300);
});
cardElement.addEventListener('touchend', () => {
clearTimeout(pressTimer);
this.hideCardTooltip();
});
cardElement.addEventListener('touchmove', () => {
clearTimeout(pressTimer);
this.hideCardTooltip();
});
}
scheduleTooltip(cardElement, cardData) {
this.hideCardTooltip();
if (!cardData?.face_up || !cardData?.rank) return;
this.tooltipTimeout = setTimeout(() => {
this.showCardTooltip(cardElement, cardData);
}, 500); // 500ms delay on desktop
}
showCardTooltip(cardElement, cardData) {
if (!cardData?.face_up || !cardData?.rank) return;
const value = this.getCardPointValue(cardData);
const special = this.getCardSpecialNote(cardData);
// Build tooltip content
let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`;
if (special) {
content += `<span class="tooltip-note">${special}</span>`;
}
this.tooltip.innerHTML = content;
// Position tooltip
const rect = cardElement.getBoundingClientRect();
const tooltipRect = this.tooltip.getBoundingClientRect();
let left = rect.left + rect.width / 2;
let top = rect.bottom + 8;
// Keep on screen
if (left + tooltipRect.width / 2 > window.innerWidth) {
left = window.innerWidth - tooltipRect.width / 2 - 10;
}
if (left - tooltipRect.width / 2 < 0) {
left = tooltipRect.width / 2 + 10;
}
if (top + tooltipRect.height > window.innerHeight) {
top = rect.top - tooltipRect.height - 8;
}
this.tooltip.style.left = `${left}px`;
this.tooltip.style.top = `${top}px`;
this.tooltip.classList.remove('hidden');
this.currentTooltipTarget = cardElement;
}
hideCardTooltip() {
clearTimeout(this.tooltipTimeout);
this.tooltip.classList.add('hidden');
this.currentTooltipTarget = null;
}
getCardPointValue(cardData) {
const values = this.gameState?.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
};
return values[cardData.rank] ?? 0;
}
getCardSpecialNote(cardData) {
const rank = cardData.rank;
const value = this.getCardPointValue(cardData);
// Special notes for notable cards
if (value < 0) {
return 'Negative - keep it!';
}
if (rank === 'K' && value === 0) {
return 'Safe card';
}
if (rank === 'K' && value === -2) {
return 'Super King!';
}
if (rank === '10' && value === 1) {
return 'Ten Penny rule';
}
if (rank === 'J' || rank === 'Q') {
return 'High - replace if possible';
}
return null;
}
```
### CSS
```css
/* Card value tooltip */
.card-value-tooltip {
position: fixed;
transform: translateX(-50%);
background: rgba(26, 26, 46, 0.95);
color: white;
padding: 6px 12px;
border-radius: 8px;
font-size: 0.85em;
text-align: center;
z-index: 500;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: opacity 0.15s;
}
.card-value-tooltip.hidden {
opacity: 0;
pointer-events: none;
}
.card-value-tooltip::before {
content: '';
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-bottom-color: rgba(26, 26, 46, 0.95);
}
.tooltip-value {
display: block;
font-size: 1.2em;
font-weight: bold;
}
.tooltip-value.negative {
color: #27ae60;
}
.tooltip-note {
display: block;
font-size: 0.85em;
color: rgba(255, 255, 255, 0.7);
margin-top: 2px;
}
/* Visual indicator that tooltip is available */
.card[data-has-tooltip]:hover {
cursor: help;
}
```
### Integration with renderGame
```javascript
// In renderGame, after creating card elements
renderPlayerCards() {
// ... existing card rendering ...
const cards = this.playerCards.querySelectorAll('.card');
const myData = this.getMyPlayerData();
cards.forEach((cardEl, i) => {
const cardData = myData?.cards[i];
if (cardData?.face_up) {
cardEl.dataset.hasTooltip = 'true';
this.bindCardTooltipEvents(cardEl, cardData);
}
});
}
// Similar for opponent cards
renderOpponentCards(player, container) {
// ... existing card rendering ...
const cards = container.querySelectorAll('.card');
player.cards.forEach((cardData, i) => {
if (cardData?.face_up && cards[i]) {
cards[i].dataset.hasTooltip = 'true';
this.bindCardTooltipEvents(cards[i], cardData);
}
});
}
```
---
## House Rule Awareness
Tooltip values should reflect active house rules:
```javascript
getCardPointValue(cardData) {
// Use server-provided values which include house rules
if (this.gameState?.card_values) {
return this.gameState.card_values[cardData.rank] ?? 0;
}
// Fallback to defaults
return DEFAULT_CARD_VALUES[cardData.rank] ?? 0;
}
```
The server already provides `card_values` in game state that accounts for:
- `super_kings` (K = -2)
- `ten_penny` (10 = 1)
- `lucky_swing` (Joker = -5)
- etc.
---
## Performance Considerations
- Only bind tooltip events to face-up cards
- Remove tooltip events when cards re-render
- Use event delegation if performance becomes an issue
```javascript
// Event delegation approach
this.playerCards.addEventListener('mouseenter', (e) => {
const card = e.target.closest('.card');
if (card && card.dataset.hasTooltip) {
const cardData = this.getCardDataForElement(card);
this.scheduleTooltip(card, cardData);
}
}, true);
```
---
## Settings Option (Optional)
Let players disable tooltips:
```javascript
// In settings
this.showCardTooltips = localStorage.getItem('showCardTooltips') !== 'false';
// Check before showing
showCardTooltip(cardElement, cardData) {
if (!this.showCardTooltips) return;
// ... rest of method
}
```
---
## Test Scenarios
1. **Hover on face-up card** - Tooltip appears after delay
2. **Long-press on mobile** - Tooltip appears
3. **Move mouse away** - Tooltip disappears
4. **Face-down card** - No tooltip
5. **Special cards (K, 2, Joker)** - Show special note
6. **House rules active** - Modified values shown
7. **Rapid card changes** - No stale tooltips
---
## Acceptance Criteria
- [ ] Hover (500ms delay) shows tooltip on desktop
- [ ] Long-press (300ms) shows tooltip on mobile
- [ ] Tooltip shows point value
- [ ] Negative values highlighted green
- [ ] Special notes for notable cards
- [ ] House rule modified values displayed
- [ ] Tooltips don't interfere with gameplay
- [ ] Tooltips position correctly (stay on screen)
- [ ] Face-down cards have no tooltip
---
## Implementation Order
1. Create tooltip element and basic CSS
2. Implement `showCardTooltip()` method
3. Implement `hideCardTooltip()` method
4. Add desktop hover events
5. Add mobile long-press events
6. Integrate with `renderGame()`
7. Add house rule awareness
8. Test on mobile and desktop
9. Optional: Add settings toggle
---
## Notes for Agent
- **CSS vs anime.js**: CSS is appropriate for tooltip show/hide transitions (simple UI)
- The 500ms delay prevents tooltips appearing during normal play
- Mobile long-press should be discoverable but not intrusive
- Use server-provided `card_values` for house rule accuracy
- Consider: Quick reference card in rules screen? (Separate feature)
- Don't show tooltip during swap animation

View File

@@ -0,0 +1,332 @@
# V3-14: Active Rules Context
## Overview
The active rules bar shows which house rules are in effect, but doesn't highlight when a rule is relevant to the current action. This feature adds contextual highlighting to help players understand rule effects.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Highlight relevant rules during specific actions
2. Brief explanatory tooltip when rule affects play
3. Help players learn how rules work
4. Don't clutter the interface
5. Fade after the moment passes
---
## Current State
From `app.js`:
```javascript
updateActiveRulesBar() {
const rules = this.gameState.active_rules || [];
if (rules.length === 0) {
this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
} else {
// Show rule tags
}
}
```
Rules are listed but never highlighted contextually.
---
## Design
### Contextual Highlighting Moments
| Moment | Relevant Rule(s) | Highlight Text |
|--------|------------------|----------------|
| Discard from deck | flip_mode: always | "Must flip a card!" |
| Player knocks | knock_penalty | "+10 if not lowest!" |
| Player knocks | knock_bonus | "-5 for going out first" |
| Pair negative cards | negative_pairs_keep_value | "Pairs keep -4!" |
| Draw Joker | lucky_swing | "Worth -5!" |
| Round end | underdog_bonus | "-3 for lowest score" |
| Score = 21 | blackjack | "Blackjack! Score → 0" |
| Four Jacks | wolfpack | "-20 Wolfpack bonus!" |
### Visual Treatment
```
Normal: [Speed Golf] [Knock Penalty]
Highlighted: [Speed Golf ← Must flip!] [Knock Penalty]
Pulsing, expanded
```
---
## Implementation
### Rule Highlight Method
```javascript
highlightRule(ruleKey, message, duration = 3000) {
const ruleTag = this.activeRulesList.querySelector(
`[data-rule="${ruleKey}"]`
);
if (!ruleTag) return;
// Add highlight class
ruleTag.classList.add('rule-highlighted');
// Add message
const messageEl = document.createElement('span');
messageEl.className = 'rule-message';
messageEl.textContent = message;
ruleTag.appendChild(messageEl);
// Remove after duration
setTimeout(() => {
ruleTag.classList.remove('rule-highlighted');
messageEl.remove();
}, duration);
}
```
### Integration Points
```javascript
// In handleMessage or state change handlers
// 1. Speed Golf - must flip after discard
case 'can_flip':
if (!data.optional && this.gameState.flip_mode === 'always') {
this.highlightRule('flip_mode', 'Must flip a card!');
}
break;
// 2. Knock penalty warning
knockEarly() {
if (this.gameState.knock_penalty) {
this.highlightRule('knock_penalty', '+10 if not lowest!', 4000);
}
// ... rest of knock logic
}
// 3. Lucky swing Joker
case 'card_drawn':
if (data.card.rank === '★' && this.gameState.lucky_swing) {
this.highlightRule('lucky_swing', 'Worth -5!');
}
break;
// 4. Blackjack at round end
showScoreboard(scores, isFinal, rankings) {
// Check for blackjack
for (const [playerId, score] of Object.entries(scores)) {
if (score === 0 && this.wasOriginallyBlackjack(playerId)) {
this.highlightRule('blackjack', 'Blackjack! 21 → 0');
}
}
// ... rest of scoreboard logic
}
```
### Update Rule Rendering
Add data attributes for targeting:
```javascript
updateActiveRulesBar() {
const rules = this.gameState.active_rules || [];
if (rules.length === 0) {
this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
return;
}
this.activeRulesList.innerHTML = rules
.map(rule => {
const key = this.getRuleKey(rule);
return `<span class="rule-tag" data-rule="${key}">${rule}</span>`;
})
.join('');
}
getRuleKey(ruleName) {
// Convert display name to key
const mapping = {
'Speed Golf': 'flip_mode',
'Endgame Flip': 'flip_mode',
'Knock Penalty': 'knock_penalty',
'Knock Bonus': 'knock_bonus',
'Super Kings': 'super_kings',
'Ten Penny': 'ten_penny',
'Lucky Swing': 'lucky_swing',
'Eagle Eye': 'eagle_eye',
'Underdog': 'underdog_bonus',
'Tied Shame': 'tied_shame',
'Blackjack': 'blackjack',
'Wolfpack': 'wolfpack',
'Flip Action': 'flip_as_action',
'4 of a Kind': 'four_of_a_kind',
'Negative Pairs': 'negative_pairs_keep_value',
'One-Eyed Jacks': 'one_eyed_jacks',
'Knock Early': 'knock_early',
};
return mapping[ruleName] || ruleName.toLowerCase().replace(/\s+/g, '_');
}
```
### CSS
```css
/* Rule tag base */
.rule-tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.15);
border-radius: 12px;
font-size: 0.8em;
transition: all 0.3s ease;
}
/* Highlighted rule */
.rule-tag.rule-highlighted {
background: rgba(244, 164, 96, 0.3);
box-shadow: 0 0 10px rgba(244, 164, 96, 0.4);
animation: rule-pulse 0.5s ease-out;
}
@keyframes rule-pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
/* Message that appears */
.rule-message {
margin-left: 8px;
padding-left: 8px;
border-left: 1px solid rgba(255, 255, 255, 0.3);
font-weight: bold;
color: #f4a460;
animation: message-fade-in 0.3s ease-out;
}
@keyframes message-fade-in {
0% { opacity: 0; transform: translateX(-5px); }
100% { opacity: 1; transform: translateX(0); }
}
/* Ensure bar is visible when highlighted */
#active-rules-bar:has(.rule-highlighted) {
background: rgba(0, 0, 0, 0.4);
}
```
---
## Rule-Specific Triggers
### Flip Mode (Speed Golf/Endgame)
```javascript
// When player must flip
if (this.waitingForFlip && !this.flipIsOptional) {
this.highlightRule('flip_mode', 'Flip a face-down card!');
}
```
### Knock Penalty/Bonus
```javascript
// When someone triggers final turn
if (newState.phase === 'final_turn' && oldState?.phase !== 'final_turn') {
if (this.gameState.knock_penalty) {
this.highlightRule('knock_penalty', '+10 if beaten!');
}
if (this.gameState.knock_bonus) {
this.highlightRule('knock_bonus', '-5 for going out!');
}
}
```
### Negative Pairs
```javascript
// When pair of 2s or Jokers is formed
checkForNewPairs(oldState, newState, playerId) {
// ... pair detection ...
if (nowPaired && this.gameState.negative_pairs_keep_value) {
const isNegativePair = cardRank === '2' || cardRank === '★';
if (isNegativePair) {
this.highlightRule('negative_pairs_keep_value', 'Keeps -4!');
}
}
}
```
### Score Bonuses (Round End)
```javascript
// In showScoreboard
if (this.gameState.underdog_bonus) {
const lowestPlayer = findLowest(scores);
this.highlightRule('underdog_bonus', `${lowestPlayer} gets -3!`);
}
if (this.gameState.tied_shame) {
const ties = findTies(scores);
if (ties.length > 0) {
this.highlightRule('tied_shame', '+5 for ties!');
}
}
```
---
## Test Scenarios
1. **Speed Golf mode** - "Must flip" highlighted when discarding
2. **Knock with penalty** - Warning shown
3. **Draw Lucky Swing Joker** - "-5" highlighted
4. **Blackjack score** - Celebration when 21 → 0
5. **No active rules** - No highlights
6. **Multiple rules trigger** - All relevant ones highlight
---
## Acceptance Criteria
- [ ] Rules have data attributes for targeting
- [ ] Relevant rule highlights during specific actions
- [ ] Highlight message explains the effect
- [ ] Highlight auto-fades after duration
- [ ] Multiple rules can highlight simultaneously
- [ ] Works for all major house rules
- [ ] Doesn't interfere with gameplay flow
---
## Implementation Order
1. Add `data-rule` attributes to rule tags
2. Implement `getRuleKey()` mapping
3. Implement `highlightRule()` method
4. Add CSS for highlight animation
5. Add trigger points for each major rule
6. Test with various rule combinations
7. Tune timing and messaging
---
## Notes for Agent
- **CSS vs anime.js**: CSS is appropriate for rule tag highlights (simple UI feedback)
- Keep highlight messages very short (3-5 words)
- Don't highlight on every single action, just key moments
- The goal is education, not distraction
- Consider: First-time highlight only? (Too complex for V3)
- Make sure the bar is visible when highlighting (expand if collapsed)

View File

@@ -0,0 +1,384 @@
# V3-15: Discard Pile History
## Overview
In physical card games, you can see the top few cards of the discard pile fanned out slightly. This provides memory aid and context for recent play. Currently our discard pile shows only the top card.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Show 2-3 recent discards visually fanned
2. Help players track what's been discarded recently
3. Subtle visual depth without cluttering
4. Optional: expandable full discard view
5. Authentic card game feel
---
## Current State
From `app.js` and CSS:
```javascript
// Only shows the top card
updateDiscard(cardData) {
this.discard.innerHTML = this.createCardHTML(cardData);
}
```
The discard pile is a single card element with no history visualization.
---
## Design
### Visual Treatment
```
Current: With history:
┌─────┐ ┌─────┐
│ 7 │ │ 7 │ ← Top card (clickable)
│ ♥ │ ╱└─────┘
└─────┘ └─────┘ ← Previous (faded, offset)
└─────┘ ← Older (more faded)
```
### Fan Layout
- Top card: Full visibility, normal position
- Previous card: Offset 3-4px left and up, 50% opacity
- Older card: Offset 6-8px left and up, 25% opacity
- Maximum 3 visible cards (performance + clarity)
---
## Implementation
### Track Discard History
```javascript
// In app.js constructor
this.discardHistory = [];
this.maxVisibleHistory = 3;
// Update when discard changes
updateDiscardHistory(newCard) {
if (!newCard) {
this.discardHistory = [];
return;
}
// Add new card to front
this.discardHistory.unshift(newCard);
// Keep only recent cards
if (this.discardHistory.length > this.maxVisibleHistory) {
this.discardHistory = this.discardHistory.slice(0, this.maxVisibleHistory);
}
}
// Called from state differ or handleMessage
onDiscardChange(newCard, oldCard) {
// Only add if it's a new card (not initial state)
if (oldCard && newCard && oldCard.rank !== newCard.rank) {
this.updateDiscardHistory(newCard);
} else if (newCard && !oldCard) {
this.updateDiscardHistory(newCard);
}
this.renderDiscardPile();
}
```
### Render Fanned Pile
```javascript
renderDiscardPile() {
const container = this.discard;
container.innerHTML = '';
if (this.discardHistory.length === 0) {
container.innerHTML = '<div class="card empty">Empty</div>';
return;
}
// Render from oldest to newest (back to front)
const cards = [...this.discardHistory].reverse();
cards.forEach((cardData, index) => {
const reverseIndex = cards.length - 1 - index;
const card = this.createDiscardCard(cardData, reverseIndex);
container.appendChild(card);
});
}
createDiscardCard(cardData, depthIndex) {
const card = document.createElement('div');
card.className = 'card discard-card';
card.dataset.depth = depthIndex;
// Only top card is interactive
if (depthIndex === 0) {
card.classList.add('top-card');
card.addEventListener('click', () => this.handleDiscardClick());
}
// Set card content
card.innerHTML = this.createCardContentHTML(cardData);
// Apply offset based on depth
const offset = depthIndex * 4;
card.style.setProperty('--depth-offset', `${offset}px`);
return card;
}
```
### CSS Styling
```css
/* Discard pile container */
#discard {
position: relative;
width: var(--card-width);
height: var(--card-height);
}
/* Stacked discard cards */
.discard-card {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transition: transform 0.2s, opacity 0.2s;
}
/* Depth-based styling */
.discard-card[data-depth="0"] {
z-index: 3;
opacity: 1;
transform: translate(0, 0);
}
.discard-card[data-depth="1"] {
z-index: 2;
opacity: 0.5;
transform: translate(-4px, -4px);
pointer-events: none;
}
.discard-card[data-depth="2"] {
z-index: 1;
opacity: 0.25;
transform: translate(-8px, -8px);
pointer-events: none;
}
/* Using CSS variable for dynamic offset */
.discard-card:not(.top-card) {
transform: translate(
calc(var(--depth-offset, 0px) * -1),
calc(var(--depth-offset, 0px) * -1)
);
}
/* Hover to expand history slightly */
#discard:hover .discard-card[data-depth="1"] {
opacity: 0.7;
transform: translate(-8px, -8px);
}
#discard:hover .discard-card[data-depth="2"] {
opacity: 0.4;
transform: translate(-16px, -16px);
}
/* Animation when new card is discarded */
@keyframes discard-land {
0% {
transform: translate(0, -20px) scale(1.05);
opacity: 0;
}
100% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
}
.discard-card.top-card.just-landed {
animation: discard-land 0.2s ease-out;
}
/* Shift animation for cards moving back */
@keyframes shift-back {
0% { transform: translate(0, 0); }
100% { transform: translate(var(--depth-offset) * -1, var(--depth-offset) * -1); }
}
```
### Integration with State Changes
```javascript
// In state-differ.js or wherever discard changes are detected
detectDiscardChange(oldState, newState) {
const oldDiscard = oldState?.discard_pile?.[oldState.discard_pile.length - 1];
const newDiscard = newState?.discard_pile?.[newState.discard_pile.length - 1];
if (this.cardsDifferent(oldDiscard, newDiscard)) {
return {
type: 'discard_change',
oldCard: oldDiscard,
newCard: newDiscard
};
}
return null;
}
// Handle the change
handleDiscardChange(change) {
this.onDiscardChange(change.newCard, change.oldCard);
}
```
### Round/Game Reset
```javascript
// Clear history at start of new round
onNewRound() {
this.discardHistory = [];
this.renderDiscardPile();
}
// Or when deck is reshuffled (if that's a game mechanic)
onDeckReshuffle() {
this.discardHistory = [];
}
```
---
## Optional: Expandable Full History
For players who want to see all discards:
```javascript
// Toggle full discard view
showDiscardHistory() {
const modal = document.getElementById('discard-history-modal');
modal.innerHTML = this.buildFullDiscardView();
modal.classList.add('visible');
}
buildFullDiscardView() {
// Show all cards in discard pile from game state
const discards = this.gameState.discard_pile || [];
return discards.map(card =>
`<div class="card mini">${this.createCardContentHTML(card)}</div>`
).join('');
}
```
```css
#discard-history-modal {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(26, 26, 46, 0.95);
padding: 12px;
border-radius: 12px;
display: none;
max-width: 90vw;
overflow-x: auto;
}
#discard-history-modal.visible {
display: flex;
gap: 8px;
}
#discard-history-modal .card.mini {
width: 40px;
height: 56px;
font-size: 0.7em;
}
```
---
## Mobile Considerations
On smaller screens, reduce the fan offset:
```css
@media (max-width: 600px) {
.discard-card[data-depth="1"] {
transform: translate(-2px, -2px);
}
.discard-card[data-depth="2"] {
transform: translate(-4px, -4px);
}
/* Skip hover expansion on touch */
#discard:hover .discard-card {
transform: translate(
calc(var(--depth-offset, 0px) * -0.5),
calc(var(--depth-offset, 0px) * -0.5)
);
}
}
```
---
## Test Scenarios
1. **First discard** - Single card shows
2. **Second discard** - Two cards fanned
3. **Third+ discards** - Three cards max, oldest drops off
4. **New round** - History clears
5. **Draw from discard** - Top card removed, others shift forward
6. **Hover interaction** - Cards fan out slightly more
7. **Mobile view** - Smaller offset, still visible
---
## Acceptance Criteria
- [ ] Recent 2-3 discards visible in fanned pile
- [ ] Older cards progressively more faded
- [ ] Only top card is interactive
- [ ] History updates smoothly when cards change
- [ ] History clears on new round
- [ ] Hover expands fan slightly (desktop)
- [ ] Works on mobile with smaller offsets
- [ ] Optional: expandable full history view
---
## Implementation Order
1. Add `discardHistory` array tracking
2. Implement `renderDiscardPile()` method
3. Add CSS for fanned stack
4. Integrate with state change detection
5. Add round reset handling
6. Add hover expansion effect
7. Test on various screen sizes
8. Optional: Add full history modal
---
## Notes for Agent
- **CSS vs anime.js**: CSS is appropriate for static fan layout. If adding "landing" animation for new discards, use anime.js.
- Keep visible history small (3 cards max) for clarity
- The fan offset should be subtle, not dramatic
- History helps players remember what was recently played
- Consider: Should drawing from discard affect history display?
- Mobile: smaller offset but still visible
- Don't overcomplicate - this is a nice-to-have feature

View File

@@ -0,0 +1,632 @@
# V3-16: Realistic Card Sounds
## Overview
Current sounds use simple Web Audio oscillator beeps. Real card games have distinct sounds: shuffling, dealing, flipping, placing. This feature improves audio feedback to feel more physical.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Distinct sounds for each card action
2. Variation to avoid repetition fatigue
3. Physical "card" quality (paper, snap, thunk)
4. Volume control and mute option
5. Performant (Web Audio API synthesis or small samples)
---
## Current State
From `app.js` and `card-animations.js`:
```javascript
// app.js has the main playSound method
playSound(type) {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
// Simple beep tones for different actions
}
// CardAnimations routes to app.js via window.game.playSound()
playSound(type) {
if (window.game && typeof window.game.playSound === 'function') {
window.game.playSound(type);
}
}
```
Sounds are functional but feel digital/arcade rather than physical. The existing sound types include:
- `card` - general card movement
- `flip` - card flip
- `shuffle` - deck shuffle
---
## Design
### Sound Palette
| Action | Sound Character | Notes |
|--------|-----------------|-------|
| Card flip | Sharp snap | Paper/cardboard flip |
| Card place | Soft thunk | Card landing on table |
| Card draw | Slide + lift | Taking from pile |
| Card shuffle | Multiple snaps | Riffle texture |
| Pair formed | Satisfying click | Success feedback |
| Knock | Table tap | Knuckle on table |
| Deal | Quick sequence | Multiple snaps |
| Turn notification | Subtle chime | Alert without jarring |
| Round end | Flourish | Resolution feel |
### Synthesis vs Samples
**Option A: Synthesized sounds (current approach, enhanced)**
- No external files needed
- Smaller bundle size
- More control over variations
- Can sound artificial
**Option B: Audio samples**
- More realistic
- Larger file size (small samples ~5-10KB each)
- Need to handle loading
- Can use Web Audio for variations
**Recommendation:** Hybrid - synthesized base with sample layering for key sounds.
---
## Implementation
### Enhanced Sound System
```javascript
// sound-system.js
class SoundSystem {
constructor() {
this.ctx = null;
this.enabled = true;
this.volume = 0.5;
this.samples = {};
this.initialized = false;
}
async init() {
if (this.initialized) return;
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.connect(this.ctx.destination);
this.masterGain.gain.value = this.volume;
// Load settings
this.enabled = localStorage.getItem('soundEnabled') !== 'false';
this.volume = parseFloat(localStorage.getItem('soundVolume') || '0.5');
this.initialized = true;
}
setVolume(value) {
this.volume = Math.max(0, Math.min(1, value));
if (this.masterGain) {
this.masterGain.gain.value = this.volume;
}
localStorage.setItem('soundVolume', this.volume.toString());
}
setEnabled(enabled) {
this.enabled = enabled;
localStorage.setItem('soundEnabled', enabled.toString());
}
async play(type) {
if (!this.enabled) return;
if (!this.ctx || this.ctx.state === 'suspended') {
await this.ctx?.resume();
}
const now = this.ctx.currentTime;
switch (type) {
case 'flip':
this.playFlip(now);
break;
case 'place':
case 'discard':
this.playPlace(now);
break;
case 'draw-deck':
this.playDrawDeck(now);
break;
case 'draw-discard':
this.playDrawDiscard(now);
break;
case 'pair':
this.playPair(now);
break;
case 'knock':
this.playKnock(now);
break;
case 'deal':
this.playDeal(now);
break;
case 'shuffle':
this.playShuffle(now);
break;
case 'turn':
this.playTurn(now);
break;
case 'round-end':
this.playRoundEnd(now);
break;
case 'win':
this.playWin(now);
break;
default:
this.playGeneric(now);
}
}
// Card flip - sharp snap
playFlip(now) {
// White noise burst for paper snap
const noise = this.createNoiseBurst(0.03, 0.02);
// High frequency click
const click = this.ctx.createOscillator();
const clickGain = this.ctx.createGain();
click.connect(clickGain);
clickGain.connect(this.masterGain);
click.type = 'square';
click.frequency.setValueAtTime(2000 + Math.random() * 500, now);
click.frequency.exponentialRampToValueAtTime(800, now + 0.02);
clickGain.gain.setValueAtTime(0.15, now);
clickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
click.start(now);
click.stop(now + 0.05);
}
// Card place - soft thunk
playPlace(now) {
// Low thump
const thump = this.ctx.createOscillator();
const thumpGain = this.ctx.createGain();
thump.connect(thumpGain);
thumpGain.connect(this.masterGain);
thump.type = 'sine';
thump.frequency.setValueAtTime(150 + Math.random() * 30, now);
thump.frequency.exponentialRampToValueAtTime(80, now + 0.08);
thumpGain.gain.setValueAtTime(0.2, now);
thumpGain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
thump.start(now);
thump.stop(now + 0.1);
// Soft noise
this.createNoiseBurst(0.02, 0.04);
}
// Draw from deck - mysterious slide + flip
playDrawDeck(now) {
// Slide sound
const slide = this.ctx.createOscillator();
const slideGain = this.ctx.createGain();
slide.connect(slideGain);
slideGain.connect(this.masterGain);
slide.type = 'triangle';
slide.frequency.setValueAtTime(200, now);
slide.frequency.exponentialRampToValueAtTime(400, now + 0.1);
slideGain.gain.setValueAtTime(0.08, now);
slideGain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
slide.start(now);
slide.stop(now + 0.12);
// Delayed flip
setTimeout(() => this.playFlip(this.ctx.currentTime), 150);
}
// Draw from discard - quick grab
playDrawDiscard(now) {
const grab = this.ctx.createOscillator();
const grabGain = this.ctx.createGain();
grab.connect(grabGain);
grabGain.connect(this.masterGain);
grab.type = 'square';
grab.frequency.setValueAtTime(600, now);
grab.frequency.exponentialRampToValueAtTime(300, now + 0.04);
grabGain.gain.setValueAtTime(0.1, now);
grabGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
grab.start(now);
grab.stop(now + 0.05);
}
// Pair formed - satisfying double click
playPair(now) {
// Two quick clicks
for (let i = 0; i < 2; i++) {
const click = this.ctx.createOscillator();
const gain = this.ctx.createGain();
click.connect(gain);
gain.connect(this.masterGain);
click.type = 'triangle';
click.frequency.setValueAtTime(800 + i * 200, now + i * 0.08);
gain.gain.setValueAtTime(0.15, now + i * 0.08);
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.06);
click.start(now + i * 0.08);
click.stop(now + i * 0.08 + 0.06);
}
}
// Knock - table tap
playKnock(now) {
// Low woody thunk
const knock = this.ctx.createOscillator();
const knockGain = this.ctx.createGain();
knock.connect(knockGain);
knockGain.connect(this.masterGain);
knock.type = 'sine';
knock.frequency.setValueAtTime(120, now);
knock.frequency.exponentialRampToValueAtTime(60, now + 0.1);
knockGain.gain.setValueAtTime(0.3, now);
knockGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
knock.start(now);
knock.stop(now + 0.15);
// Resonance
const resonance = this.ctx.createOscillator();
const resGain = this.ctx.createGain();
resonance.connect(resGain);
resGain.connect(this.masterGain);
resonance.type = 'triangle';
resonance.frequency.setValueAtTime(180, now);
resGain.gain.setValueAtTime(0.1, now);
resGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
resonance.start(now);
resonance.stop(now + 0.2);
}
// Deal - rapid card sequence
playDeal(now) {
// Multiple quick snaps
for (let i = 0; i < 4; i++) {
setTimeout(() => {
const snap = this.ctx.createOscillator();
const gain = this.ctx.createGain();
snap.connect(gain);
gain.connect(this.masterGain);
snap.type = 'square';
snap.frequency.setValueAtTime(1500 + Math.random() * 300, this.ctx.currentTime);
gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.03);
snap.start(this.ctx.currentTime);
snap.stop(this.ctx.currentTime + 0.03);
}, i * 80);
}
}
// Shuffle - riffle texture
playShuffle(now) {
// Many tiny clicks with frequency variation
for (let i = 0; i < 12; i++) {
setTimeout(() => {
this.createNoiseBurst(0.01, 0.01 + Math.random() * 0.02);
}, i * 40 + Math.random() * 20);
}
}
// Turn notification - gentle chime
playTurn(now) {
const freqs = [523, 659]; // C5, E5
freqs.forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.masterGain);
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, now + i * 0.1);
gain.gain.setValueAtTime(0.1, now + i * 0.1);
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.1 + 0.3);
osc.start(now + i * 0.1);
osc.stop(now + i * 0.1 + 0.3);
});
}
// Round end - resolution flourish
playRoundEnd(now) {
const freqs = [392, 494, 587, 784]; // G4, B4, D5, G5
freqs.forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.masterGain);
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, now + i * 0.08);
gain.gain.setValueAtTime(0.12, now + i * 0.08);
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.4);
osc.start(now + i * 0.08);
osc.stop(now + i * 0.08 + 0.4);
});
}
// Win celebration
playWin(now) {
const freqs = [523, 659, 784, 1047]; // C5, E5, G5, C6
freqs.forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.masterGain);
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, now + i * 0.12);
gain.gain.setValueAtTime(0.15, now + i * 0.12);
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.12 + 0.5);
osc.start(now + i * 0.12);
osc.stop(now + i * 0.12 + 0.5);
});
}
// Generic click
playGeneric(now) {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.masterGain);
osc.type = 'triangle';
osc.frequency.setValueAtTime(440, now);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
osc.start(now);
osc.stop(now + 0.1);
}
// Helper: Create white noise burst for paper/snap sounds
createNoiseBurst(volume, duration) {
const bufferSize = this.ctx.sampleRate * duration;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const output = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
const noise = this.ctx.createBufferSource();
noise.buffer = buffer;
const noiseGain = this.ctx.createGain();
noise.connect(noiseGain);
noiseGain.connect(this.masterGain);
const now = this.ctx.currentTime;
noiseGain.gain.setValueAtTime(volume, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
noise.start(now);
noise.stop(now + duration);
return noise;
}
}
// Export singleton
const soundSystem = new SoundSystem();
export default soundSystem;
```
### Integration with App
The SoundSystem can replace the existing `playSound()` method in `app.js`:
```javascript
// In app.js - replace the existing playSound method
// Option 1: Direct integration (no import needed for non-module setup)
// Create global instance
window.soundSystem = new SoundSystem();
// Initialize on first interaction
document.addEventListener('click', async () => {
await window.soundSystem.init();
}, { once: true });
// Replace existing playSound calls
playSound(type) {
window.soundSystem.play(type);
}
// CardAnimations already routes through window.game.playSound()
// so no changes needed in card-animations.js
```
### Sound Variation
Add slight randomization to prevent repetitive sounds:
```javascript
playFlip(now) {
// Random variation
const pitchVariation = 1 + (Math.random() - 0.5) * 0.1;
const volumeVariation = 1 + (Math.random() - 0.5) * 0.2;
// Apply to sound...
click.frequency.setValueAtTime(2000 * pitchVariation, now);
clickGain.gain.setValueAtTime(0.15 * volumeVariation, now);
}
```
### Settings UI
```javascript
// In settings panel
renderSoundSettings() {
return `
<div class="setting-group">
<label class="setting-toggle">
<input type="checkbox" id="sound-enabled"
${soundSystem.enabled ? 'checked' : ''}>
<span>Sound Effects</span>
</label>
<label class="setting-slider" ${!soundSystem.enabled ? 'style="opacity: 0.5"' : ''}>
<span>Volume</span>
<input type="range" id="sound-volume"
min="0" max="1" step="0.1"
value="${soundSystem.volume}"
${!soundSystem.enabled ? 'disabled' : ''}>
</label>
</div>
`;
}
// Event handlers
document.getElementById('sound-enabled').addEventListener('change', (e) => {
soundSystem.setEnabled(e.target.checked);
});
document.getElementById('sound-volume').addEventListener('input', (e) => {
soundSystem.setVolume(parseFloat(e.target.value));
});
```
---
## CSS for Settings
```css
.setting-group {
margin-bottom: 16px;
}
.setting-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.setting-slider {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
transition: opacity 0.2s;
}
.setting-slider input[type="range"] {
flex: 1;
-webkit-appearance: none;
background: rgba(255, 255, 255, 0.2);
height: 4px;
border-radius: 2px;
}
.setting-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: #f4a460;
border-radius: 50%;
cursor: pointer;
}
```
---
## Test Scenarios
1. **Card flip** - Sharp snap sound
2. **Card place/discard** - Soft thunk
3. **Draw from deck** - Slide + flip sequence
4. **Draw from discard** - Quick grab
5. **Pair formed** - Double click satisfaction
6. **Knock** - Table tap
7. **Deal sequence** - Rapid snaps
8. **Volume control** - Adjusts all sounds
9. **Mute toggle** - Silences all sounds
10. **Settings persist** - Reload maintains preferences
11. **First interaction** - AudioContext initializes
---
## Acceptance Criteria
- [ ] Distinct sounds for each card action
- [ ] Sounds feel physical (not arcade beeps)
- [ ] Variation prevents repetition fatigue
- [ ] Volume slider works
- [ ] Mute toggle works
- [ ] Settings persist in localStorage
- [ ] AudioContext handles browser restrictions
- [ ] No sound glitches or overlaps
- [ ] Performant (no audio lag)
---
## Implementation Order
1. Create SoundSystem class with basic structure
2. Implement individual sound methods
3. Add noise burst helper for paper sounds
4. Add volume/enabled controls
5. Integrate with existing playSound calls
6. Add variation to prevent repetition
7. Add settings UI
8. Test on various browsers
9. Fine-tune sound character
---
## Notes for Agent
- Replaces existing `playSound()` method in `app.js`
- CardAnimations already routes through `window.game.playSound()` - no changes needed there
- Web Audio API has good browser support
- AudioContext must be created after user interaction
- Noise bursts add realistic texture to card sounds
- Keep sounds short (<200ms) to stay responsive
- Volume variation and pitch variation prevent fatigue
- Test with headphones - sounds should be pleasant, not jarring
- Consider: different sound "themes"? (Classic, Minimal, Fun)
- Mobile: test performance impact of audio synthesis
- Settings should persist in localStorage

View File

@@ -0,0 +1,117 @@
# V3.17: Mobile Portrait Layout
**Version:** 3.1.1
**Commits:** `4fcdf13`, `fb3bd53`
## Overview
Full mobile portrait layout for phones, triggered by JS `matchMedia` on narrow portrait screens (`max-width: 500px`, `orientation: portrait`). The desktop layout is completely untouched — all mobile rules are scoped under `body.mobile-portrait`.
## Key Features
### Responsive Game Layout
- Viewport fills 100dvh with no scroll; `overscroll-behavior: contain` prevents pull-to-refresh
- Game screen uses flexbox column: compact header → opponents row → player row → bottom bar
- Safe-area insets respected for notched devices (`env(safe-area-inset-top/bottom)`)
### Compact Header
- Single-row header with reduced font sizes (0.75rem) and tight gaps
- Non-essential items hidden on mobile: username display, logout button, active rules bar
- Status message, round info, final turn badge, and leave button all use `white-space: nowrap` with ellipsis overflow
### Opponent Cards
- Flat horizontal strip (no arch rotation) with horizontal scroll for 4+ opponents
- Cards scaled to 32x45px with 0.6rem font (26x36px on short screens)
- Dealer chip scaled from 38px to 20px diameter to fit compact opponent areas
- Showing score badge sized proportionally
### Deck/Discard Area
- Deck and discard cards match player card size (72x101px) for visual consistency
- Held card floating matches player card size with proportional font scaling
### Player Cards
- Fixed 72x101px cards with 1.5rem font in 3-column grid
- 60x84px with 1.3rem font on short screens (max-height: 600px)
- Font size set inline by `card-manager.js` proportional to card width (0.35x ratio on mobile)
### Side Panels as Bottom Drawers
- Standings and scoreboard panels slide up as bottom drawers from a mobile bottom bar
- Drawer backdrop overlay with tap-to-dismiss
- Drag handle visual indicator on each drawer
- Drawers auto-close on screen change or layout change back to desktop
### Short Screen Fallback
- `@media (max-height: 600px)` reduces all card sizes, gaps, and padding
- Opponent cards: 26x36px, deck/discard: 60x84px, player cards: 60x84px
## Animation Fixes
### Deal Animation Guard
- `renderGame()` returns early when `dealAnimationInProgress` is true
- Prevents WebSocket state updates from destroying card slot DOM elements mid-deal animation
- Cards were piling up at (0,0) because `getCardSlotRect()` read stale/null positions after `innerHTML = ''`
### Animation Overlay Card Sizing
- **Root cause:** Base `.card` CSS (`width: clamp(65px, 5.5vw, 100px)`) was leaking into animation overlay elements (`.draw-anim-front.card`), overriding the intended `width: 100%` inherited from the overlay container
- **Effect:** Opponent flip overlays appeared at 65px instead of 32px (too big); deck/discard draw overlays appeared at 65px instead of 72px (too small)
- **Fix:** Added `!important` to `.draw-anim-front/.draw-anim-back` `width` and `height` rules to ensure animation overlays always match their parent container's inline dimensions from JavaScript
### Opponent Swap Held Card Sizing
- `fireSwapAnimation()` now passes a `heldRect` sized to match the opponent card (32px) positioned at the holding location, instead of defaulting to deck dimensions (72px)
- The traveling held card no longer appears oversized relative to opponent cards during the swap arc
### Font Size Consistency
- `cardFontSize()` helper in `CardAnimations` uses 0.35x width ratio on mobile (vs 0.5x desktop)
- Applied consistently across all animation paths: `createAnimCard`, `createCardFromData`, and arc swap font transitions
- Held card floating gets inline font-size scaled to card width on mobile
## CSS Architecture
All mobile rules use the `body.mobile-portrait` scope:
```css
/* Applied by JS matchMedia, not CSS media query */
body.mobile-portrait .selector { ... }
/* Short screen fallback uses both */
@media (max-height: 600px) {
body.mobile-portrait .selector { ... }
}
```
Card sizing uses `!important` to override base `.card` clamp values:
```css
body.mobile-portrait .opponent-area .card {
width: 32px !important;
height: 45px !important;
}
```
Animation overlays use `!important` to override base `.card` leaking:
```css
.draw-anim-front,
.draw-anim-back {
width: 100% !important;
height: 100% !important;
}
```
## Files Modified
| File | Changes |
|------|---------|
| `client/style.css` | ~470 lines of mobile portrait CSS added at end of file |
| `client/app.js` | Mobile detection, drawer management, `renderGame()` guard, swap heldRect sizing, held card font scaling |
| `client/card-animations.js` | `cardFontSize()` helper, consistent font scaling across all animation paths |
| `client/card-manager.js` | Inline font-size on mobile for `updateCardElement()` |
| `client/index.html` | Mobile bottom bar, drawer backdrop, viewport-fit=cover |
## Testing
- **Desktop:** No visual changes — all rules scoped under `body.mobile-portrait`
- **Mobile portrait:** Verify game fits 100dvh, no scroll, cards properly sized
- **Deal animation:** Cards fly to correct grid positions (not piling up)
- **Draw/discard:** Animation overlay matches source card size
- **Opponent swap:** Flip and arc animations use opponent card dimensions
- **Short screens (iPhone SE):** All elements fit with reduced sizes
- **Orientation change:** Layout switches cleanly between mobile and desktop

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)

276
docs/v3/refactor-ai.md Normal file
View File

@@ -0,0 +1,276 @@
# Plan 2: ai.py Refactor
## Overview
`ai.py` is 1,978 lines with a single function (`choose_swap_or_discard`) at **666 lines** and cyclomatic complexity 50+. The goal is to decompose it into testable, understandable pieces without changing any AI behavior.
Key constraint: **AI behavior must remain identical.** This is pure structural refactoring. We can validate with `python server/simulate.py 500` before and after - stats should match within normal variance.
---
## The Problem Functions
| Function | Lines | What It Does |
|----------|-------|-------------|
| `choose_swap_or_discard()` | ~666 | Decides which position (0-5) to swap drawn card into, or None to discard |
| `calculate_swap_score()` | ~240 | Scores a single position for swapping |
| `should_take_discard()` | ~160 | Decides whether to take from discard pile |
| `process_cpu_turn()` | ~240 | Orchestrates a full CPU turn with timing |
---
## Refactoring Plan
### Step 1: Extract Named Constants
Create section at top of `ai.py` (or a separate `ai_constants.py` if preferred):
```python
# =============================================================================
# AI Decision Constants
# =============================================================================
# Expected value of an unknown (face-down) card, based on deck distribution
EXPECTED_HIDDEN_VALUE = 4.5
# Pessimistic estimate for hidden cards (used in go-out safety checks)
PESSIMISTIC_HIDDEN_VALUE = 6.0
# Conservative estimate (used by conservative personality)
CONSERVATIVE_HIDDEN_VALUE = 2.5
# Cards at or above this value should never be swapped into unknown positions
HIGH_CARD_THRESHOLD = 8
# Maximum card value for unpredictability swaps
UNPREDICTABLE_MAX_VALUE = 7
# Pair potential discount when adjacent card matches
PAIR_POTENTIAL_DISCOUNT = 0.25
# Blackjack target score
BLACKJACK_TARGET = 21
# Base acceptable score range for go-out decisions
GO_OUT_SCORE_BASE = 12
GO_OUT_SCORE_MAX = 20
```
**Locations to update:** ~30 magic number sites across the file. Each becomes a named reference.
### Step 2: Extract Column/Pair Utility Functions
The "iterate columns, check pairs" pattern appears 8+ times. Create shared utilities:
```python
def iter_columns(player: Player):
"""Yield (col_index, top_idx, bot_idx, top_card, bot_card) for each column."""
for col in range(3):
top_idx = col
bot_idx = col + 3
yield col, top_idx, bot_idx, player.cards[top_idx], player.cards[bot_idx]
def project_score(player: Player, swap_pos: int, new_card: Card, options: GameOptions) -> int:
"""Calculate what the player's score would be if new_card were swapped into swap_pos.
Handles pair cancellation correctly. Used by multiple decision paths.
"""
total = 0
for col, top_idx, bot_idx, top_card, bot_card in iter_columns(player):
# Substitute the new card if it's in this column
effective_top = new_card if top_idx == swap_pos else top_card
effective_bot = new_card if bot_idx == swap_pos else bot_card
if effective_top.rank == effective_bot.rank:
# Pair cancels (with house rule exceptions)
continue
total += get_ai_card_value(effective_top, options)
total += get_ai_card_value(effective_bot, options)
return total
def count_hidden(player: Player) -> int:
"""Count face-down cards."""
return sum(1 for c in player.cards if not c.face_up)
def hidden_positions(player: Player) -> list[int]:
"""Get indices of face-down cards."""
return [i for i, c in enumerate(player.cards) if not c.face_up]
def known_score(player: Player, options: GameOptions) -> int:
"""Calculate score from face-up cards only, using EXPECTED_HIDDEN_VALUE for unknowns."""
# Centralized version of the repeated estimation logic
...
```
This replaces duplicated loops at roughly lines: 679, 949, 1002, 1053, 1145, 1213, 1232.
### Step 3: Decompose `choose_swap_or_discard()`
Break into focused sub-functions. The current flow is roughly:
1. **Go-out safety check** (lines ~1087-1186) - "I'm about to go out, pick the best swap to minimize my score"
2. **Score all 6 positions** (lines ~1190-1270) - Calculate swap benefit for each position
3. **Filter and rank candidates** (lines ~1270-1330) - Safety filters, personality tie-breaking
4. **Blackjack special case** (lines ~1330-1380) - If blackjack rule enabled, check for 21
5. **Endgame safety** (lines ~1380-1410) - Don't swap 8+ into unknowns in endgame
6. **Denial logic** (lines ~1410-1480) - Block opponent by taking their useful cards
Proposed decomposition:
```python
def choose_swap_or_discard(player, drawn_card, profile, game, ...) -> Optional[int]:
"""Main orchestrator - delegates to focused sub-functions."""
# Check if we should force a go-out swap
go_out_pos = _check_go_out_swap(player, drawn_card, profile, game, ...)
if go_out_pos is not None:
return go_out_pos
# Score all positions
candidates = _score_all_positions(player, drawn_card, profile, game, ...)
# Apply filters and select best
best = _select_best_candidate(candidates, player, drawn_card, profile, game, ...)
if best is not None:
return best
# Try denial as fallback
return _check_denial_swap(player, drawn_card, profile, game, ...)
def _check_go_out_swap(player, drawn_card, profile, game, ...) -> Optional[int]:
"""If player is close to going out, find the best position to minimize final score.
Handles:
- All-but-one face-up: find the best slot for the drawn card
- Acceptable score threshold based on game state and personality
- Pair completion opportunities
"""
# Lines ~1087-1186 of current choose_swap_or_discard
...
def _score_all_positions(player, drawn_card, profile, game, ...) -> list[tuple[int, float]]:
"""Calculate swap benefit score for each of the 6 positions.
Returns list of (position, score) tuples, sorted by score descending.
Each score represents how much the swap improves the player's hand.
"""
# Lines ~1190-1270 - calls calculate_swap_score() for each position
...
def _select_best_candidate(candidates, player, drawn_card, profile, game, ...) -> Optional[int]:
"""From scored candidates, apply personality modifiers and safety filters.
Handles:
- Minimum improvement threshold
- Personality tie-breaking (pair_hunter prefers pair columns, etc.)
- Unpredictability (occasional random choice with value threshold)
- High-card safety filter (never swap 8+ into hidden positions)
- Blackjack special case (swap to reach exactly 21)
- Endgame safety (discard 8+ rather than force into unknown)
"""
# Lines ~1270-1410
...
def _check_denial_swap(player, drawn_card, profile, game, ...) -> Optional[int]:
"""Check if we should swap to deny opponents a useful card.
Only triggers for profiles with denial_aggression > 0.
Skips hidden positions for high cards (8+).
"""
# Lines ~1410-1480
...
```
### Step 4: Simplify `calculate_swap_score()`
Currently ~240 lines. Some of its complexity comes from inlined pair calculations and standings pressure. Extract:
```python
def _pair_improvement(player, position, new_card, options) -> float:
"""Calculate pair-related benefit of swapping into this position."""
# Would the swap create a new pair? Break an existing pair?
...
def _standings_pressure(player, game) -> float:
"""Calculate how much standings position should affect decisions."""
# Shared between calculate_swap_score and should_take_discard
...
```
### Step 5: Simplify `should_take_discard()`
Currently ~160 lines. Much of the complexity is from re-deriving information that `calculate_swap_score` also computes. After Step 2's utilities exist, this should shrink significantly since `project_score()` and `known_score()` handle the repeated estimation logic.
### Step 6: Clean up `process_cpu_turn()`
Currently ~240 lines. This function is the CPU turn orchestrator and is mostly fine structurally, but has some inline logic for:
- Flip-as-action decisions (~30 lines)
- Knock-early decisions (~30 lines)
- Game logging (~20 lines repeated twice)
Extract:
```python
def _should_flip_as_action(player, game, profile) -> Optional[int]:
"""Decide whether to use flip-as-action and which position."""
...
def _should_knock_early(player, game, profile) -> bool:
"""Decide whether to knock early."""
...
def _log_cpu_action(game_id, player, action, card=None, position=None, reason=""):
"""Log a CPU action if logger is available."""
...
```
---
## Execution Order
1. **Step 1** (constants) - Safe, mechanical, reduces cognitive load immediately
2. **Step 2** (utilities) - Foundation for everything else
3. **Step 3** (decompose choose_swap_or_discard) - The big win
4. **Step 4** (simplify calculate_swap_score) - Benefits from Step 2 utilities
5. **Step 5** (simplify should_take_discard) - Benefits from Step 2 utilities
6. **Step 6** (clean up process_cpu_turn) - Lower priority
**Run `python server/simulate.py 500` before Step 1 and after each step to verify identical behavior.**
---
## Validation Strategy
```bash
# Before any changes - capture baseline
python server/simulate.py 500 > /tmp/ai_baseline.txt
# After each step
python server/simulate.py 500 > /tmp/ai_after_stepN.txt
# Compare key metrics:
# - Average scores per personality
# - "Swapped 8+ into unknown" rate (should stay < 0.1%)
# - Win rate distribution
```
---
## Files Touched
- `server/ai.py` - major restructuring (same file, new internal organization)
- No new files needed (all changes within ai.py unless we decide to split constants out)
## Risk Assessment
- **Low risk** if done mechanically (cut-paste into functions, update call sites)
- **Medium risk** if we accidentally change conditional logic order or miss an early return
- Simulation tests are the safety net - run after every step

View File

@@ -0,0 +1,279 @@
# Plan 1: main.py & game.py Refactor
## Overview
Break apart the 575-line WebSocket handler in `main.py` into discrete message handlers, eliminate repeated patterns (logging, locking, error responses), and clean up `game.py`'s scattered house rule display logic and options boilerplate.
No backwards-compatibility concerns - no existing userbase.
---
## Part A: main.py WebSocket Handler Decomposition
### A1. Create `server/handlers.py` - Message Handler Registry
Extract each `elif msg_type == "..."` block from `websocket_endpoint()` into standalone async handler functions. One function per message type:
```python
# server/handlers.py
async def handle_create_room(ws, data, ctx) -> None: ...
async def handle_join_room(ws, data, ctx) -> None: ...
async def handle_get_cpu_profiles(ws, data, ctx) -> None: ...
async def handle_add_cpu(ws, data, ctx) -> None: ...
async def handle_remove_cpu(ws, data, ctx) -> None: ...
async def handle_start_game(ws, data, ctx) -> None: ...
async def handle_flip_initial(ws, data, ctx) -> None: ...
async def handle_draw(ws, data, ctx) -> None: ...
async def handle_swap(ws, data, ctx) -> None: ...
async def handle_discard(ws, data, ctx) -> None: ...
async def handle_cancel_draw(ws, data, ctx) -> None: ...
async def handle_flip_card(ws, data, ctx) -> None: ...
async def handle_skip_flip(ws, data, ctx) -> None: ...
async def handle_flip_as_action(ws, data, ctx) -> None: ...
async def handle_knock_early(ws, data, ctx) -> None: ...
async def handle_next_round(ws, data, ctx) -> None: ...
async def handle_leave_room(ws, data, ctx) -> None: ...
async def handle_leave_game(ws, data, ctx) -> None: ...
async def handle_end_game(ws, data, ctx) -> None: ...
```
**Context object** passed to every handler:
```python
@dataclass
class ConnectionContext:
websocket: WebSocket
connection_id: str
player_id: str
auth_user_id: Optional[str]
authenticated_user: Optional[User]
current_room: Optional[Room] # mutable reference
```
**Handler dispatch** in `websocket_endpoint()` becomes:
```python
HANDLERS = {
"create_room": handle_create_room,
"join_room": handle_join_room,
# ... etc
}
while True:
data = await websocket.receive_json()
handler = HANDLERS.get(data.get("type"))
if handler:
await handler(data, ctx)
```
This takes `websocket_endpoint()` from ~575 lines to ~30 lines.
### A2. Extract Game Action Logger Helper
The pattern repeated 8 times across draw/swap/discard/flip/skip_flip/flip_as_action/knock_early:
```python
game_logger = get_logger()
if game_logger and current_room.game_log_id and player:
game_logger.log_move(
game_id=current_room.game_log_id,
player=player,
is_cpu=False,
action="...",
card=...,
position=...,
game=current_room.game,
decision_reason="...",
)
```
Extract to:
```python
# In handlers.py or a small helpers module
def log_human_action(room, player, action, card=None, position=None, reason=""):
game_logger = get_logger()
if game_logger and room.game_log_id and player:
game_logger.log_move(
game_id=room.game_log_id,
player=player,
is_cpu=False,
action=action,
card=card,
position=position,
game=room.game,
decision_reason=reason,
)
```
Each handler call site becomes a single line.
### A3. Replace Static File Routes with `StaticFiles` Mount
Currently 15+ hand-written `@app.get()` routes for static files (lines 1188-1255). Replace with:
```python
from fastapi.staticfiles import StaticFiles
# Serve specific HTML routes first
@app.get("/")
async def serve_index():
return FileResponse(os.path.join(client_path, "index.html"))
@app.get("/admin")
async def serve_admin():
return FileResponse(os.path.join(client_path, "admin.html"))
@app.get("/replay/{share_code}")
async def serve_replay_page(share_code: str):
return FileResponse(os.path.join(client_path, "index.html"))
# Mount static files for everything else (JS, CSS, SVG, etc.)
app.mount("/", StaticFiles(directory=client_path), name="static")
```
Eliminates ~70 lines and auto-handles any new client files without code changes.
### A4. Clean Up Lifespan Service Init
The lifespan function (lines 83-242) has a deeply nested try/except block initializing ~8 services with lots of `set_*` calls. Simplify by extracting service init:
```python
async def _init_database_services():
"""Initialize all PostgreSQL-dependent services. Returns dict of services."""
# All the import/init/set logic currently in lifespan
...
async def _init_redis(redis_url):
"""Initialize Redis client and rate limiter."""
...
@asynccontextmanager
async def lifespan(app: FastAPI):
if config.REDIS_URL:
await _init_redis(config.REDIS_URL)
if config.POSTGRES_URL:
await _init_database_services()
# health check setup
...
yield
# shutdown...
```
---
## Part B: game.py Cleanup
### B1. Data-Driven Active Rules Display
Replace the 38-line if-chain in `get_state()` (lines 1546-1584) with a declarative approach:
```python
# On GameOptions class or as module-level constant
_RULE_DISPLAY = [
# (attribute, display_name, condition_fn_or_None)
("knock_penalty", "Knock Penalty", None),
("lucky_swing", "Lucky Swing", None),
("eagle_eye", "Eagle-Eye", None),
("super_kings", "Super Kings", None),
("ten_penny", "Ten Penny", None),
("knock_bonus", "Knock Bonus", None),
("underdog_bonus", "Underdog", None),
("tied_shame", "Tied Shame", None),
("blackjack", "Blackjack", None),
("wolfpack", "Wolfpack", None),
("flip_as_action", "Flip as Action", None),
("four_of_a_kind", "Four of a Kind", None),
("negative_pairs_keep_value", "Negative Pairs Keep Value", None),
("one_eyed_jacks", "One-Eyed Jacks", None),
("knock_early", "Early Knock", None),
]
def get_active_rules(self) -> list[str]:
rules = []
# Special: flip mode
if self.options.flip_mode == FlipMode.ALWAYS.value:
rules.append("Speed Golf")
elif self.options.flip_mode == FlipMode.ENDGAME.value:
rules.append("Endgame Flip")
# Special: jokers (only if not overridden by lucky_swing/eagle_eye)
if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye:
rules.append("Jokers")
# Boolean rules
for attr, display_name, _ in _RULE_DISPLAY:
if getattr(self.options, attr):
rules.append(display_name)
return rules
```
### B2. Simplify `_options_to_dict()`
Replace the 22-line manual dict construction (lines 791-813) with `dataclasses.asdict()` or a simple comprehension:
```python
from dataclasses import asdict
def _options_to_dict(self) -> dict:
return asdict(self.options)
```
Or if we want to exclude `deck_colors` or similar:
```python
def _options_to_dict(self) -> dict:
return {k: v for k, v in asdict(self.options).items()}
```
### B3. Add `GameOptions.to_start_game_dict()` for main.py
The `start_game` handler in main.py (lines 663-689) manually maps 17 `data.get()` calls to `GameOptions()`. Add a classmethod:
```python
@classmethod
def from_client_data(cls, data: dict) -> "GameOptions":
"""Build GameOptions from client WebSocket message data."""
return cls(
flip_mode=data.get("flip_mode", "never"),
initial_flips=max(0, min(2, data.get("initial_flips", 2))),
knock_penalty=data.get("knock_penalty", False),
use_jokers=data.get("use_jokers", False),
lucky_swing=data.get("lucky_swing", False),
super_kings=data.get("super_kings", False),
ten_penny=data.get("ten_penny", False),
knock_bonus=data.get("knock_bonus", False),
underdog_bonus=data.get("underdog_bonus", False),
tied_shame=data.get("tied_shame", False),
blackjack=data.get("blackjack", False),
eagle_eye=data.get("eagle_eye", False),
wolfpack=data.get("wolfpack", False),
flip_as_action=data.get("flip_as_action", False),
four_of_a_kind=data.get("four_of_a_kind", False),
negative_pairs_keep_value=data.get("negative_pairs_keep_value", False),
one_eyed_jacks=data.get("one_eyed_jacks", False),
knock_early=data.get("knock_early", False),
deck_colors=data.get("deck_colors", ["red", "blue", "gold"]),
)
```
This keeps the construction logic on the class that owns it and out of the WebSocket handler.
---
## Execution Order
1. **B2, B3** (game.py small wins) - low risk, immediate cleanup
2. **A2** (log helper) - extract before moving handlers, so handlers are clean from the start
3. **A1** (handler extraction) - the big refactor, each handler is a cut-paste + cleanup
4. **A3** (static file mount) - easy win, independent
5. **B1** (active rules) - can do anytime
6. **A4** (lifespan cleanup) - lower priority, nice-to-have
## Files Touched
- `server/main.py` - major changes (handler extraction, static files, lifespan)
- `server/handlers.py` - **new file** with all message handlers
- `server/game.py` - minor changes (active rules, options_to_dict, from_client_data)
## Testing
- All existing tests in `test_game.py` should continue passing (game.py changes are additive/cosmetic)
- The WebSocket handler refactor is structural only - same logic, just reorganized
- Manual smoke test: create room, add CPU, play a round, verify everything works

175
docs/v3/refactor-misc.md Normal file
View File

@@ -0,0 +1,175 @@
# Plan 3: Miscellaneous Refactoring & Improvements
## Overview
Everything that doesn't fall under the main.py/game.py or ai.py refactors: shared utilities, dead code, test improvements, and structural cleanup.
---
## M1. Duplicate `get_card_value` Functions
There are currently **three** functions that compute card values:
1. `game.py:get_card_value(card: Card, options)` - Takes Card objects
2. `constants.py:get_card_value_for_rank(rank_str, options_dict)` - Takes rank strings
3. `ai.py:get_ai_card_value(card, options)` - AI-specific wrapper (also handles face-down estimation)
**Problem:** `game.py` and `constants.py` do the same thing with different interfaces, and neither handles all house rules identically. The AI version adds face-down logic but duplicates the base value lookup.
**Fix:**
- Keep `game.py:get_card_value()` as the canonical Card-based function (it already is the most complete)
- Keep `constants.py:get_card_value_for_rank()` for string-based lookups from logs/JSON
- Have `ai.py:get_ai_card_value()` delegate to `game.py:get_card_value()` for the base value, only adding its face-down estimation on top
- Add a brief comment in each noting which is canonical and why each variant exists
This is a minor cleanup - the current code works, it's just slightly confusing to have three entry points.
## M2. `GameOptions` Boilerplate Reduction
`GameOptions` currently has 17+ boolean fields. Every time a new house rule is added, you have to update:
1. `GameOptions` dataclass definition
2. `_options_to_dict()` in game.py
3. `get_active_rules()` logic in `get_state()`
4. `from_client_data()` (proposed in Plan 1)
5. `start_game` handler in main.py (currently, will move to handlers.py)
**Fix:** Use `dataclasses.fields()` introspection to auto-generate the dict and client data parsing:
```python
from dataclasses import fields, asdict
# _options_to_dict becomes:
def _options_to_dict(self) -> dict:
return asdict(self.options)
# from_client_data becomes:
@classmethod
def from_client_data(cls, data: dict) -> "GameOptions":
field_defaults = {f.name: f.default for f in fields(cls)}
kwargs = {}
for f in fields(cls):
if f.name in data:
kwargs[f.name] = data[f.name]
# Special validation
kwargs["initial_flips"] = max(0, min(2, kwargs.get("initial_flips", 2)))
return cls(**kwargs)
```
This means adding a new house rule only requires adding the field to `GameOptions` and its entry in the active_rules display table (from Plan 1's B1).
## M3. Consolidate Game Logger Pattern in AI
`ai.py:process_cpu_turn()` has the same logger boilerplate as main.py's human handlers. After Plan 1's A2 creates `log_human_action()`, create a parallel:
```python
def log_cpu_action(game_id, player, action, card=None, position=None, game=None, reason=""):
game_logger = get_logger()
if game_logger and game_id:
game_logger.log_move(
game_id=game_id,
player=player,
is_cpu=True,
action=action,
card=card,
position=position,
game=game,
decision_reason=reason,
)
```
This appears ~4 times in `process_cpu_turn()`.
## M4. `Player.get_player()` Linear Search
`Game.get_player()` does a linear scan of the players list:
```python
def get_player(self, player_id: str) -> Optional[Player]:
for player in self.players:
if player.id == player_id:
return player
return None
```
With max 6 players this is fine performance-wise, but it's called frequently. Could add a `_player_lookup: dict[str, Player]` cache maintained by `add_player`/`remove_player`. Very minor optimization - only worth doing if we're already touching these methods.
## M5. Room Code Collision Potential
`RoomManager._generate_code()` generates random 4-letter codes and retries on collision. With 26^4 = 456,976 possibilities this is fine now, but if we ever scale, the while-True loop could theoretically spin. Low priority, but a simple improvement:
```python
def _generate_code(self, max_attempts=100) -> str:
for _ in range(max_attempts):
code = "".join(random.choices(string.ascii_uppercase, k=4))
if code not in self.rooms:
return code
raise RuntimeError("Could not generate unique room code")
```
## M6. Test Coverage Gaps
Current test files:
- `test_game.py` - Core game logic (good coverage)
- `test_house_rules.py` - House rule scoring
- `test_v3_features.py` - New v3 features
- `test_maya_bug.py` - Specific regression test
- `tests/test_event_replay.py`, `test_persistence.py`, `test_replay.py` - Event system
**Missing:**
- No tests for `room.py` (Room, RoomManager, RoomPlayer)
- No tests for WebSocket message handlers (will be much easier to test after Plan 1's handler extraction)
- No unit tests for individual AI decision functions (will be much easier after Plan 2's decomposition)
**Recommendation:** After Plans 1 and 2 are complete, add:
- `test_handlers.py` - Test each message handler with mock WebSocket/Room
- `test_ai_decisions.py` - Test individual AI sub-functions (go-out logic, denial, etc.)
- `test_room.py` - Test Room/RoomManager CRUD operations
## M7. Unused/Dead Code Audit
Things to verify and potentially remove:
- `score_analysis.py` - Is this used anywhere or was it a one-off analysis tool?
- `game_analyzer.py` - Same question
- `auth.py` (top-level, not in routers/) - Appears to be an old file superseded by `services/auth_service.py`?
- `models/game_state.py` - Check if used or leftover from earlier design
## M8. Type Hints Consistency
Some functions have full type hints, others don't. The AI functions especially are loosely typed. After the ai.py refactor (Plan 2), ensure all new sub-functions have proper type hints:
```python
def _check_go_out_swap(
player: Player,
drawn_card: Card,
profile: CPUProfile,
game: Game,
game_state: dict,
) -> Optional[int]:
```
This helps with IDE navigation and catching bugs during future changes.
---
## Execution Order
1. **M3** (AI logger helper) - Do alongside Plan 1's A2
2. **M2** (GameOptions introspection) - Do alongside Plan 1's B2/B3
3. **M1** (card value consolidation) - Quick cleanup
4. **M7** (dead code audit) - Quick investigation
5. **M5** (room code safety) - 2 lines
6. **M6** (tests) - After Plans 1 and 2 are complete
7. **M4** (player lookup) - Only if touching add/remove_player for other reasons
8. **M8** (type hints) - Ongoing, do as part of Plan 2
## Files Touched
- `server/ai.py` - logger helper, card value delegation
- `server/game.py` - GameOptions introspection
- `server/constants.py` - comments clarifying role
- `server/room.py` - room code safety (minor)
- `server/test_room.py` - **new file** (eventually)
- `server/test_handlers.py` - **new file** (eventually)
- `server/test_ai_decisions.py` - **new file** (eventually)
- Various files checked in dead code audit

View File

@@ -0,0 +1,42 @@
# Remaining Refactor Tasks
Leftover items from the v3 refactor plans that are functional but could benefit from further cleanup.
---
## R1. Decompose `calculate_swap_score()` (from Plan 2, Step 4)
**File:** `server/ai.py` (~236 lines)
Scores a single position for swapping. Still long with inline pair calculations, point gain logic, reveal bonuses, and comeback bonuses. Could extract:
- `_pair_improvement(player, position, new_card, options)` — pair-related benefit of swapping into a position
- `_standings_pressure(player, game)` — how much standings position should affect decisions (shared with `should_take_discard`)
**Validation:** `python server/simulate.py 500` before and after — stats should match within normal variance.
---
## R2. Decompose `should_take_discard()` (from Plan 2, Step 5)
**File:** `server/ai.py` (~148 lines)
Decides whether to take from discard pile. Contains a nested `has_good_swap_option()` helper. After R1's extracted utilities exist, this should shrink since `project_score()` and `known_score()` handle the repeated estimation logic.
**Validation:** Same simulation approach as R1.
---
## R3. New Test Files (from Plan 3, M6)
After Plans 1 and 2, the extracted handlers and AI sub-functions are much easier to unit test. Add:
- **`server/test_handlers.py`** — Test each message handler with mock WebSocket/Room
- **`server/test_ai_decisions.py`** — Test individual AI sub-functions (go-out logic, denial, etc.)
- **`server/test_room.py`** — Test Room/RoomManager CRUD operations
---
## Priority
R1 and R2 are pure structural refactors — no behavior changes, low risk, but also low urgency since the code works fine. R3 adds safety nets for future changes.

123
pyproject.toml Normal file
View File

@@ -0,0 +1,123 @@
[project]
name = "golfgame"
version = "3.1.1"
description = "6-Card Golf card game with AI opponents"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [
{name = "alee"}
]
keywords = ["card-game", "golf", "websocket", "fastapi", "ai"]
classifiers = [
"Development Status :: 3 - Alpha",
"Framework :: FastAPI",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Games/Entertainment :: Board Games",
]
dependencies = [
"fastapi>=0.109.0",
"uvicorn[standard]>=0.27.0",
"websockets>=12.0",
"python-dotenv>=1.0.0",
# V2: Event sourcing infrastructure
"asyncpg>=0.29.0",
"redis>=5.0.0",
# V2: Authentication
"bcrypt>=4.1.0",
"resend>=2.0.0",
# V2: Production monitoring (optional but recommended)
"sentry-sdk[fastapi]>=1.40.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
"ruff>=0.1.0",
"mypy>=1.8.0",
]
[project.scripts]
golfgame = "server.main:run"
[project.urls]
Homepage = "https://github.com/alee/golfgame"
Repository = "https://github.com/alee/golfgame"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["server"]
# ============================================================================
# Tool Configuration
# ============================================================================
[tool.pytest.ini_options]
testpaths = ["server"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
asyncio_mode = "auto"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = [
"E501", # line too long (handled by formatter)
"B008", # do not perform function calls in argument defaults
]
[tool.ruff.lint.isort]
known-first-party = ["server"]
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = true
ignore_missing_imports = true
# ============================================================================
# Game Configuration Defaults
# ============================================================================
# These can be overridden via environment variables
# See .env.example for documentation
[tool.golfgame]
# Server settings
host = "0.0.0.0"
port = 8000
debug = false
log_level = "INFO"
# Database
database_url = "sqlite:///server/games.db"
# Game defaults
default_rounds = 9
max_players_per_room = 6
room_timeout_minutes = 60
# Card values (standard 6-Card Golf)
# These are defined in server/constants.py

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."

9
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
set -e
DROPLET="root@165.245.152.51"
REMOTE_DIR="/opt/golfgame"
echo "Deploying to $DROPLET..."
ssh $DROPLET "cd $REMOTE_DIR && git pull origin main && docker compose -f docker-compose.prod.yml up -d --build app"
echo "Deploy complete."

39
scripts/dev-server.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
#
# Start the Golf Game development server
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
# Check if venv exists
if [ ! -f "bin/python" ]; then
echo "Virtual environment not found. Run ./scripts/install.sh first."
exit 1
fi
# Check if Docker services are running
if command -v docker &> /dev/null; then
if ! docker ps --filter "name=redis" --format "{{.Names}}" 2>/dev/null | grep -q redis; then
echo "Warning: Redis container not running. Start with:"
echo " docker-compose -f docker-compose.dev.yml up -d"
echo ""
fi
fi
# Load .env if exists
if [ -f ".env" ]; then
set -a
source .env
set +a
fi
echo "Starting Golf Game development server..."
echo "Server will be available at http://localhost:${PORT:-8000}"
echo ""
cd server
exec ../bin/uvicorn main:app --reload --host "${HOST:-0.0.0.0}" --port "${PORT:-8000}"

43
scripts/docker-build.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/bash
#
# Build Docker images for Golf Game
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
# Colors
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
IMAGE_NAME="${IMAGE_NAME:-golfgame}"
TAG="${TAG:-latest}"
echo -e "${BLUE}Building Golf Game Docker image...${NC}"
echo "Image: $IMAGE_NAME:$TAG"
echo ""
docker build -t "$IMAGE_NAME:$TAG" .
echo ""
echo -e "${GREEN}Build complete!${NC}"
echo ""
echo "To run with docker-compose (production):"
echo ""
echo " export DB_PASSWORD=your-secure-password"
echo " export SECRET_KEY=\$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
echo " export ACME_EMAIL=your-email@example.com"
echo " export DOMAIN=your-domain.com"
echo " docker-compose -f docker-compose.prod.yml up -d"
echo ""
echo "To run standalone:"
echo ""
echo " docker run -d -p 8000:8000 \\"
echo " -e POSTGRES_URL=postgresql://user:pass@host:5432/golf \\"
echo " -e REDIS_URL=redis://host:6379 \\"
echo " -e SECRET_KEY=your-secret-key \\"
echo " $IMAGE_NAME:$TAG"

529
scripts/install.sh Executable file
View File

@@ -0,0 +1,529 @@
#!/bin/bash
#
# Golf Game Installer
#
# This script provides a menu-driven installation for the Golf card game.
# Run with: ./scripts/install.sh
#
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Get the directory where this script lives
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
echo -e "${BLUE}"
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Golf Game Installer ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
show_menu() {
echo ""
echo "Select an option:"
echo ""
echo " 1) Development Setup"
echo " - Start Docker services (PostgreSQL, Redis)"
echo " - Create Python virtual environment"
echo " - Install dependencies"
echo " - Create .env from template"
echo ""
echo " 2) Production Install to /opt/golfgame"
echo " - Install application to /opt/golfgame"
echo " - Create production .env"
echo " - Set up systemd service"
echo ""
echo " 3) Docker Services Only"
echo " - Start PostgreSQL and Redis containers"
echo ""
echo " 4) Create/Update Systemd Service"
echo " - Create or update the systemd service file"
echo ""
echo " 5) Uninstall Production"
echo " - Stop and remove systemd service"
echo " - Optionally remove /opt/golfgame"
echo ""
echo " 6) Show Status"
echo " - Check Docker containers"
echo " - Check systemd service"
echo " - Test endpoints"
echo ""
echo " q) Quit"
echo ""
}
check_requirements() {
local missing=()
if ! command -v python3 &> /dev/null; then
missing+=("python3")
fi
if ! command -v docker &> /dev/null; then
missing+=("docker")
fi
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
missing+=("docker-compose")
fi
if [ ${#missing[@]} -gt 0 ]; then
echo -e "${RED}Missing required tools: ${missing[*]}${NC}"
echo "Please install them before continuing."
return 1
fi
return 0
}
start_docker_services() {
echo -e "${BLUE}Starting Docker services...${NC}"
cd "$PROJECT_DIR"
if docker compose version &> /dev/null; then
docker compose -f docker-compose.dev.yml up -d
else
docker-compose -f docker-compose.dev.yml up -d
fi
echo -e "${GREEN}Docker services started.${NC}"
echo ""
echo "Services:"
echo " - PostgreSQL: localhost:5432 (user: golf, password: devpassword, db: golf)"
echo " - Redis: localhost:6379"
}
setup_dev_venv() {
echo -e "${BLUE}Setting up Python virtual environment...${NC}"
cd "$PROJECT_DIR"
# Check Python version
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
echo "Using Python $PYTHON_VERSION"
# Remove old venv if it exists and is broken
if [ -f "pyvenv.cfg" ]; then
if [ -L "bin/python" ] && [ ! -e "bin/python" ]; then
echo -e "${YELLOW}Removing broken virtual environment...${NC}"
rm -rf bin lib lib64 pyvenv.cfg include share 2>/dev/null || true
fi
fi
# Create venv if it doesn't exist
if [ ! -f "pyvenv.cfg" ]; then
echo "Creating virtual environment..."
python3 -m venv .
fi
# Install dependencies
echo "Installing dependencies..."
./bin/pip install --upgrade pip
./bin/pip install -e ".[dev]"
echo -e "${GREEN}Virtual environment ready.${NC}"
}
setup_dev_env() {
echo -e "${BLUE}Setting up .env file...${NC}"
cd "$PROJECT_DIR"
if [ -f ".env" ]; then
echo -e "${YELLOW}.env file already exists. Overwrite? (y/N)${NC}"
read -r response
if [[ ! "$response" =~ ^[Yy]$ ]]; then
echo "Keeping existing .env"
return
fi
fi
cat > .env << 'EOF'
# Golf Game Development Configuration
# Generated by install.sh
HOST=0.0.0.0
PORT=8000
DEBUG=true
LOG_LEVEL=DEBUG
ENVIRONMENT=development
# PostgreSQL (from docker-compose.dev.yml)
DATABASE_URL=postgresql://golf:devpassword@localhost:5432/golf
POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf
# Room Settings
MAX_PLAYERS_PER_ROOM=6
ROOM_TIMEOUT_MINUTES=60
ROOM_CODE_LENGTH=4
# Game Defaults
DEFAULT_ROUNDS=9
DEFAULT_INITIAL_FLIPS=2
DEFAULT_USE_JOKERS=false
DEFAULT_FLIP_ON_DISCARD=false
EOF
echo -e "${GREEN}.env file created.${NC}"
}
dev_setup() {
echo -e "${BLUE}=== Development Setup ===${NC}"
echo ""
if ! check_requirements; then
return 1
fi
start_docker_services
echo ""
setup_dev_venv
echo ""
setup_dev_env
echo ""
echo -e "${GREEN}=== Development Setup Complete ===${NC}"
echo ""
echo "To start the development server:"
echo ""
echo " cd $PROJECT_DIR/server"
echo " ../bin/uvicorn main:app --reload --host 0.0.0.0 --port 8000"
echo ""
echo "Or use the helper script:"
echo ""
echo " $PROJECT_DIR/scripts/dev-server.sh"
echo ""
}
prod_install() {
echo -e "${BLUE}=== Production Installation ===${NC}"
echo ""
INSTALL_DIR="/opt/golfgame"
# Check if running as root or with sudo available
if [ "$EUID" -ne 0 ]; then
if ! command -v sudo &> /dev/null; then
echo -e "${RED}This option requires root privileges. Run with sudo or as root.${NC}"
return 1
fi
SUDO="sudo"
else
SUDO=""
fi
echo "This will install Golf Game to $INSTALL_DIR"
echo -e "${YELLOW}Continue? (y/N)${NC}"
read -r response
if [[ ! "$response" =~ ^[Yy]$ ]]; then
echo "Aborted."
return
fi
# Create directory
echo "Creating $INSTALL_DIR..."
$SUDO mkdir -p "$INSTALL_DIR"
# Copy files
echo "Copying application files..."
$SUDO cp -r "$PROJECT_DIR/server" "$INSTALL_DIR/"
$SUDO cp -r "$PROJECT_DIR/client" "$INSTALL_DIR/"
$SUDO cp "$PROJECT_DIR/pyproject.toml" "$INSTALL_DIR/"
$SUDO cp "$PROJECT_DIR/README.md" "$INSTALL_DIR/"
$SUDO cp "$PROJECT_DIR/INSTALL.md" "$INSTALL_DIR/"
$SUDO cp "$PROJECT_DIR/.env.example" "$INSTALL_DIR/"
$SUDO cp -r "$PROJECT_DIR/scripts" "$INSTALL_DIR/"
# Create venv
echo "Creating virtual environment..."
$SUDO python3 -m venv "$INSTALL_DIR"
$SUDO "$INSTALL_DIR/bin/pip" install --upgrade pip
$SUDO "$INSTALL_DIR/bin/pip" install "$INSTALL_DIR"
# Create production .env if it doesn't exist
if [ ! -f "$INSTALL_DIR/.env" ]; then
echo "Creating production .env..."
# Generate a secret key
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
$SUDO tee "$INSTALL_DIR/.env" > /dev/null << EOF
# Golf Game Production Configuration
# Generated by install.sh
HOST=0.0.0.0
PORT=8000
DEBUG=false
LOG_LEVEL=INFO
ENVIRONMENT=production
# PostgreSQL - UPDATE THESE VALUES
DATABASE_URL=postgresql://golf:CHANGE_ME@localhost:5432/golf
POSTGRES_URL=postgresql://golf:CHANGE_ME@localhost:5432/golf
# Security
SECRET_KEY=$SECRET_KEY
# Room Settings
MAX_PLAYERS_PER_ROOM=6
ROOM_TIMEOUT_MINUTES=60
ROOM_CODE_LENGTH=4
# Game Defaults
DEFAULT_ROUNDS=9
DEFAULT_INITIAL_FLIPS=2
DEFAULT_USE_JOKERS=false
DEFAULT_FLIP_ON_DISCARD=false
# Optional: Sentry error tracking
# SENTRY_DSN=https://your-sentry-dsn
EOF
$SUDO chmod 600 "$INSTALL_DIR/.env"
fi
# Set ownership
echo "Setting permissions..."
$SUDO chown -R www-data:www-data "$INSTALL_DIR"
echo -e "${GREEN}Application installed to $INSTALL_DIR${NC}"
echo ""
echo -e "${YELLOW}IMPORTANT: Edit $INSTALL_DIR/.env and update:${NC}"
echo " - DATABASE_URL / POSTGRES_URL with your PostgreSQL credentials"
echo " - Any other settings as needed"
echo ""
# Offer to set up systemd
echo "Set up systemd service now? (Y/n)"
read -r response
if [[ ! "$response" =~ ^[Nn]$ ]]; then
setup_systemd
fi
}
setup_systemd() {
echo -e "${BLUE}=== Systemd Service Setup ===${NC}"
echo ""
INSTALL_DIR="/opt/golfgame"
SERVICE_FILE="/etc/systemd/system/golfgame.service"
if [ "$EUID" -ne 0 ]; then
if ! command -v sudo &> /dev/null; then
echo -e "${RED}This option requires root privileges.${NC}"
return 1
fi
SUDO="sudo"
else
SUDO=""
fi
if [ ! -d "$INSTALL_DIR" ]; then
echo -e "${RED}$INSTALL_DIR does not exist. Run production install first.${NC}"
return 1
fi
echo "Creating systemd service..."
$SUDO tee "$SERVICE_FILE" > /dev/null << 'EOF'
[Unit]
Description=Golf Card Game Server
Documentation=https://github.com/alee/golfgame
After=network.target postgresql.service redis.service
Wants=postgresql.service redis.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/golfgame/server
Environment="PATH=/opt/golfgame/bin:/usr/local/bin:/usr/bin:/bin"
EnvironmentFile=/opt/golfgame/.env
ExecStart=/opt/golfgame/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/golfgame
[Install]
WantedBy=multi-user.target
EOF
echo "Reloading systemd..."
$SUDO systemctl daemon-reload
echo "Enabling service..."
$SUDO systemctl enable golfgame
echo -e "${GREEN}Systemd service created.${NC}"
echo ""
echo "Commands:"
echo " sudo systemctl start golfgame # Start the service"
echo " sudo systemctl stop golfgame # Stop the service"
echo " sudo systemctl restart golfgame # Restart the service"
echo " sudo systemctl status golfgame # Check status"
echo " journalctl -u golfgame -f # View logs"
echo ""
echo "Start the service now? (Y/n)"
read -r response
if [[ ! "$response" =~ ^[Nn]$ ]]; then
$SUDO systemctl start golfgame
sleep 2
$SUDO systemctl status golfgame --no-pager
fi
}
uninstall_prod() {
echo -e "${BLUE}=== Production Uninstall ===${NC}"
echo ""
if [ "$EUID" -ne 0 ]; then
if ! command -v sudo &> /dev/null; then
echo -e "${RED}This option requires root privileges.${NC}"
return 1
fi
SUDO="sudo"
else
SUDO=""
fi
echo -e "${YELLOW}This will stop and remove the systemd service.${NC}"
echo "Continue? (y/N)"
read -r response
if [[ ! "$response" =~ ^[Yy]$ ]]; then
echo "Aborted."
return
fi
# Stop and disable service
if [ -f "/etc/systemd/system/golfgame.service" ]; then
echo "Stopping service..."
$SUDO systemctl stop golfgame 2>/dev/null || true
$SUDO systemctl disable golfgame 2>/dev/null || true
$SUDO rm -f /etc/systemd/system/golfgame.service
$SUDO systemctl daemon-reload
echo "Service removed."
else
echo "No systemd service found."
fi
# Optionally remove installation directory
if [ -d "/opt/golfgame" ]; then
echo ""
echo -e "${YELLOW}Remove /opt/golfgame directory? (y/N)${NC}"
read -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
$SUDO rm -rf /opt/golfgame
echo "Directory removed."
else
echo "Directory kept."
fi
fi
echo -e "${GREEN}Uninstall complete.${NC}"
}
show_status() {
echo -e "${BLUE}=== Status Check ===${NC}"
echo ""
# Docker containers
echo "Docker Containers:"
if command -v docker &> /dev/null; then
docker ps --filter "name=golfgame" --format " {{.Names}}: {{.Status}}" 2>/dev/null || echo " (none running)"
echo ""
else
echo " Docker not installed"
echo ""
fi
# Systemd service
echo "Systemd Service:"
if [ -f "/etc/systemd/system/golfgame.service" ]; then
systemctl status golfgame --no-pager 2>/dev/null | head -5 || echo " Service not running"
else
echo " Not installed"
fi
echo ""
# Health check
echo "Health Check:"
for port in 8000; do
if curl -s "http://localhost:$port/health" > /dev/null 2>&1; then
response=$(curl -s "http://localhost:$port/health")
echo -e " Port $port: ${GREEN}OK${NC} - $response"
else
echo -e " Port $port: ${RED}Not responding${NC}"
fi
done
echo ""
# Database
echo "PostgreSQL:"
if command -v pg_isready &> /dev/null; then
if pg_isready -h localhost -p 5432 > /dev/null 2>&1; then
echo -e " ${GREEN}Running${NC} on localhost:5432"
else
echo -e " ${RED}Not responding${NC}"
fi
else
if docker ps --filter "name=postgres" --format "{{.Names}}" 2>/dev/null | grep -q postgres; then
echo -e " ${GREEN}Running${NC} (Docker)"
else
echo " Unable to check (pg_isready not installed)"
fi
fi
echo ""
# Redis
echo "Redis:"
if command -v redis-cli &> /dev/null; then
if redis-cli ping > /dev/null 2>&1; then
echo -e " ${GREEN}Running${NC} on localhost:6379"
else
echo -e " ${RED}Not responding${NC}"
fi
else
if docker ps --filter "name=redis" --format "{{.Names}}" 2>/dev/null | grep -q redis; then
echo -e " ${GREEN}Running${NC} (Docker)"
else
echo " Unable to check (redis-cli not installed)"
fi
fi
}
# Main loop
while true; do
show_menu
echo -n "Enter choice: "
read -r choice
case $choice in
1) dev_setup ;;
2) prod_install ;;
3) start_docker_services ;;
4) setup_systemd ;;
5) uninstall_prod ;;
6) show_status ;;
q|Q) echo "Goodbye!"; exit 0 ;;
*) echo -e "${RED}Invalid option${NC}" ;;
esac
echo ""
echo "Press Enter to continue..."
read -r
done

73
server/.env.example Normal file
View File

@@ -0,0 +1,73 @@
# Golf Game Server Configuration
# Copy this file to .env and adjust values as needed
# Server settings
HOST=0.0.0.0
PORT=8000
DEBUG=true
LOG_LEVEL=DEBUG
# Per-module log level overrides (optional)
# These override LOG_LEVEL for specific modules.
# LOG_LEVEL_GAME=DEBUG # Core game logic
# LOG_LEVEL_AI=DEBUG # AI decisions (very verbose at DEBUG)
# LOG_LEVEL_HANDLERS=DEBUG # WebSocket message handlers
# LOG_LEVEL_ROOM=DEBUG # Room/lobby management
# LOG_LEVEL_AUTH=DEBUG # Auth stack (auth, routers.auth, services.auth_service)
# LOG_LEVEL_STORES=DEBUG # Database/Redis operations
# --- Preset examples ---
# Staging (debug game logic, quiet everything else):
# LOG_LEVEL=INFO
# LOG_LEVEL_GAME=DEBUG
# LOG_LEVEL_AI=DEBUG
#
# Production (minimal logging):
# LOG_LEVEL=WARNING
# Environment (development, staging, production)
# Affects logging format, security headers (HSTS), etc.
ENVIRONMENT=development
# Legacy SQLite database (for analytics/auth)
DATABASE_URL=sqlite:///games.db
# V2: PostgreSQL for event store
# Used with: docker-compose -f docker-compose.dev.yml up -d
POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf
# V2: Redis for live state cache and pub/sub
# Used with: docker-compose -f docker-compose.dev.yml up -d
REDIS_URL=redis://localhost:6379
# Room settings
MAX_PLAYERS_PER_ROOM=6
ROOM_TIMEOUT_MINUTES=60
# Security (optional)
# SECRET_KEY=your-secret-key-here
# INVITE_ONLY=false
# ADMIN_EMAILS=admin@example.com,another@example.com
# V2: Email configuration (Resend)
# Get API key from https://resend.com
# RESEND_API_KEY=re_xxxxxxxx
# EMAIL_FROM=Golf Game <noreply@yourdomain.com>
# V2: Base URL for email links
# BASE_URL=http://localhost:8000
# V2: Session settings
# SESSION_EXPIRY_HOURS=168
# V2: Email verification
# Set to true to require email verification before login
# REQUIRE_EMAIL_VERIFICATION=false
# V2: Rate limiting
# Set to false to disable API rate limiting
# RATE_LIMIT_ENABLED=true
# V2: Error tracking (Sentry)
# Get DSN from https://sentry.io
# SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

602
server/auth.py Normal file
View File

@@ -0,0 +1,602 @@
"""
Authentication and user management for Golf game.
Features:
- User accounts stored in SQLite
- Admin accounts can manage other users
- Invite codes (room codes) allow new user registration
- Session-based authentication via tokens
"""
import hashlib
import secrets
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import Optional
from config import config
class UserRole(Enum):
"""User roles for access control."""
USER = "user"
ADMIN = "admin"
@dataclass
class User:
"""User account."""
id: str
username: str
email: Optional[str]
password_hash: str
role: UserRole
created_at: datetime
last_login: Optional[datetime]
is_active: bool
invited_by: Optional[str] # Username of who invited them
def is_admin(self) -> bool:
return self.role == UserRole.ADMIN
def to_dict(self, include_sensitive: bool = False) -> dict:
"""Convert to dictionary for API responses."""
data = {
"id": self.id,
"username": self.username,
"email": self.email,
"role": self.role.value,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_login": self.last_login.isoformat() if self.last_login else None,
"is_active": self.is_active,
"invited_by": self.invited_by,
}
if include_sensitive:
data["password_hash"] = self.password_hash
return data
@dataclass
class Session:
"""User session."""
token: str
user_id: str
created_at: datetime
expires_at: datetime
def is_expired(self) -> bool:
return datetime.now() > self.expires_at
@dataclass
class InviteCode:
"""Invite code for user registration."""
code: str
created_by: str # User ID who created the invite
created_at: datetime
expires_at: Optional[datetime]
max_uses: int
use_count: int
is_active: bool
def is_valid(self) -> bool:
if not self.is_active:
return False
if self.expires_at and datetime.now() > self.expires_at:
return False
if self.max_uses > 0 and self.use_count >= self.max_uses:
return False
return True
class AuthManager:
"""Manages user authentication and authorization."""
def __init__(self, db_path: str = "games.db"):
self.db_path = Path(db_path)
self._init_db()
self._ensure_admin()
def _init_db(self):
"""Initialize auth database schema."""
with sqlite3.connect(self.db_path) as conn:
conn.executescript("""
-- Users table
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
invited_by TEXT
);
-- Sessions table
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
-- Invite codes table
CREATE TABLE IF NOT EXISTS invite_codes (
code TEXT PRIMARY KEY,
created_by TEXT REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
max_uses INTEGER DEFAULT 1,
use_count INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT 1
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_invite_codes_active ON invite_codes(is_active);
""")
def _ensure_admin(self):
"""Ensure at least one admin account exists (without password - must be set on first login)."""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute(
"SELECT COUNT(*) FROM users WHERE role = ?",
(UserRole.ADMIN.value,)
)
admin_count = cursor.fetchone()[0]
if admin_count == 0:
# Check if admin emails are configured
if config.ADMIN_EMAILS:
# Create admin accounts for configured emails (no password yet)
for email in config.ADMIN_EMAILS:
username = email.split("@")[0]
self._create_user_without_password(
username=username,
email=email,
role=UserRole.ADMIN,
)
print(f"Created admin account: {username} - password must be set on first login")
else:
# Create default admin if no admins exist (no password yet)
self._create_user_without_password(
username="admin",
role=UserRole.ADMIN,
)
print("Created default admin account - password must be set on first login")
print("Set ADMIN_EMAILS in .env to configure admin accounts.")
def _create_user_without_password(
self,
username: str,
email: Optional[str] = None,
role: UserRole = UserRole.USER,
) -> Optional[str]:
"""Create a user without a password (for first-time setup)."""
user_id = secrets.token_hex(16)
# Empty password_hash indicates password needs to be set
password_hash = ""
try:
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"""
INSERT INTO users (id, username, email, password_hash, role)
VALUES (?, ?, ?, ?, ?)
""",
(user_id, username, email, password_hash, role.value),
)
return user_id
except sqlite3.IntegrityError:
return None
def needs_password_setup(self, username: str) -> bool:
"""Check if user needs to set up their password (first login)."""
user = self.get_user_by_username(username)
if not user:
return False
return user.password_hash == ""
def setup_password(self, username: str, new_password: str) -> Optional[User]:
"""Set password for first-time setup. Only works if password is not yet set."""
user = self.get_user_by_username(username)
if not user:
return None
if user.password_hash != "":
return None # Password already set
password_hash = self._hash_password(new_password)
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"UPDATE users SET password_hash = ?, last_login = ? WHERE id = ?",
(password_hash, datetime.now(), user.id)
)
return self.get_user_by_id(user.id)
@staticmethod
def _hash_password(password: str) -> str:
"""Hash a password using SHA-256 with salt."""
salt = secrets.token_hex(16)
hash_input = f"{salt}:{password}".encode()
password_hash = hashlib.sha256(hash_input).hexdigest()
return f"{salt}:{password_hash}"
@staticmethod
def _verify_password(password: str, stored_hash: str) -> bool:
"""Verify a password against its hash."""
try:
salt, hash_value = stored_hash.split(":")
hash_input = f"{salt}:{password}".encode()
computed_hash = hashlib.sha256(hash_input).hexdigest()
return secrets.compare_digest(computed_hash, hash_value)
except ValueError:
return False
def create_user(
self,
username: str,
password: str,
email: Optional[str] = None,
role: UserRole = UserRole.USER,
invited_by: Optional[str] = None,
) -> Optional[User]:
"""Create a new user account."""
user_id = secrets.token_hex(16)
password_hash = self._hash_password(password)
try:
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"""
INSERT INTO users (id, username, email, password_hash, role, invited_by)
VALUES (?, ?, ?, ?, ?, ?)
""",
(user_id, username, email, password_hash, role.value, invited_by),
)
return self.get_user_by_id(user_id)
except sqlite3.IntegrityError:
return None # Username or email already exists
def get_user_by_id(self, user_id: str) -> Optional[User]:
"""Get user by ID."""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM users WHERE id = ?",
(user_id,)
)
row = cursor.fetchone()
if row:
return self._row_to_user(row)
return None
def get_user_by_username(self, username: str) -> Optional[User]:
"""Get user by username."""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM users WHERE username = ?",
(username,)
)
row = cursor.fetchone()
if row:
return self._row_to_user(row)
return None
def _row_to_user(self, row: sqlite3.Row) -> User:
"""Convert database row to User object."""
return User(
id=row["id"],
username=row["username"],
email=row["email"],
password_hash=row["password_hash"],
role=UserRole(row["role"]),
created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else None,
last_login=datetime.fromisoformat(row["last_login"]) if row["last_login"] else None,
is_active=bool(row["is_active"]),
invited_by=row["invited_by"],
)
def authenticate(self, username: str, password: str) -> Optional[User]:
"""Authenticate user with username and password."""
user = self.get_user_by_username(username)
if not user:
return None
if not user.is_active:
return None
if not self._verify_password(password, user.password_hash):
return None
# Update last login
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"UPDATE users SET last_login = ? WHERE id = ?",
(datetime.now(), user.id)
)
return user
def create_session(self, user: User, duration_hours: int = 24) -> Session:
"""Create a new session for a user."""
token = secrets.token_urlsafe(32)
created_at = datetime.now()
expires_at = created_at + timedelta(hours=duration_hours)
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"""
INSERT INTO sessions (token, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)
""",
(token, user.id, created_at, expires_at)
)
return Session(
token=token,
user_id=user.id,
created_at=created_at,
expires_at=expires_at,
)
def get_session(self, token: str) -> Optional[Session]:
"""Get session by token."""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM sessions WHERE token = ?",
(token,)
)
row = cursor.fetchone()
if row:
session = Session(
token=row["token"],
user_id=row["user_id"],
created_at=datetime.fromisoformat(row["created_at"]),
expires_at=datetime.fromisoformat(row["expires_at"]),
)
if not session.is_expired():
return session
# Clean up expired session
self.invalidate_session(token)
return None
def get_user_from_session(self, token: str) -> Optional[User]:
"""Get user from session token."""
session = self.get_session(token)
if session:
return self.get_user_by_id(session.user_id)
return None
def invalidate_session(self, token: str):
"""Invalidate a session."""
with sqlite3.connect(self.db_path) as conn:
conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
def invalidate_user_sessions(self, user_id: str):
"""Invalidate all sessions for a user."""
with sqlite3.connect(self.db_path) as conn:
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
# =========================================================================
# Invite Codes
# =========================================================================
def create_invite_code(
self,
created_by: str,
max_uses: int = 1,
expires_in_days: Optional[int] = 7,
) -> InviteCode:
"""Create a new invite code."""
code = secrets.token_urlsafe(8).upper()[:8] # 8 character code
created_at = datetime.now()
expires_at = created_at + timedelta(days=expires_in_days) if expires_in_days else None
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"""
INSERT INTO invite_codes (code, created_by, created_at, expires_at, max_uses)
VALUES (?, ?, ?, ?, ?)
""",
(code, created_by, created_at, expires_at, max_uses)
)
return InviteCode(
code=code,
created_by=created_by,
created_at=created_at,
expires_at=expires_at,
max_uses=max_uses,
use_count=0,
is_active=True,
)
def get_invite_code(self, code: str) -> Optional[InviteCode]:
"""Get invite code by code string."""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM invite_codes WHERE code = ?",
(code.upper(),)
)
row = cursor.fetchone()
if row:
return InviteCode(
code=row["code"],
created_by=row["created_by"],
created_at=datetime.fromisoformat(row["created_at"]),
expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
max_uses=row["max_uses"],
use_count=row["use_count"],
is_active=bool(row["is_active"]),
)
return None
def use_invite_code(self, code: str) -> bool:
"""Mark an invite code as used. Returns False if invalid."""
invite = self.get_invite_code(code)
if not invite or not invite.is_valid():
return False
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"UPDATE invite_codes SET use_count = use_count + 1 WHERE code = ?",
(code.upper(),)
)
return True
def validate_room_code_as_invite(self, room_code: str) -> bool:
"""
Check if a room code is valid for registration.
Room codes from active games act as invite codes.
"""
# First check if it's an explicit invite code
invite = self.get_invite_code(room_code)
if invite and invite.is_valid():
return True
# Check if it's an active room code (from room manager)
# This will be checked by the caller since we don't have room_manager here
return False
# =========================================================================
# Admin Functions
# =========================================================================
def list_users(self, include_inactive: bool = False) -> list[User]:
"""List all users (admin function)."""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
if include_inactive:
cursor = conn.execute("SELECT * FROM users ORDER BY created_at DESC")
else:
cursor = conn.execute(
"SELECT * FROM users WHERE is_active = 1 ORDER BY created_at DESC"
)
return [self._row_to_user(row) for row in cursor.fetchall()]
def update_user(
self,
user_id: str,
username: Optional[str] = None,
email: Optional[str] = None,
role: Optional[UserRole] = None,
is_active: Optional[bool] = None,
) -> Optional[User]:
"""Update user details (admin function)."""
updates = []
params = []
if username is not None:
updates.append("username = ?")
params.append(username)
if email is not None:
updates.append("email = ?")
params.append(email)
if role is not None:
updates.append("role = ?")
params.append(role.value)
if is_active is not None:
updates.append("is_active = ?")
params.append(is_active)
if not updates:
return self.get_user_by_id(user_id)
params.append(user_id)
try:
with sqlite3.connect(self.db_path) as conn:
conn.execute(
f"UPDATE users SET {', '.join(updates)} WHERE id = ?",
params
)
return self.get_user_by_id(user_id)
except sqlite3.IntegrityError:
return None
def change_password(self, user_id: str, new_password: str) -> bool:
"""Change user password."""
password_hash = self._hash_password(new_password)
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(password_hash, user_id)
)
return cursor.rowcount > 0
def delete_user(self, user_id: str) -> bool:
"""Delete a user (admin function). Actually just deactivates."""
# Invalidate all sessions first
self.invalidate_user_sessions(user_id)
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute(
"UPDATE users SET is_active = 0 WHERE id = ?",
(user_id,)
)
return cursor.rowcount > 0
def list_invite_codes(self, created_by: Optional[str] = None) -> list[InviteCode]:
"""List invite codes (admin function)."""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
if created_by:
cursor = conn.execute(
"SELECT * FROM invite_codes WHERE created_by = ? ORDER BY created_at DESC",
(created_by,)
)
else:
cursor = conn.execute(
"SELECT * FROM invite_codes ORDER BY created_at DESC"
)
return [
InviteCode(
code=row["code"],
created_by=row["created_by"],
created_at=datetime.fromisoformat(row["created_at"]),
expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
max_uses=row["max_uses"],
use_count=row["use_count"],
is_active=bool(row["is_active"]),
)
for row in cursor.fetchall()
]
def deactivate_invite_code(self, code: str) -> bool:
"""Deactivate an invite code (admin function)."""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute(
"UPDATE invite_codes SET is_active = 0 WHERE code = ?",
(code.upper(),)
)
return cursor.rowcount > 0
def cleanup_expired_sessions(self):
"""Remove expired sessions from database."""
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"DELETE FROM sessions WHERE expires_at < ?",
(datetime.now(),)
)
# Global auth manager instance (lazy initialization)
_auth_manager: Optional[AuthManager] = None
def get_auth_manager() -> AuthManager:
"""Get or create the global auth manager instance."""
global _auth_manager
if _auth_manager is None:
_auth_manager = AuthManager()
return _auth_manager

231
server/config.py Normal file
View File

@@ -0,0 +1,231 @@
"""
Centralized configuration for Golf game server.
Configuration is loaded from (in order of precedence):
1. Environment variables
2. .env file (if exists)
3. Default values
Usage:
from config import config
print(config.PORT)
print(config.card_values)
"""
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
# Load .env file if it exists
try:
from dotenv import load_dotenv
# Check server/.env first, then project root .env
env_path = Path(__file__).parent / ".env"
if not env_path.exists():
env_path = Path(__file__).parent.parent / ".env"
if env_path.exists():
load_dotenv(env_path)
except ImportError:
pass # python-dotenv not installed, use env vars only
def get_env(key: str, default: str = "") -> str:
"""Get environment variable with default."""
return os.environ.get(key, default)
def get_env_bool(key: str, default: bool = False) -> bool:
"""Get boolean environment variable."""
val = os.environ.get(key, "").lower()
if val in ("true", "1", "yes", "on"):
return True
if val in ("false", "0", "no", "off"):
return False
return default
def get_env_int(key: str, default: int = 0) -> int:
"""Get integer environment variable."""
try:
return int(os.environ.get(key, str(default)))
except ValueError:
return default
@dataclass
class CardValues:
"""Card point values - the single source of truth."""
ACE: int = 1
TWO: int = -2
THREE: int = 3
FOUR: int = 4
FIVE: int = 5
SIX: int = 6
SEVEN: int = 7
EIGHT: int = 8
NINE: int = 9
TEN: int = 10
JACK: int = 10
QUEEN: int = 10
KING: int = 0
JOKER: int = -2
# House rule modifiers
SUPER_KINGS: int = -2 # King value when super_kings enabled
TEN_PENNY: int = 1 # 10 value when ten_penny enabled
LUCKY_SWING_JOKER: int = -5 # Joker value when lucky_swing enabled
def to_dict(self) -> dict[str, int]:
"""Get card values as dictionary for game use."""
return {
'A': self.ACE,
'2': self.TWO,
'3': self.THREE,
'4': self.FOUR,
'5': self.FIVE,
'6': self.SIX,
'7': self.SEVEN,
'8': self.EIGHT,
'9': self.NINE,
'10': self.TEN,
'J': self.JACK,
'Q': self.QUEEN,
'K': self.KING,
'': self.JOKER,
}
@dataclass
class GameDefaults:
"""Default game settings."""
rounds: int = 9
initial_flips: int = 2
use_jokers: bool = False
flip_mode: str = "never" # "never", "always", or "endgame"
@dataclass
class ServerConfig:
"""Server configuration."""
HOST: str = "0.0.0.0"
PORT: int = 8000
DEBUG: bool = False
LOG_LEVEL: str = "INFO"
# Environment (development, staging, production)
ENVIRONMENT: str = "development"
# Database (SQLite for legacy analytics/auth)
DATABASE_URL: str = "sqlite:///games.db"
# PostgreSQL for V2 event store
# Format: postgresql://user:password@host:port/database
POSTGRES_URL: str = ""
# Redis for V2 live state cache and pub/sub
# Format: redis://host:port or redis://:password@host:port
REDIS_URL: str = ""
# Email settings (Resend integration)
RESEND_API_KEY: str = ""
EMAIL_FROM: str = "Golf Game <noreply@example.com>"
BASE_URL: str = "http://localhost:8000"
# Session settings
SESSION_EXPIRY_HOURS: int = 168 # 1 week
# Email verification
REQUIRE_EMAIL_VERIFICATION: bool = False
# Room settings
MAX_PLAYERS_PER_ROOM: int = 6
ROOM_TIMEOUT_MINUTES: int = 60
ROOM_CODE_LENGTH: int = 4
# Security (for future auth system)
SECRET_KEY: str = ""
INVITE_ONLY: bool = True
# Bootstrap admin (for first-time setup when INVITE_ONLY=true)
BOOTSTRAP_ADMIN_USERNAME: str = ""
BOOTSTRAP_ADMIN_PASSWORD: str = ""
ADMIN_EMAILS: list[str] = field(default_factory=list)
# Matchmaking
MATCHMAKING_ENABLED: bool = True
MATCHMAKING_MIN_PLAYERS: int = 2
MATCHMAKING_MAX_PLAYERS: int = 4
# Rate limiting
RATE_LIMIT_ENABLED: bool = True
# Error tracking (Sentry)
SENTRY_DSN: str = ""
# Card values
card_values: CardValues = field(default_factory=CardValues)
# Game defaults
game_defaults: GameDefaults = field(default_factory=GameDefaults)
@classmethod
def from_env(cls) -> "ServerConfig":
"""Load configuration from environment variables."""
admin_emails_str = get_env("ADMIN_EMAILS", "")
admin_emails = [e.strip() for e in admin_emails_str.split(",") if e.strip()]
return cls(
HOST=get_env("HOST", "0.0.0.0"),
PORT=get_env_int("PORT", 8000),
DEBUG=get_env_bool("DEBUG", False),
LOG_LEVEL=get_env("LOG_LEVEL", "INFO"),
ENVIRONMENT=get_env("ENVIRONMENT", "development"),
DATABASE_URL=get_env("DATABASE_URL", "sqlite:///games.db"),
POSTGRES_URL=get_env("POSTGRES_URL", ""),
REDIS_URL=get_env("REDIS_URL", ""),
RESEND_API_KEY=get_env("RESEND_API_KEY", ""),
EMAIL_FROM=get_env("EMAIL_FROM", "Golf Game <noreply@example.com>"),
BASE_URL=get_env("BASE_URL", "http://localhost:8000"),
SESSION_EXPIRY_HOURS=get_env_int("SESSION_EXPIRY_HOURS", 168),
REQUIRE_EMAIL_VERIFICATION=get_env_bool("REQUIRE_EMAIL_VERIFICATION", False),
MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6),
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
SECRET_KEY=get_env("SECRET_KEY", ""),
INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
MATCHMAKING_MIN_PLAYERS=get_env_int("MATCHMAKING_MIN_PLAYERS", 2),
MATCHMAKING_MAX_PLAYERS=get_env_int("MATCHMAKING_MAX_PLAYERS", 4),
ADMIN_EMAILS=admin_emails,
RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True),
SENTRY_DSN=get_env("SENTRY_DSN", ""),
card_values=CardValues(
ACE=get_env_int("CARD_ACE", 1),
TWO=get_env_int("CARD_TWO", -2),
KING=get_env_int("CARD_KING", 0),
JOKER=get_env_int("CARD_JOKER", -2),
SUPER_KINGS=get_env_int("CARD_SUPER_KINGS", -2),
TEN_PENNY=get_env_int("CARD_TEN_PENNY", 1),
LUCKY_SWING_JOKER=get_env_int("CARD_LUCKY_SWING_JOKER", -5),
),
game_defaults=GameDefaults(
rounds=get_env_int("DEFAULT_ROUNDS", 9),
initial_flips=get_env_int("DEFAULT_INITIAL_FLIPS", 2),
use_jokers=get_env_bool("DEFAULT_USE_JOKERS", False),
flip_mode=get_env("DEFAULT_FLIP_MODE", "never"),
),
)
# Global config instance - loaded once at module import
config = ServerConfig.from_env()
def reload_config() -> ServerConfig:
"""Reload configuration from environment (useful for testing)."""
global config
config = ServerConfig.from_env()
return config

View File

@@ -1,30 +1,102 @@
# Card values - Single source of truth for all card scoring
# Per RULES.md: A=1, 2=-2, 3-10=face, J/Q=10, K=0, Joker=-2
DEFAULT_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, # Joker (standard)
}
"""
Card value constants for 6-Card Golf.
# House rule modifications (per RULES.md House Rules section)
SUPER_KINGS_VALUE = -2 # K worth -2 instead of 0
LUCKY_SEVENS_VALUE = 0 # 7 worth 0 instead of 7
TEN_PENNY_VALUE = 1 # 10 worth 1 instead of 10
LUCKY_SWING_JOKER_VALUE = -5 # Joker worth -5 in Lucky Swing mode
This module is the single source of truth for all card point values.
House rule modifications are defined here and applied in game.py.
Configuration can be customized via environment variables.
See config.py and .env.example for details.
Standard Golf Scoring:
- Ace: 1 point
- Two: -2 points (special - only negative non-joker)
- 3-9: Face value
- 10, Jack, Queen: 10 points
- King: 0 points
- Joker: -2 points (when enabled)
"""
from typing import Optional
# Try to load from config (which reads env vars), fall back to hardcoded defaults
try:
from config import config
_use_config = True
except ImportError:
_use_config = False
def get_card_value_for_rank(rank_str: str, options: dict | None = None) -> int:
# =============================================================================
# Card Values - Single Source of Truth
# =============================================================================
if _use_config:
# Load from environment-aware config
DEFAULT_CARD_VALUES: dict[str, int] = config.card_values.to_dict()
SUPER_KINGS_VALUE: int = config.card_values.SUPER_KINGS
TEN_PENNY_VALUE: int = config.card_values.TEN_PENNY
LUCKY_SWING_JOKER_VALUE: int = config.card_values.LUCKY_SWING_JOKER
else:
# Hardcoded defaults (fallback)
DEFAULT_CARD_VALUES: dict[str, int] = {
'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, # Joker (standard mode)
}
SUPER_KINGS_VALUE: int = -2 # Kings worth -2 instead of 0
TEN_PENNY_VALUE: int = 1 # 10s worth 1 instead of 10
LUCKY_SWING_JOKER_VALUE: int = -5 # Single joker worth -5
# =============================================================================
# Bonus/Penalty Constants
# =============================================================================
WOLFPACK_BONUS: int = -20 # All 4 Jacks (2 pairs) bonus (was -5, buffed)
FOUR_OF_A_KIND_BONUS: int = -20 # Four equal cards in two columns bonus
# =============================================================================
# Game Constants
# =============================================================================
if _use_config:
MAX_PLAYERS = config.MAX_PLAYERS_PER_ROOM
ROOM_CODE_LENGTH = config.ROOM_CODE_LENGTH
ROOM_TIMEOUT_MINUTES = config.ROOM_TIMEOUT_MINUTES
DEFAULT_ROUNDS = config.game_defaults.rounds
DEFAULT_INITIAL_FLIPS = config.game_defaults.initial_flips
DEFAULT_USE_JOKERS = config.game_defaults.use_jokers
DEFAULT_FLIP_MODE = config.game_defaults.flip_mode
else:
MAX_PLAYERS = 6
ROOM_CODE_LENGTH = 4
ROOM_TIMEOUT_MINUTES = 60
DEFAULT_ROUNDS = 9
DEFAULT_INITIAL_FLIPS = 2
DEFAULT_USE_JOKERS = False
DEFAULT_FLIP_MODE = "never"
# =============================================================================
# Helper Functions
# =============================================================================
def get_card_value_for_rank(
rank_str: str,
options: Optional[dict] = None,
) -> int:
"""
Get point value for a card rank string, with house rules applied.
@@ -45,8 +117,6 @@ def get_card_value_for_rank(rank_str: str, options: dict | None = None) -> int:
value = LUCKY_SWING_JOKER_VALUE
elif rank_str == 'K' and options.get('super_kings'):
value = SUPER_KINGS_VALUE
elif rank_str == '7' and options.get('lucky_sevens'):
value = LUCKY_SEVENS_VALUE
elif rank_str == '10' and options.get('ten_penny'):
value = TEN_PENNY_VALUE

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,20 @@ Game Analyzer for 6-Card Golf AI decisions.
Evaluates AI decisions against optimal play baselines and generates
reports on decision quality, mistake rates, and areas for improvement.
NOTE: This analyzer has been updated to use PostgreSQL. It requires
POSTGRES_URL to be configured. For quick analysis during simulations,
use the SimulationStats class in simulate.py instead.
Usage:
python game_analyzer.py blunders [limit]
python game_analyzer.py recent [limit]
"""
import asyncio
import json
import sqlite3
import os
import sqlite3 # For legacy GameAnalyzer class (deprecated)
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@@ -339,7 +349,12 @@ class DecisionEvaluator:
# =============================================================================
class GameAnalyzer:
"""Analyzes logged games for decision quality."""
"""Analyzes logged games for decision quality.
DEPRECATED: This class uses SQLite which has been replaced by PostgreSQL.
Use the CLI commands (blunders, recent) instead, or query the moves table
in PostgreSQL directly.
"""
def __init__(self, db_path: str = "games.db"):
self.db_path = Path(db_path)
@@ -579,59 +594,76 @@ def print_blunder_report(blunders: list[dict]):
# =============================================================================
# CLI Interface
# CLI Interface (PostgreSQL version)
# =============================================================================
if __name__ == "__main__":
async def run_cli():
"""Async CLI entry point."""
import sys
if len(sys.argv) < 2:
print("Usage:")
print(" python game_analyzer.py blunders [limit]")
print(" python game_analyzer.py game <game_id> <player_name>")
print(" python game_analyzer.py summary")
print(" python game_analyzer.py recent [limit]")
print("")
print("Requires POSTGRES_URL environment variable.")
sys.exit(1)
postgres_url = os.environ.get("POSTGRES_URL")
if not postgres_url:
print("Error: POSTGRES_URL environment variable not set.")
print("")
print("Set it like: export POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf")
print("")
print("For simulation analysis without PostgreSQL, use:")
print(" python simulate.py 100 --preset baseline")
sys.exit(1)
from stores.event_store import EventStore
try:
event_store = await EventStore.create(postgres_url)
except Exception as e:
print(f"Error connecting to PostgreSQL: {e}")
sys.exit(1)
command = sys.argv[1]
try:
analyzer = GameAnalyzer()
except FileNotFoundError:
print("No games.db found. Play some games first!")
sys.exit(1)
if command == "blunders":
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 20
blunders = await event_store.find_suspicious_discards(limit)
if command == "blunders":
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 20
blunders = analyzer.find_blunders(limit)
print_blunder_report(blunders)
print(f"\n=== Suspicious Discards ({len(blunders)} found) ===\n")
for b in blunders:
print(f"Player: {b.get('player_name', 'Unknown')}")
print(f"Action: discard {b.get('card_rank', '?')}")
print(f"Room: {b.get('room_code', 'N/A')}")
print(f"Reason: {b.get('decision_reason', 'N/A')}")
print("-" * 40)
elif command == "game" and len(sys.argv) >= 4:
game_id = sys.argv[2]
player_name = sys.argv[3]
summary = analyzer.analyze_player_game(game_id, player_name)
print(generate_player_report(summary))
elif command == "summary":
# Quick summary of recent games
with sqlite3.connect("games.db") as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute("""
SELECT g.id, g.room_code, g.started_at, g.num_players,
COUNT(m.id) as move_count
FROM games g
LEFT JOIN moves m ON g.id = m.game_id
GROUP BY g.id
ORDER BY g.started_at DESC
LIMIT 10
""")
elif command == "recent":
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 10
games = await event_store.get_recent_games_with_stats(limit)
print("\n=== Recent Games ===\n")
for row in cursor:
print(f"Game: {row['id'][:8]}... Room: {row['room_code']}")
print(f" Players: {row['num_players']}, Moves: {row['move_count']}")
print(f" Started: {row['started_at']}")
print()
for game in games:
game_id = str(game.get('id', ''))[:8]
room_code = game.get('room_code', 'N/A')
status = game.get('status', 'unknown')
moves = game.get('total_moves', 0)
print(f"{game_id}... | Room: {room_code} | Status: {status} | Moves: {moves}")
else:
print(f"Unknown command: {command}")
sys.exit(1)
else:
print(f"Unknown command: {command}")
print("Available: blunders, recent")
finally:
await event_store.close()
if __name__ == "__main__":
# Note: The detailed analysis (GameAnalyzer class) still uses the old SQLite
# schema format. For now, use the CLI commands above for PostgreSQL queries.
# Full migration of the analysis logic is TODO.
asyncio.run(run_cli())

View File

@@ -1,242 +0,0 @@
"""SQLite game logging for AI decision analysis."""
import json
import sqlite3
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from dataclasses import asdict
from game import Card, Player, Game, GameOptions
class GameLogger:
"""Logs game state and AI decisions to SQLite for post-game analysis."""
def __init__(self, db_path: str = "games.db"):
self.db_path = Path(db_path)
self._init_db()
def _init_db(self):
"""Initialize database schema."""
with sqlite3.connect(self.db_path) as conn:
conn.executescript("""
-- Games table
CREATE TABLE IF NOT EXISTS games (
id TEXT PRIMARY KEY,
room_code TEXT,
started_at TIMESTAMP,
ended_at TIMESTAMP,
num_players INTEGER,
options_json TEXT
);
-- Moves table (one per AI decision)
CREATE TABLE IF NOT EXISTS moves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id TEXT REFERENCES games(id),
move_number INTEGER,
timestamp TIMESTAMP,
player_id TEXT,
player_name TEXT,
is_cpu BOOLEAN,
-- Decision context
action TEXT,
-- Cards involved
card_rank TEXT,
card_suit TEXT,
position INTEGER,
-- Full state snapshot
hand_json TEXT,
discard_top_json TEXT,
visible_opponents_json TEXT,
-- AI reasoning
decision_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_moves_game_id ON moves(game_id);
CREATE INDEX IF NOT EXISTS idx_moves_action ON moves(action);
CREATE INDEX IF NOT EXISTS idx_moves_is_cpu ON moves(is_cpu);
""")
def log_game_start(
self, room_code: str, num_players: int, options: GameOptions
) -> str:
"""Log start of a new game. Returns game_id."""
game_id = str(uuid.uuid4())
options_dict = {
"flip_on_discard": options.flip_on_discard,
"initial_flips": options.initial_flips,
"knock_penalty": options.knock_penalty,
"use_jokers": options.use_jokers,
"lucky_swing": options.lucky_swing,
"super_kings": options.super_kings,
"lucky_sevens": options.lucky_sevens,
"ten_penny": options.ten_penny,
"knock_bonus": options.knock_bonus,
"underdog_bonus": options.underdog_bonus,
"tied_shame": options.tied_shame,
"blackjack": options.blackjack,
"queens_wild": options.queens_wild,
"four_of_a_kind": options.four_of_a_kind,
"eagle_eye": options.eagle_eye,
}
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"""
INSERT INTO games (id, room_code, started_at, num_players, options_json)
VALUES (?, ?, ?, ?, ?)
""",
(game_id, room_code, datetime.now(), num_players, json.dumps(options_dict)),
)
return game_id
def log_move(
self,
game_id: str,
player: Player,
is_cpu: bool,
action: str,
card: Optional[Card] = None,
position: Optional[int] = None,
game: Optional[Game] = None,
decision_reason: Optional[str] = None,
):
"""Log a single move/decision."""
# Get current move number
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute(
"SELECT COALESCE(MAX(move_number), 0) + 1 FROM moves WHERE game_id = ?",
(game_id,),
)
move_number = cursor.fetchone()[0]
# Serialize hand
hand_data = []
for c in player.cards:
hand_data.append({
"rank": c.rank.value,
"suit": c.suit.value,
"face_up": c.face_up,
})
# Serialize discard top
discard_top_data = None
if game:
discard_top = game.discard_top()
if discard_top:
discard_top_data = {
"rank": discard_top.rank.value,
"suit": discard_top.suit.value,
}
# Serialize visible opponent cards
visible_opponents = {}
if game:
for p in game.players:
if p.id != player.id:
visible = []
for c in p.cards:
if c.face_up:
visible.append({
"rank": c.rank.value,
"suit": c.suit.value,
})
visible_opponents[p.name] = visible
conn.execute(
"""
INSERT INTO moves (
game_id, move_number, timestamp, player_id, player_name, is_cpu,
action, card_rank, card_suit, position,
hand_json, discard_top_json, visible_opponents_json, decision_reason
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
game_id,
move_number,
datetime.now(),
player.id,
player.name,
is_cpu,
action,
card.rank.value if card else None,
card.suit.value if card else None,
position,
json.dumps(hand_data),
json.dumps(discard_top_data) if discard_top_data else None,
json.dumps(visible_opponents),
decision_reason,
),
)
def log_game_end(self, game_id: str):
"""Mark game as ended."""
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"UPDATE games SET ended_at = ? WHERE id = ?",
(datetime.now(), game_id),
)
# Query helpers for analysis
def find_suspicious_discards(db_path: str = "games.db") -> list[dict]:
"""Find cases where AI discarded good cards (Ace, 2, King)."""
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute("""
SELECT m.*, g.room_code
FROM moves m
JOIN games g ON m.game_id = g.id
WHERE m.action = 'discard'
AND m.card_rank IN ('A', '2', 'K')
AND m.is_cpu = 1
ORDER BY m.timestamp DESC
""")
return [dict(row) for row in cursor.fetchall()]
def get_player_decisions(db_path: str, game_id: str, player_name: str) -> list[dict]:
"""Get all decisions made by a specific player in a game."""
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute("""
SELECT * FROM moves
WHERE game_id = ? AND player_name = ?
ORDER BY move_number
""", (game_id, player_name))
return [dict(row) for row in cursor.fetchall()]
def get_recent_games(db_path: str = "games.db", limit: int = 10) -> list[dict]:
"""Get list of recent games."""
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute("""
SELECT g.*, COUNT(m.id) as total_moves
FROM games g
LEFT JOIN moves m ON g.id = m.game_id
GROUP BY g.id
ORDER BY g.started_at DESC
LIMIT ?
""", (limit,))
return [dict(row) for row in cursor.fetchall()]
# Global logger instance (lazy initialization)
_logger: Optional[GameLogger] = None
def get_logger() -> GameLogger:
"""Get or create the global game logger instance."""
global _logger
if _logger is None:
_logger = GameLogger()
return _logger

584
server/handlers.py Normal file
View File

@@ -0,0 +1,584 @@
"""WebSocket message handlers for the Golf card game.
Each handler corresponds to a single message type from the client.
Handlers are dispatched via the HANDLERS dict in main.py.
"""
import asyncio
import logging
import uuid
from dataclasses import dataclass
from typing import Optional
from fastapi import WebSocket
from config import config
from game import GamePhase, GameOptions
from ai import GolfAI, get_all_profiles
from room import Room
from services.game_logger import get_logger
logger = logging.getLogger(__name__)
@dataclass
class ConnectionContext:
"""State tracked per WebSocket connection."""
websocket: WebSocket
connection_id: str
player_id: str
auth_user_id: Optional[str]
authenticated_user: object # Optional[User]
current_room: Optional[Room] = None
def log_human_action(room: Room, player, action: str, card=None, position=None, reason: str = ""):
"""Log a human player's game action (shared helper for all handlers)."""
game_logger = get_logger()
if game_logger and room.game_log_id and player:
game_logger.log_move(
game_id=room.game_log_id,
player=player,
is_cpu=False,
action=action,
card=card,
position=position,
game=room.game,
decision_reason=reason,
)
# ---------------------------------------------------------------------------
# Lobby / Room handlers
# ---------------------------------------------------------------------------
async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
if config.INVITE_ONLY and not ctx.authenticated_user:
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
return
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
await ctx.websocket.send_json({
"type": "error",
"message": f"Maximum {max_concurrent} concurrent games allowed",
})
return
# Use authenticated username as player name
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
room = room_manager.create_room()
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
ctx.current_room = room
await ctx.websocket.send_json({
"type": "room_created",
"room_code": room.code,
"player_id": ctx.player_id,
"authenticated": ctx.authenticated_user is not None,
})
await room.broadcast({
"type": "player_joined",
"players": room.player_list(),
})
async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
if config.INVITE_ONLY and not ctx.authenticated_user:
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
return
room_code = data.get("room_code", "").upper()
# Use authenticated username as player name
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
await ctx.websocket.send_json({
"type": "error",
"message": f"Maximum {max_concurrent} concurrent games allowed",
})
return
room = room_manager.get_room(room_code)
if not room:
await ctx.websocket.send_json({"type": "error", "message": "Room not found"})
return
if len(room.players) >= 6:
await ctx.websocket.send_json({"type": "error", "message": "Room is full"})
return
if room.game.phase != GamePhase.WAITING:
await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
return
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
ctx.current_room = room
await ctx.websocket.send_json({
"type": "room_joined",
"room_code": room.code,
"player_id": ctx.player_id,
"authenticated": ctx.authenticated_user is not None,
})
await room.broadcast({
"type": "player_joined",
"players": room.player_list(),
})
async def handle_get_cpu_profiles(data: dict, ctx: ConnectionContext, **kw) -> None:
if not ctx.current_room:
return
await ctx.websocket.send_json({
"type": "cpu_profiles",
"profiles": get_all_profiles(),
})
async def handle_add_cpu(data: dict, ctx: ConnectionContext, **kw) -> None:
if not ctx.current_room:
return
room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host:
await ctx.websocket.send_json({"type": "error", "message": "Only the host can add CPU players"})
return
if len(ctx.current_room.players) >= 6:
await ctx.websocket.send_json({"type": "error", "message": "Room is full"})
return
cpu_id = f"cpu_{uuid.uuid4().hex[:8]}"
profile_name = data.get("profile_name")
cpu_player = ctx.current_room.add_cpu_player(cpu_id, profile_name)
if not cpu_player:
await ctx.websocket.send_json({"type": "error", "message": "CPU profile not available"})
return
await ctx.current_room.broadcast({
"type": "player_joined",
"players": ctx.current_room.player_list(),
})
async def handle_remove_cpu(data: dict, ctx: ConnectionContext, **kw) -> None:
if not ctx.current_room:
return
room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host:
return
cpu_players = ctx.current_room.get_cpu_players()
if cpu_players:
ctx.current_room.remove_player(cpu_players[-1].id)
await ctx.current_room.broadcast({
"type": "player_joined",
"players": ctx.current_room.player_list(),
})
# ---------------------------------------------------------------------------
# Game lifecycle handlers
# ---------------------------------------------------------------------------
async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host:
await ctx.websocket.send_json({"type": "error", "message": "Only the host can start the game"})
return
if len(ctx.current_room.players) < 2:
await ctx.websocket.send_json({"type": "error", "message": "Need at least 2 players"})
return
num_decks = max(1, min(3, data.get("decks", 1)))
num_rounds = max(1, min(18, data.get("rounds", 1)))
options = GameOptions.from_client_data(data)
async with ctx.current_room.game_lock:
ctx.current_room.game.start_game(num_decks, num_rounds, options)
game_logger = get_logger()
if game_logger:
ctx.current_room.game_log_id = game_logger.log_game_start(
room_code=ctx.current_room.code,
num_players=len(ctx.current_room.players),
options=options,
)
# CPU players do their initial flips immediately
if options.initial_flips > 0:
for cpu in ctx.current_room.get_cpu_players():
positions = GolfAI.choose_initial_flips(options.initial_flips)
ctx.current_room.game.flip_initial_cards(cpu.id, positions)
# Send game started to all human players
for pid, player in ctx.current_room.players.items():
if player.websocket and not player.is_cpu:
game_state = ctx.current_room.game.get_state(pid)
await player.websocket.send_json({
"type": "game_started",
"game_state": game_state,
})
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:
if not ctx.current_room:
return
positions = data.get("positions", [])
async with ctx.current_room.game_lock:
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
await broadcast_game_state(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
# ---------------------------------------------------------------------------
# Turn action handlers
# ---------------------------------------------------------------------------
async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
if not ctx.current_room:
return
source = data.get("source", "deck")
async with ctx.current_room.game_lock:
discard_before_draw = ctx.current_room.game.discard_top()
card = ctx.current_room.game.draw_card(ctx.player_id, source)
if card:
player = ctx.current_room.game.get_player(ctx.player_id)
reason = f"took {discard_before_draw.rank.value} from discard" if source == "discard" else "drew from deck"
log_human_action(
ctx.current_room, player,
"take_discard" if source == "discard" else "draw_deck",
card=card, reason=reason,
)
await ctx.websocket.send_json({
"type": "card_drawn",
"card": card.to_dict(),
"source": source,
})
await broadcast_game_state(ctx.current_room)
async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
position = data.get("position", 0)
async with ctx.current_room.game_lock:
drawn_card = ctx.current_room.game.drawn_card
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
discarded = ctx.current_room.game.swap_card(ctx.player_id, position)
if discarded:
if drawn_card and player:
old_rank = old_card.rank.value if old_card else "?"
log_human_action(
ctx.current_room, player, "swap",
card=drawn_card, position=position,
reason=f"swapped {drawn_card.rank.value} into position {position}, replaced {old_rank}",
)
await broadcast_game_state(ctx.current_room)
await asyncio.sleep(1.0)
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:
if not ctx.current_room:
return
async with ctx.current_room.game_lock:
drawn_card = ctx.current_room.game.drawn_card
player = ctx.current_room.game.get_player(ctx.player_id)
if ctx.current_room.game.discard_drawn(ctx.player_id):
if drawn_card and player:
log_human_action(
ctx.current_room, player, "discard",
card=drawn_card,
reason=f"discarded {drawn_card.rank.value}",
)
await broadcast_game_state(ctx.current_room)
if ctx.current_room.game.flip_on_discard:
player = ctx.current_room.game.get_player(ctx.player_id)
has_face_down = player and any(not c.face_up for c in player.cards)
if has_face_down:
await ctx.websocket.send_json({
"type": "can_flip",
"optional": ctx.current_room.game.flip_is_optional,
})
else:
await asyncio.sleep(0.5)
check_and_run_cpu_turn(ctx.current_room)
else:
logger.debug("Player discarded, waiting 0.5s before CPU turn")
await asyncio.sleep(0.5)
logger.debug("Post-discard delay complete, checking for CPU turn")
check_and_run_cpu_turn(ctx.current_room)
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
if not ctx.current_room:
return
async with ctx.current_room.game_lock:
if ctx.current_room.game.cancel_discard_draw(ctx.player_id):
await broadcast_game_state(ctx.current_room)
async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
position = data.get("position", 0)
async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id)
ctx.current_room.game.flip_and_end_turn(ctx.player_id, position)
if player and 0 <= position < len(player.cards):
flipped_card = player.cards[position]
log_human_action(
ctx.current_room, player, "flip",
card=flipped_card, position=position,
reason=f"flipped card at position {position}",
)
await broadcast_game_state(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:
if not ctx.current_room:
return
async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id)
if ctx.current_room.game.skip_flip_and_end_turn(ctx.player_id):
log_human_action(
ctx.current_room, player, "skip_flip",
reason="skipped optional flip (endgame mode)",
)
await broadcast_game_state(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:
if not ctx.current_room:
return
position = data.get("position", 0)
async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id)
if ctx.current_room.game.flip_card_as_action(ctx.player_id, position):
if player and 0 <= position < len(player.cards):
flipped_card = player.cards[position]
log_human_action(
ctx.current_room, player, "flip_as_action",
card=flipped_card, position=position,
reason=f"used flip-as-action to reveal position {position}",
)
await broadcast_game_state(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:
if not ctx.current_room:
return
async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id)
if ctx.current_room.game.knock_early(ctx.player_id):
if player:
face_down_count = sum(1 for c in player.cards if not c.face_up)
log_human_action(
ctx.current_room, player, "knock_early",
reason=f"knocked early, revealing {face_down_count} hidden cards",
)
await broadcast_game_state(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:
if not ctx.current_room:
return
room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host:
return
async with ctx.current_room.game_lock:
if ctx.current_room.game.start_next_round():
for cpu in ctx.current_room.get_cpu_players():
positions = GolfAI.choose_initial_flips()
ctx.current_room.game.flip_initial_cards(cpu.id, positions)
for pid, player in ctx.current_room.players.items():
if player.websocket and not player.is_cpu:
game_state = ctx.current_room.game.get_state(pid)
await player.websocket.send_json({
"type": "round_started",
"game_state": game_state,
})
check_and_run_cpu_turn(ctx.current_room)
else:
await broadcast_game_state(ctx.current_room)
# ---------------------------------------------------------------------------
# Leave / End handlers
# ---------------------------------------------------------------------------
async def handle_leave_room(data: dict, ctx: ConnectionContext, *, handle_player_leave, **kw) -> None:
if ctx.current_room:
await handle_player_leave(ctx.current_room, ctx.player_id)
ctx.current_room = None
async def handle_leave_game(data: dict, ctx: ConnectionContext, *, handle_player_leave, **kw) -> None:
if ctx.current_room:
await handle_player_leave(ctx.current_room, ctx.player_id)
ctx.current_room = None
async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None:
if not ctx.current_room:
return
room_player = ctx.current_room.get_player(ctx.player_id)
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"})
return
# Cancel any running CPU turn task so the game ends immediately
if ctx.current_room.cpu_turn_task:
ctx.current_room.cpu_turn_task.cancel()
try:
await ctx.current_room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
ctx.current_room.cpu_turn_task = None
await ctx.current_room.broadcast({
"type": "game_ended",
"reason": "Host ended the game",
})
room_code = ctx.current_room.code
for cpu in list(ctx.current_room.get_cpu_players()):
ctx.current_room.remove_player(cpu.id)
cleanup_room_profiles(room_code)
room_manager.remove_room(room_code)
ctx.current_room = None
# ---------------------------------------------------------------------------
# Handler dispatch table
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Matchmaking handlers
# ---------------------------------------------------------------------------
async def handle_queue_join(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, rating_service=None, **kw) -> None:
if not matchmaking_service:
await ctx.websocket.send_json({"type": "error", "message": "Matchmaking not available"})
return
if not ctx.authenticated_user:
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to find a game"})
return
# Get player's rating
rating = 1500.0
if rating_service:
try:
player_rating = await rating_service.get_rating(ctx.auth_user_id)
rating = player_rating.rating
except Exception:
pass
status = await matchmaking_service.join_queue(
user_id=ctx.auth_user_id,
username=ctx.authenticated_user.username,
rating=rating,
websocket=ctx.websocket,
connection_id=ctx.connection_id,
)
await ctx.websocket.send_json({
"type": "queue_joined",
**status,
})
async def handle_queue_leave(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
if not matchmaking_service or not ctx.auth_user_id:
return
removed = await matchmaking_service.leave_queue(ctx.auth_user_id)
await ctx.websocket.send_json({
"type": "queue_left",
"was_queued": removed,
})
async def handle_queue_status(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
if not matchmaking_service or not ctx.auth_user_id:
await ctx.websocket.send_json({"type": "queue_status", "in_queue": False})
return
status = await matchmaking_service.get_queue_status(ctx.auth_user_id)
await ctx.websocket.send_json({
"type": "queue_status",
**status,
})
HANDLERS = {
"create_room": handle_create_room,
"join_room": handle_join_room,
"get_cpu_profiles": handle_get_cpu_profiles,
"add_cpu": handle_add_cpu,
"remove_cpu": handle_remove_cpu,
"start_game": handle_start_game,
"flip_initial": handle_flip_initial,
"draw": handle_draw,
"swap": handle_swap,
"discard": handle_discard,
"cancel_draw": handle_cancel_draw,
"flip_card": handle_flip_card,
"skip_flip": handle_skip_flip,
"flip_as_action": handle_flip_as_action,
"knock_early": handle_knock_early,
"next_round": handle_next_round,
"leave_room": handle_leave_room,
"leave_game": handle_leave_game,
"end_game": handle_end_game,
"queue_join": handle_queue_join,
"queue_leave": handle_queue_leave,
"queue_status": handle_queue_status,
}

291
server/logging_config.py Normal file
View File

@@ -0,0 +1,291 @@
"""
Structured logging configuration for Golf game server.
Provides:
- JSONFormatter for production (machine-readable logs)
- Human-readable formatter for development
- Contextual logging (request_id, user_id, game_id)
"""
import json
import logging
import os
import sys
from contextvars import ContextVar
from datetime import datetime, timezone
from typing import Optional
# Context variables for request-scoped data
request_id_var: ContextVar[Optional[str]] = ContextVar("request_id", default=None)
user_id_var: ContextVar[Optional[str]] = ContextVar("user_id", default=None)
game_id_var: ContextVar[Optional[str]] = ContextVar("game_id", default=None)
class JSONFormatter(logging.Formatter):
"""
Format logs as JSON for production log aggregation.
Output format is compatible with common log aggregation systems
(ELK, CloudWatch, Datadog, etc.).
"""
def format(self, record: logging.LogRecord) -> str:
"""
Format log record as JSON.
Args:
record: Log record to format.
Returns:
JSON-formatted log string.
"""
log_data = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
# Add context from context variables
request_id = request_id_var.get()
if request_id:
log_data["request_id"] = request_id
user_id = user_id_var.get()
if user_id:
log_data["user_id"] = user_id
game_id = game_id_var.get()
if game_id:
log_data["game_id"] = game_id
# Add extra fields from record
if hasattr(record, "request_id") and record.request_id:
log_data["request_id"] = record.request_id
if hasattr(record, "user_id") and record.user_id:
log_data["user_id"] = record.user_id
if hasattr(record, "game_id") and record.game_id:
log_data["game_id"] = record.game_id
if hasattr(record, "room_code") and record.room_code:
log_data["room_code"] = record.room_code
if hasattr(record, "player_id") and record.player_id:
log_data["player_id"] = record.player_id
# Add source location for errors
if record.levelno >= logging.ERROR:
log_data["source"] = {
"file": record.pathname,
"line": record.lineno,
"function": record.funcName,
}
# Add exception info
if record.exc_info:
log_data["exception"] = self.formatException(record.exc_info)
return json.dumps(log_data, default=str)
class DevelopmentFormatter(logging.Formatter):
"""
Human-readable formatter for development.
Includes colors and context for easy debugging.
"""
COLORS = {
"DEBUG": "\033[36m", # Cyan
"INFO": "\033[32m", # Green
"WARNING": "\033[33m", # Yellow
"ERROR": "\033[31m", # Red
"CRITICAL": "\033[35m", # Magenta
}
RESET = "\033[0m"
def format(self, record: logging.LogRecord) -> str:
"""
Format log record with colors and context.
Args:
record: Log record to format.
Returns:
Formatted log string.
"""
# Get color for level
color = self.COLORS.get(record.levelname, "")
reset = self.RESET if color else ""
# Build timestamp
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
# Build context string
context_parts = []
request_id = request_id_var.get() or getattr(record, "request_id", None)
if request_id:
context_parts.append(f"req={request_id[:8]}")
user_id = user_id_var.get() or getattr(record, "user_id", None)
if user_id:
context_parts.append(f"user={user_id[:8]}")
room_code = getattr(record, "room_code", None)
if room_code:
context_parts.append(f"room={room_code}")
context = f" [{', '.join(context_parts)}]" if context_parts else ""
# Format message
message = record.getMessage()
# Build final output
output = f"{timestamp} {color}{record.levelname:8}{reset} {record.name}{context} - {message}"
# Add exception if present
if record.exc_info:
output += "\n" + self.formatException(record.exc_info)
return output
# Per-module log level overrides via env vars.
# Key: env var suffix, Value: list of Python logger names to apply to.
MODULE_LOGGER_MAP = {
"GAME": ["game"],
"AI": ["ai"],
"HANDLERS": ["handlers"],
"ROOM": ["room"],
"AUTH": ["auth", "routers.auth", "services.auth_service"],
"STORES": ["stores"],
}
def _apply_module_overrides() -> dict[str, str]:
"""
Apply per-module log level overrides from LOG_LEVEL_{MODULE} env vars.
Returns:
Dict of module name -> level for any overrides that were applied.
"""
active = {}
for module, logger_names in MODULE_LOGGER_MAP.items():
env_val = os.environ.get(f"LOG_LEVEL_{module}", "").upper()
if not env_val:
continue
level = getattr(logging, env_val, None)
if level is None:
continue
active[module] = env_val
for name in logger_names:
logging.getLogger(name).setLevel(level)
return active
def setup_logging(
level: str = "INFO",
environment: str = "development",
) -> None:
"""
Configure application logging.
Args:
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
environment: Environment name (production uses JSON, else human-readable).
"""
# Get log level
log_level = getattr(logging, level.upper(), logging.INFO)
# Create handler
handler = logging.StreamHandler(sys.stdout)
# Choose formatter based on environment
if environment == "production":
handler.setFormatter(JSONFormatter())
else:
handler.setFormatter(DevelopmentFormatter())
# Configure root logger
root_logger = logging.getLogger()
root_logger.handlers = [handler]
root_logger.setLevel(log_level)
# Reduce noise from libraries
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
logging.getLogger("websockets").setLevel(logging.WARNING)
logging.getLogger("asyncio").setLevel(logging.WARNING)
# Apply per-module overrides from env vars
overrides = _apply_module_overrides()
# Log startup
logger = logging.getLogger(__name__)
logger.info(
f"Logging configured: level={level}, environment={environment}",
extra={"level": level, "environment": environment},
)
if overrides:
logger.info(
f"Per-module log level overrides: {', '.join(f'{m}={l}' for m, l in overrides.items())}",
)
class ContextLogger(logging.LoggerAdapter):
"""
Logger adapter that automatically includes context.
Usage:
logger = ContextLogger(logging.getLogger(__name__))
logger.with_context(room_code="ABCD", player_id="123").info("Player joined")
"""
def __init__(self, logger: logging.Logger, extra: Optional[dict] = None):
"""
Initialize context logger.
Args:
logger: Base logger instance.
extra: Extra context to include in all messages.
"""
super().__init__(logger, extra or {})
def with_context(self, **kwargs) -> "ContextLogger":
"""
Create a new logger with additional context.
Args:
**kwargs: Context key-value pairs to add.
Returns:
New ContextLogger with combined context.
"""
new_extra = {**self.extra, **kwargs}
return ContextLogger(self.logger, new_extra)
def process(self, msg: str, kwargs: dict) -> tuple[str, dict]:
"""
Process log message to include context.
Args:
msg: Log message.
kwargs: Keyword arguments.
Returns:
Processed message and kwargs.
"""
# Merge extra into kwargs
kwargs["extra"] = {**self.extra, **kwargs.get("extra", {})}
return msg, kwargs
def get_logger(name: str) -> ContextLogger:
"""
Get a context-aware logger.
Args:
name: Logger name (typically __name__).
Returns:
ContextLogger instance.
"""
return ContextLogger(logging.getLogger(name))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
"""
Middleware components for Golf game server.
Provides:
- RateLimitMiddleware: API rate limiting with Redis backend
- SecurityHeadersMiddleware: Security headers (CSP, HSTS, etc.)
- RequestIDMiddleware: Request tracing with X-Request-ID
"""
from .ratelimit import RateLimitMiddleware
from .security import SecurityHeadersMiddleware
from .request_id import RequestIDMiddleware
__all__ = [
"RateLimitMiddleware",
"SecurityHeadersMiddleware",
"RequestIDMiddleware",
]

View File

@@ -0,0 +1,173 @@
"""
Rate limiting middleware for FastAPI.
Applies per-endpoint rate limits and adds X-RateLimit-* headers to responses.
"""
import logging
from typing import Callable, Optional
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse, Response
from services.ratelimit import RateLimiter, RATE_LIMITS
logger = logging.getLogger(__name__)
class RateLimitMiddleware(BaseHTTPMiddleware):
"""
HTTP middleware for rate limiting API requests.
Applies rate limits based on request path and adds standard
rate limit headers to all responses.
"""
def __init__(
self,
app,
rate_limiter: RateLimiter,
enabled: bool = True,
get_user_id: Optional[Callable[[Request], Optional[str]]] = None,
):
"""
Initialize rate limit middleware.
Args:
app: FastAPI application.
rate_limiter: RateLimiter service instance.
enabled: Whether rate limiting is enabled.
get_user_id: Optional callback to extract user ID from request.
"""
super().__init__(app)
self.limiter = rate_limiter
self.enabled = enabled
self.get_user_id = get_user_id
async def dispatch(self, request: Request, call_next) -> Response:
"""
Process request through rate limiter.
Args:
request: Incoming HTTP request.
call_next: Next middleware/handler in chain.
Returns:
HTTP response with rate limit headers.
"""
# Skip if disabled
if not self.enabled:
return await call_next(request)
# Determine rate limit tier based on path
path = request.url.path
limit_config = self._get_limit_config(path, request.method)
# No rate limiting for this endpoint
if limit_config is None:
return await call_next(request)
limit, window = limit_config
# Get user ID if authenticated
user_id = None
if self.get_user_id:
try:
user_id = self.get_user_id(request)
except Exception:
pass
# Generate client key
client_key = self.limiter.get_client_key(request, user_id)
# Check rate limit
endpoint_key = self._get_endpoint_key(path)
full_key = f"{endpoint_key}:{client_key}"
allowed, info = await self.limiter.is_allowed(full_key, limit, window)
# Build response
if allowed:
response = await call_next(request)
else:
response = JSONResponse(
status_code=429,
content={
"error": "Rate limit exceeded",
"message": f"Too many requests. Please wait {info['reset']} seconds.",
"retry_after": info["reset"],
},
)
# Add rate limit headers
response.headers["X-RateLimit-Limit"] = str(info["limit"])
response.headers["X-RateLimit-Remaining"] = str(info["remaining"])
response.headers["X-RateLimit-Reset"] = str(info["reset"])
if not allowed:
response.headers["Retry-After"] = str(info["reset"])
return response
def _get_limit_config(
self,
path: str,
method: str,
) -> Optional[tuple[int, int]]:
"""
Get rate limit configuration for a path.
Args:
path: Request URL path.
method: HTTP method.
Returns:
Tuple of (limit, window_seconds) or None for no limiting.
"""
# No rate limiting for health checks
if path in ("/health", "/ready", "/metrics"):
return None
# No rate limiting for static files
if path.endswith((".js", ".css", ".html", ".ico", ".png", ".jpg")):
return None
# Authentication endpoints - stricter limits
if path.startswith("/api/auth"):
return RATE_LIMITS["api_auth"]
# Room creation - moderate limits
if path == "/api/rooms" and method == "POST":
return RATE_LIMITS["api_create_room"]
# Email endpoints - very strict
if "email" in path or "verify" in path:
return RATE_LIMITS["email_send"]
# General API endpoints
if path.startswith("/api"):
return RATE_LIMITS["api_general"]
# Default: no rate limiting for non-API paths
return None
def _get_endpoint_key(self, path: str) -> str:
"""
Normalize path to endpoint key for rate limiting.
Groups similar endpoints together (e.g., /api/users/123 -> /api/users/:id).
Args:
path: Request URL path.
Returns:
Normalized endpoint key.
"""
# Simple normalization - strip trailing slashes
key = path.rstrip("/")
# Could add more sophisticated path parameter normalization here
# For example: /api/users/123 -> /api/users/:id
return key or "/"

View File

@@ -0,0 +1,93 @@
"""
Request ID middleware for request tracing.
Generates or propagates X-Request-ID header for distributed tracing.
"""
import logging
import uuid
from typing import Optional
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from logging_config import request_id_var
logger = logging.getLogger(__name__)
class RequestIDMiddleware(BaseHTTPMiddleware):
"""
HTTP middleware for request ID generation and propagation.
- Extracts X-Request-ID from incoming request headers
- Generates a new UUID if not present
- Sets request_id in context var for logging
- Adds X-Request-ID to response headers
"""
def __init__(
self,
app,
header_name: str = "X-Request-ID",
generator: Optional[callable] = None,
):
"""
Initialize request ID middleware.
Args:
app: FastAPI application.
header_name: Header name for request ID.
generator: Optional custom ID generator function.
"""
super().__init__(app)
self.header_name = header_name
self.generator = generator or (lambda: str(uuid.uuid4()))
async def dispatch(self, request: Request, call_next) -> Response:
"""
Process request with request ID.
Args:
request: Incoming HTTP request.
call_next: Next middleware/handler in chain.
Returns:
HTTP response with X-Request-ID header.
"""
# Get or generate request ID
request_id = request.headers.get(self.header_name)
if not request_id:
request_id = self.generator()
# Set in request state for access in handlers
request.state.request_id = request_id
# Set in context var for logging
token = request_id_var.set(request_id)
try:
# Process request
response = await call_next(request)
# Add request ID to response
response.headers[self.header_name] = request_id
return response
finally:
# Reset context var
request_id_var.reset(token)
def get_request_id(request: Request) -> Optional[str]:
"""
Get request ID from request state.
Args:
request: FastAPI request object.
Returns:
Request ID string or None.
"""
return getattr(request.state, "request_id", None)

View File

@@ -0,0 +1,142 @@
"""
Security headers middleware for FastAPI.
Adds security headers to all responses:
- Content-Security-Policy (CSP)
- X-Content-Type-Options
- X-Frame-Options
- X-XSS-Protection
- Referrer-Policy
- Permissions-Policy
- Strict-Transport-Security (HSTS)
"""
import logging
from typing import Optional
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
logger = logging.getLogger(__name__)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""
HTTP middleware for adding security headers.
Configurable CSP and HSTS settings for different environments.
"""
def __init__(
self,
app,
environment: str = "development",
csp_report_uri: Optional[str] = None,
allowed_hosts: Optional[list[str]] = None,
):
"""
Initialize security headers middleware.
Args:
app: FastAPI application.
environment: Environment name (production enables HSTS).
csp_report_uri: Optional URI for CSP violation reports.
allowed_hosts: List of allowed hosts for connect-src directive.
"""
super().__init__(app)
self.environment = environment
self.csp_report_uri = csp_report_uri
self.allowed_hosts = allowed_hosts or []
async def dispatch(self, request: Request, call_next) -> Response:
"""
Add security headers to response.
Args:
request: Incoming HTTP request.
call_next: Next middleware/handler in chain.
Returns:
HTTP response with security headers.
"""
response = await call_next(request)
# Basic security headers
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Permissions Policy (formerly Feature-Policy)
response.headers["Permissions-Policy"] = (
"geolocation=(), "
"microphone=(), "
"camera=(), "
"payment=(), "
"usb=()"
)
# Content Security Policy
csp = self._build_csp(request)
response.headers["Content-Security-Policy"] = csp
# HSTS (only in production with HTTPS)
if self.environment == "production":
# Only add HSTS if request came via HTTPS
forwarded_proto = request.headers.get("X-Forwarded-Proto", "")
if forwarded_proto == "https" or request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains; preload"
)
return response
def _build_csp(self, request: Request) -> str:
"""
Build Content-Security-Policy header.
Args:
request: HTTP request (for host-specific directives).
Returns:
CSP header value string.
"""
# Get the host for WebSocket connections
host = request.headers.get("host", "localhost")
# Build connect-src directive
connect_sources = ["'self'"]
# Add WebSocket URLs
if self.environment == "production":
connect_sources.append(f"ws://{host}")
connect_sources.append(f"wss://{host}")
for allowed_host in self.allowed_hosts:
connect_sources.append(f"ws://{allowed_host}")
connect_sources.append(f"wss://{allowed_host}")
else:
# Development - allow ws:// and wss://
connect_sources.append(f"ws://{host}")
connect_sources.append(f"wss://{host}")
connect_sources.append("ws://localhost:*")
connect_sources.append("wss://localhost:*")
directives = [
"default-src 'self'",
"script-src 'self'",
# Allow inline styles for UI (cards, animations)
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
f"connect-src {' '.join(connect_sources)}",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
]
# Add report-uri if configured
if self.csp_report_uri:
directives.append(f"report-uri {self.csp_report_uri}")
return "; ".join(directives)

19
server/models/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
"""Models package for Golf game V2."""
from .events import EventType, GameEvent
from .game_state import RebuiltGameState, rebuild_state, CardState, PlayerState, GamePhase
from .user import UserRole, User, UserSession, GuestSession
__all__ = [
"EventType",
"GameEvent",
"RebuiltGameState",
"rebuild_state",
"CardState",
"PlayerState",
"GamePhase",
"UserRole",
"User",
"UserSession",
"GuestSession",
]

574
server/models/events.py Normal file
View File

@@ -0,0 +1,574 @@
"""
Event definitions for Golf game event sourcing.
All game actions are stored as immutable events, enabling:
- Full game replay from any point
- Audit trails for all player actions
- Stats aggregation from event streams
- Deterministic state reconstruction
Events are the single source of truth for game state.
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Optional, Any
import json
class EventType(str, Enum):
"""All possible event types in a Golf game."""
# Lifecycle events
GAME_CREATED = "game_created"
PLAYER_JOINED = "player_joined"
PLAYER_LEFT = "player_left"
GAME_STARTED = "game_started"
ROUND_STARTED = "round_started"
ROUND_ENDED = "round_ended"
GAME_ENDED = "game_ended"
# Gameplay events
INITIAL_FLIP = "initial_flip"
CARD_DRAWN = "card_drawn"
CARD_SWAPPED = "card_swapped"
CARD_DISCARDED = "card_discarded"
CARD_FLIPPED = "card_flipped"
FLIP_SKIPPED = "flip_skipped"
FLIP_AS_ACTION = "flip_as_action"
KNOCK_EARLY = "knock_early"
@dataclass
class GameEvent:
"""
Base class for all game events.
Events are immutable records of actions that occurred in a game.
They contain all information needed to reconstruct game state.
Attributes:
event_type: The type of event (from EventType enum).
game_id: UUID of the game this event belongs to.
sequence_num: Monotonically increasing sequence number within game.
timestamp: When the event occurred (UTC).
player_id: ID of player who triggered the event (if applicable).
data: Event-specific payload data.
"""
event_type: EventType
game_id: str
sequence_num: int
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
player_id: Optional[str] = None
data: dict = field(default_factory=dict)
def to_dict(self) -> dict:
"""Serialize event to dictionary for JSON storage."""
return {
"event_type": self.event_type.value,
"game_id": self.game_id,
"sequence_num": self.sequence_num,
"timestamp": self.timestamp.isoformat(),
"player_id": self.player_id,
"data": self.data,
}
def to_json(self) -> str:
"""Serialize event to JSON string."""
return json.dumps(self.to_dict())
@classmethod
def from_dict(cls, d: dict) -> "GameEvent":
"""Deserialize event from dictionary."""
timestamp = d["timestamp"]
if isinstance(timestamp, str):
timestamp = datetime.fromisoformat(timestamp)
return cls(
event_type=EventType(d["event_type"]),
game_id=d["game_id"],
sequence_num=d["sequence_num"],
timestamp=timestamp,
player_id=d.get("player_id"),
data=d.get("data", {}),
)
@classmethod
def from_json(cls, json_str: str) -> "GameEvent":
"""Deserialize event from JSON string."""
return cls.from_dict(json.loads(json_str))
# =============================================================================
# Event Factory Functions
# =============================================================================
# These provide type-safe event construction with proper data structures.
def game_created(
game_id: str,
sequence_num: int,
room_code: str,
host_id: str,
options: dict,
) -> GameEvent:
"""
Create a GameCreated event.
Emitted when a new game room is created.
Args:
game_id: UUID for the new game.
sequence_num: Should be 1 (first event).
room_code: 4-letter room code.
host_id: Player ID of the host.
options: GameOptions as dict.
"""
return GameEvent(
event_type=EventType.GAME_CREATED,
game_id=game_id,
sequence_num=sequence_num,
player_id=host_id,
data={
"room_code": room_code,
"host_id": host_id,
"options": options,
},
)
def player_joined(
game_id: str,
sequence_num: int,
player_id: str,
player_name: str,
is_cpu: bool = False,
cpu_profile: Optional[str] = None,
) -> GameEvent:
"""
Create a PlayerJoined event.
Emitted when a player (human or CPU) joins the game.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Unique player identifier.
player_name: Display name.
is_cpu: Whether this is a CPU player.
cpu_profile: CPU profile name (for AI replay analysis).
"""
return GameEvent(
event_type=EventType.PLAYER_JOINED,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"player_name": player_name,
"is_cpu": is_cpu,
"cpu_profile": cpu_profile,
},
)
def player_left(
game_id: str,
sequence_num: int,
player_id: str,
reason: str = "left",
) -> GameEvent:
"""
Create a PlayerLeft event.
Emitted when a player leaves the game.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: ID of player who left.
reason: Why they left (left, disconnected, kicked).
"""
return GameEvent(
event_type=EventType.PLAYER_LEFT,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={"reason": reason},
)
def game_started(
game_id: str,
sequence_num: int,
player_order: list[str],
num_decks: int,
num_rounds: int,
options: dict,
) -> GameEvent:
"""
Create a GameStarted event.
Emitted when the host starts the game. This locks in settings
but doesn't deal cards (that's RoundStarted).
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_order: List of player IDs in turn order.
num_decks: Number of card decks being used.
num_rounds: Total rounds to play.
options: Final GameOptions as dict.
"""
return GameEvent(
event_type=EventType.GAME_STARTED,
game_id=game_id,
sequence_num=sequence_num,
data={
"player_order": player_order,
"num_decks": num_decks,
"num_rounds": num_rounds,
"options": options,
},
)
def round_started(
game_id: str,
sequence_num: int,
round_num: int,
deck_seed: int,
dealt_cards: dict[str, list[dict]],
first_discard: dict,
) -> GameEvent:
"""
Create a RoundStarted event.
Emitted at the start of each round. Contains all information
needed to recreate the initial state deterministically.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
round_num: Round number (1-indexed).
deck_seed: Random seed used for deck shuffle.
dealt_cards: Map of player_id -> list of 6 card dicts.
Cards include {rank, suit} (face_up always False).
first_discard: The first card on the discard pile.
"""
return GameEvent(
event_type=EventType.ROUND_STARTED,
game_id=game_id,
sequence_num=sequence_num,
data={
"round_num": round_num,
"deck_seed": deck_seed,
"dealt_cards": dealt_cards,
"first_discard": first_discard,
},
)
def round_ended(
game_id: str,
sequence_num: int,
round_num: int,
scores: dict[str, int],
final_hands: dict[str, list[dict]],
finisher_id: Optional[str] = None,
) -> GameEvent:
"""
Create a RoundEnded event.
Emitted when a round completes and scores are calculated.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
round_num: Round that just ended.
scores: Map of player_id -> round score.
final_hands: Map of player_id -> final 6 cards (all revealed).
finisher_id: ID of player who went out first (if any).
"""
return GameEvent(
event_type=EventType.ROUND_ENDED,
game_id=game_id,
sequence_num=sequence_num,
data={
"round_num": round_num,
"scores": scores,
"final_hands": final_hands,
"finisher_id": finisher_id,
},
)
def game_ended(
game_id: str,
sequence_num: int,
final_scores: dict[str, int],
rounds_won: dict[str, int],
winner_id: Optional[str] = None,
) -> GameEvent:
"""
Create a GameEnded event.
Emitted when all rounds are complete.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
final_scores: Map of player_id -> total score.
rounds_won: Map of player_id -> rounds won count.
winner_id: ID of overall winner (lowest total score).
"""
return GameEvent(
event_type=EventType.GAME_ENDED,
game_id=game_id,
sequence_num=sequence_num,
data={
"final_scores": final_scores,
"rounds_won": rounds_won,
"winner_id": winner_id,
},
)
def initial_flip(
game_id: str,
sequence_num: int,
player_id: str,
positions: list[int],
cards: list[dict],
) -> GameEvent:
"""
Create an InitialFlip event.
Emitted when a player flips their initial cards at round start.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who flipped.
positions: Card positions that were flipped (0-5).
cards: The cards that were revealed [{rank, suit}, ...].
"""
return GameEvent(
event_type=EventType.INITIAL_FLIP,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"positions": positions,
"cards": cards,
},
)
def card_drawn(
game_id: str,
sequence_num: int,
player_id: str,
source: str,
card: dict,
) -> GameEvent:
"""
Create a CardDrawn event.
Emitted when a player draws a card from deck or discard.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who drew.
source: "deck" or "discard".
card: The card drawn {rank, suit}.
"""
return GameEvent(
event_type=EventType.CARD_DRAWN,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"source": source,
"card": card,
},
)
def card_swapped(
game_id: str,
sequence_num: int,
player_id: str,
position: int,
new_card: dict,
old_card: dict,
) -> GameEvent:
"""
Create a CardSwapped event.
Emitted when a player swaps their drawn card with a hand card.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who swapped.
position: Hand position (0-5) where swap occurred.
new_card: Card placed into hand {rank, suit}.
old_card: Card removed from hand {rank, suit}.
"""
return GameEvent(
event_type=EventType.CARD_SWAPPED,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"position": position,
"new_card": new_card,
"old_card": old_card,
},
)
def card_discarded(
game_id: str,
sequence_num: int,
player_id: str,
card: dict,
) -> GameEvent:
"""
Create a CardDiscarded event.
Emitted when a player discards their drawn card without swapping.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who discarded.
card: The card discarded {rank, suit}.
"""
return GameEvent(
event_type=EventType.CARD_DISCARDED,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={"card": card},
)
def card_flipped(
game_id: str,
sequence_num: int,
player_id: str,
position: int,
card: dict,
) -> GameEvent:
"""
Create a CardFlipped event.
Emitted when a player flips a card after discarding (flip_on_discard mode).
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who flipped.
position: Position of flipped card (0-5).
card: The card revealed {rank, suit}.
"""
return GameEvent(
event_type=EventType.CARD_FLIPPED,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"position": position,
"card": card,
},
)
def flip_skipped(
game_id: str,
sequence_num: int,
player_id: str,
) -> GameEvent:
"""
Create a FlipSkipped event.
Emitted when a player skips the optional flip (endgame mode).
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who skipped.
"""
return GameEvent(
event_type=EventType.FLIP_SKIPPED,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={},
)
def flip_as_action(
game_id: str,
sequence_num: int,
player_id: str,
position: int,
card: dict,
) -> GameEvent:
"""
Create a FlipAsAction event.
Emitted when a player uses their turn to flip a card (house rule).
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who used flip-as-action.
position: Position of flipped card (0-5).
card: The card revealed {rank, suit}.
"""
return GameEvent(
event_type=EventType.FLIP_AS_ACTION,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"position": position,
"card": card,
},
)
def knock_early(
game_id: str,
sequence_num: int,
player_id: str,
positions: list[int],
cards: list[dict],
) -> GameEvent:
"""
Create a KnockEarly event.
Emitted when a player knocks early to reveal remaining cards.
Args:
game_id: Game UUID.
sequence_num: Event sequence number.
player_id: Player who knocked.
positions: Positions of cards that were face-down.
cards: The cards revealed [{rank, suit}, ...].
"""
return GameEvent(
event_type=EventType.KNOCK_EARLY,
game_id=game_id,
sequence_num=sequence_num,
player_id=player_id,
data={
"positions": positions,
"cards": cards,
},
)

561
server/models/game_state.py Normal file
View File

@@ -0,0 +1,561 @@
"""
Game state rebuilder for event sourcing.
This module provides the ability to reconstruct game state from an event stream.
The RebuiltGameState class mirrors the Game class structure but is built
entirely from events rather than direct mutation.
Usage:
events = await event_store.get_events(game_id)
state = rebuild_state(events)
print(state.phase, state.current_player_id)
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from models.events import GameEvent, EventType
class GamePhase(str, Enum):
"""Game phases matching game.py GamePhase."""
WAITING = "waiting"
INITIAL_FLIP = "initial_flip"
PLAYING = "playing"
FINAL_TURN = "final_turn"
ROUND_OVER = "round_over"
GAME_OVER = "game_over"
@dataclass
class CardState:
"""
A card's state during replay.
Attributes:
rank: Card rank (A, 2-10, J, Q, K, or Joker).
suit: Card suit (hearts, diamonds, clubs, spades).
face_up: Whether the card is visible.
"""
rank: str
suit: str
face_up: bool = False
def to_dict(self) -> dict:
"""Convert to dictionary for comparison."""
return {
"rank": self.rank,
"suit": self.suit,
"face_up": self.face_up,
}
@classmethod
def from_dict(cls, d: dict) -> "CardState":
"""
Create from dictionary.
Handles both full card data and minimal face-down data gracefully.
Args:
d: Dictionary with card data. May contain:
- Full data: {rank, suit, face_up}
- Minimal face-down: {face_up: False}
Returns:
CardState instance.
Raises:
ValueError: If face_up is True but rank/suit are missing.
"""
face_up = d.get("face_up", False)
rank = d.get("rank")
suit = d.get("suit")
# If card is face-up, we must have rank and suit
if face_up and (rank is None or suit is None):
raise ValueError("Face-up card must have rank and suit")
# For face-down cards with missing data, use placeholder values
# This handles improperly serialized data from older versions
if rank is None:
rank = "?" # Placeholder for unknown
if suit is None:
suit = "?" # Placeholder for unknown
return cls(rank=rank, suit=suit, face_up=face_up)
@dataclass
class PlayerState:
"""
A player's state during replay.
Attributes:
id: Unique player identifier.
name: Display name.
cards: The player's 6-card hand.
score: Current round score.
total_score: Cumulative score across rounds.
rounds_won: Number of rounds won.
is_cpu: Whether this is a CPU player.
cpu_profile: CPU profile name (for AI analysis).
"""
id: str
name: str
cards: list[CardState] = field(default_factory=list)
score: int = 0
total_score: int = 0
rounds_won: int = 0
is_cpu: bool = False
cpu_profile: Optional[str] = None
def all_face_up(self) -> bool:
"""Check if all cards are revealed."""
return all(card.face_up for card in self.cards)
@dataclass
class RebuiltGameState:
"""
Game state rebuilt from events.
This class reconstructs the full game state by applying events in sequence.
It mirrors the structure of the Game class from game.py but is immutable
and derived entirely from events.
Attributes:
game_id: UUID of the game.
room_code: 4-letter room code.
phase: Current game phase.
players: Map of player_id -> PlayerState.
player_order: List of player IDs in turn order.
current_player_idx: Index of current player in player_order.
deck_remaining: Cards left in deck (approximated).
discard_pile: Cards in discard pile (most recent at end).
drawn_card: Card currently held by active player.
current_round: Current round number (1-indexed).
total_rounds: Total rounds in game.
options: GameOptions as dict.
sequence_num: Last applied event sequence.
finisher_id: Player who went out first this round.
initial_flips_done: Set of player IDs who completed initial flips.
"""
game_id: str
room_code: str = ""
phase: GamePhase = GamePhase.WAITING
players: dict[str, PlayerState] = field(default_factory=dict)
player_order: list[str] = field(default_factory=list)
current_player_idx: int = 0
deck_remaining: int = 0
discard_pile: list[CardState] = field(default_factory=list)
drawn_card: Optional[CardState] = None
drawn_from_discard: bool = False
current_round: int = 0
total_rounds: int = 1
options: dict = field(default_factory=dict)
sequence_num: int = 0
finisher_id: Optional[str] = None
players_with_final_turn: set = field(default_factory=set)
initial_flips_done: set = field(default_factory=set)
host_id: Optional[str] = None
def apply(self, event: GameEvent) -> "RebuiltGameState":
"""
Apply an event to produce new state.
Events must be applied in sequence order.
Args:
event: The event to apply.
Returns:
self for chaining.
Raises:
ValueError: If event is out of sequence or unknown type.
"""
# Validate sequence (first event can be 1, then must be sequential)
expected_seq = self.sequence_num + 1 if self.sequence_num > 0 else 1
if event.sequence_num != expected_seq:
raise ValueError(
f"Expected sequence {expected_seq}, got {event.sequence_num}"
)
# Dispatch to handler
handler = getattr(self, f"_apply_{event.event_type.value}", None)
if handler is None:
raise ValueError(f"Unknown event type: {event.event_type}")
handler(event)
self.sequence_num = event.sequence_num
return self
# -------------------------------------------------------------------------
# Lifecycle Event Handlers
# -------------------------------------------------------------------------
def _apply_game_created(self, event: GameEvent) -> None:
"""Handle game_created event."""
self.room_code = event.data["room_code"]
self.host_id = event.data["host_id"]
self.options = event.data.get("options", {})
def _apply_player_joined(self, event: GameEvent) -> None:
"""Handle player_joined event."""
player_id = event.player_id
self.players[player_id] = PlayerState(
id=player_id,
name=event.data["player_name"],
is_cpu=event.data.get("is_cpu", False),
cpu_profile=event.data.get("cpu_profile"),
)
def _apply_player_left(self, event: GameEvent) -> None:
"""Handle player_left event."""
player_id = event.player_id
if player_id in self.players:
del self.players[player_id]
if player_id in self.player_order:
self.player_order.remove(player_id)
# Adjust current player index if needed
if self.current_player_idx >= len(self.player_order):
self.current_player_idx = 0
def _apply_game_started(self, event: GameEvent) -> None:
"""Handle game_started event."""
self.player_order = event.data["player_order"]
self.total_rounds = event.data["num_rounds"]
self.options = event.data.get("options", self.options)
# Note: round_started will set up the actual round
def _apply_round_started(self, event: GameEvent) -> None:
"""Handle round_started event."""
self.current_round = event.data["round_num"]
self.finisher_id = None
self.players_with_final_turn = set()
self.initial_flips_done = set()
self.drawn_card = None
self.drawn_from_discard = False
self.current_player_idx = event.data.get("current_player_idx", 0)
self.discard_pile = []
# Deal cards to players (all face-down)
dealt_cards = event.data["dealt_cards"]
for player_id, cards_data in dealt_cards.items():
if player_id in self.players:
self.players[player_id].cards = [
CardState.from_dict(c) for c in cards_data
]
# Reset round score
self.players[player_id].score = 0
# Start discard pile
first_discard = event.data.get("first_discard")
if first_discard:
card = CardState.from_dict(first_discard)
card.face_up = True
self.discard_pile.append(card)
# Set phase based on initial_flips setting
initial_flips = self.options.get("initial_flips", 2)
if initial_flips == 0:
self.phase = GamePhase.PLAYING
else:
self.phase = GamePhase.INITIAL_FLIP
# Approximate deck size (we don't track exact cards)
num_decks = self.options.get("num_decks", 1)
cards_per_deck = 52
if self.options.get("use_jokers"):
if self.options.get("lucky_swing"):
cards_per_deck += 1 # Single joker
else:
cards_per_deck += 2 # Two jokers
total_cards = num_decks * cards_per_deck
dealt_count = len(self.players) * 6 + 1 # 6 per player + 1 discard
self.deck_remaining = total_cards - dealt_count
def _apply_round_ended(self, event: GameEvent) -> None:
"""Handle round_ended event."""
self.phase = GamePhase.ROUND_OVER
scores = event.data["scores"]
# Update player scores
for player_id, score in scores.items():
if player_id in self.players:
self.players[player_id].score = score
self.players[player_id].total_score += score
# Determine round winner (lowest score)
if scores:
min_score = min(scores.values())
for player_id, score in scores.items():
if score == min_score and player_id in self.players:
self.players[player_id].rounds_won += 1
# Apply final hands if provided
final_hands = event.data.get("final_hands", {})
for player_id, cards_data in final_hands.items():
if player_id in self.players:
self.players[player_id].cards = [
CardState.from_dict(c) for c in cards_data
]
# Ensure all cards are face up
for card in self.players[player_id].cards:
card.face_up = True
def _apply_game_ended(self, event: GameEvent) -> None:
"""Handle game_ended event."""
self.phase = GamePhase.GAME_OVER
# Final scores are already tracked in players
# -------------------------------------------------------------------------
# Gameplay Event Handlers
# -------------------------------------------------------------------------
def _apply_initial_flip(self, event: GameEvent) -> None:
"""Handle initial_flip event."""
player_id = event.player_id
player = self.players.get(player_id)
if not player:
return
positions = event.data["positions"]
cards = event.data["cards"]
for pos, card_data in zip(positions, cards):
if 0 <= pos < len(player.cards):
player.cards[pos] = CardState.from_dict(card_data)
player.cards[pos].face_up = True
self.initial_flips_done.add(player_id)
# Check if all players have flipped
if len(self.initial_flips_done) == len(self.players):
self.phase = GamePhase.PLAYING
def _apply_card_drawn(self, event: GameEvent) -> None:
"""Handle card_drawn event."""
card = CardState.from_dict(event.data["card"])
card.face_up = True
self.drawn_card = card
self.drawn_from_discard = event.data["source"] == "discard"
if self.drawn_from_discard and self.discard_pile:
self.discard_pile.pop()
else:
self.deck_remaining = max(0, self.deck_remaining - 1)
def _apply_card_swapped(self, event: GameEvent) -> None:
"""Handle card_swapped event."""
player_id = event.player_id
player = self.players.get(player_id)
if not player:
return
position = event.data["position"]
new_card = CardState.from_dict(event.data["new_card"])
old_card = CardState.from_dict(event.data["old_card"])
# Place new card in hand
new_card.face_up = True
if 0 <= position < len(player.cards):
player.cards[position] = new_card
# Add old card to discard
old_card.face_up = True
self.discard_pile.append(old_card)
# Clear drawn card
self.drawn_card = None
self.drawn_from_discard = False
# Advance turn
self._end_turn(player)
def _apply_card_discarded(self, event: GameEvent) -> None:
"""Handle card_discarded event."""
player_id = event.player_id
player = self.players.get(player_id)
if self.drawn_card:
self.drawn_card.face_up = True
self.discard_pile.append(self.drawn_card)
self.drawn_card = None
self.drawn_from_discard = False
# Check if flip_on_discard mode requires a flip
# If not, end turn now
flip_mode = self.options.get("flip_mode", "never")
if flip_mode == "never":
if player:
self._end_turn(player)
# For "always" or "endgame", wait for flip_card or flip_skipped event
def _apply_card_flipped(self, event: GameEvent) -> None:
"""Handle card_flipped event (after discard in flip mode)."""
player_id = event.player_id
player = self.players.get(player_id)
if not player:
return
position = event.data["position"]
card = CardState.from_dict(event.data["card"])
card.face_up = True
if 0 <= position < len(player.cards):
player.cards[position] = card
self._end_turn(player)
def _apply_flip_skipped(self, event: GameEvent) -> None:
"""Handle flip_skipped event (endgame mode optional flip)."""
player_id = event.player_id
player = self.players.get(player_id)
if player:
self._end_turn(player)
def _apply_flip_as_action(self, event: GameEvent) -> None:
"""Handle flip_as_action event (house rule)."""
player_id = event.player_id
player = self.players.get(player_id)
if not player:
return
position = event.data["position"]
card = CardState.from_dict(event.data["card"])
card.face_up = True
if 0 <= position < len(player.cards):
player.cards[position] = card
self._end_turn(player)
def _apply_knock_early(self, event: GameEvent) -> None:
"""Handle knock_early event (house rule)."""
player_id = event.player_id
player = self.players.get(player_id)
if not player:
return
positions = event.data["positions"]
cards = event.data["cards"]
for pos, card_data in zip(positions, cards):
if 0 <= pos < len(player.cards):
card = CardState.from_dict(card_data)
card.face_up = True
player.cards[pos] = card
self._end_turn(player)
# -------------------------------------------------------------------------
# Turn Management
# -------------------------------------------------------------------------
def _end_turn(self, player: PlayerState) -> None:
"""
Handle end of player's turn.
Checks for going out and advances to next player.
"""
# Check if player went out
if player.all_face_up() and self.finisher_id is None:
self.finisher_id = player.id
self.phase = GamePhase.FINAL_TURN
self.players_with_final_turn.add(player.id)
elif self.phase == GamePhase.FINAL_TURN:
# In final turn, reveal all cards after turn ends
for card in player.cards:
card.face_up = True
self.players_with_final_turn.add(player.id)
# Advance to next player
self._next_turn()
def _next_turn(self) -> None:
"""Advance to the next player's turn."""
if not self.player_order:
return
if self.phase == GamePhase.FINAL_TURN:
# Check if all players have had their final turn
all_done = all(
pid in self.players_with_final_turn
for pid in self.player_order
)
if all_done:
# Round will end (round_ended event will set phase)
return
# Move to next player
self.current_player_idx = (self.current_player_idx + 1) % len(self.player_order)
# -------------------------------------------------------------------------
# Query Methods
# -------------------------------------------------------------------------
@property
def current_player_id(self) -> Optional[str]:
"""Get the current player's ID."""
if self.player_order and 0 <= self.current_player_idx < len(self.player_order):
return self.player_order[self.current_player_idx]
return None
@property
def current_player(self) -> Optional[PlayerState]:
"""Get the current player's state."""
player_id = self.current_player_id
return self.players.get(player_id) if player_id else None
def discard_top(self) -> Optional[CardState]:
"""Get the top card of the discard pile."""
return self.discard_pile[-1] if self.discard_pile else None
def get_player(self, player_id: str) -> Optional[PlayerState]:
"""Get a player's state by ID."""
return self.players.get(player_id)
def rebuild_state(events: list[GameEvent]) -> RebuiltGameState:
"""
Rebuild game state from a list of events.
Args:
events: List of events in sequence order.
Returns:
Reconstructed game state.
Raises:
ValueError: If events list is empty or has invalid sequence.
"""
if not events:
raise ValueError("Cannot rebuild state from empty event list")
state = RebuiltGameState(game_id=events[0].game_id)
for event in events:
state.apply(event)
return state
async def rebuild_state_from_store(
event_store,
game_id: str,
to_sequence: Optional[int] = None,
) -> RebuiltGameState:
"""
Rebuild game state by loading events from the store.
Args:
event_store: EventStore instance.
game_id: Game UUID.
to_sequence: Optional sequence to rebuild up to.
Returns:
Reconstructed game state.
"""
events = await event_store.get_events(game_id, to_sequence=to_sequence)
return rebuild_state(events)

287
server/models/user.py Normal file
View File

@@ -0,0 +1,287 @@
"""
User-related models for Golf game authentication.
Defines user accounts, sessions, and guest tracking for the V2 auth system.
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Optional, Any
import json
class UserRole(str, Enum):
"""User role levels."""
GUEST = "guest"
USER = "user"
ADMIN = "admin"
@dataclass
class User:
"""
A registered user account.
Attributes:
id: UUID primary key.
username: Unique display name.
email: Optional email address.
password_hash: bcrypt hash of password.
role: User role (guest, user, admin).
email_verified: Whether email has been verified.
verification_token: Token for email verification.
verification_expires: When verification token expires.
reset_token: Token for password reset.
reset_expires: When reset token expires.
guest_id: Guest session ID if converted from guest.
deleted_at: Soft delete timestamp.
preferences: User preferences as JSON.
created_at: When account was created.
last_login: Last login timestamp.
last_seen_at: Last activity timestamp.
is_active: Whether account is active.
is_banned: Whether user is banned.
ban_reason: Reason for ban (if banned).
force_password_reset: Whether user must reset password on next login.
"""
id: str
username: str
password_hash: str
email: Optional[str] = None
role: UserRole = UserRole.USER
email_verified: bool = False
verification_token: Optional[str] = None
verification_expires: Optional[datetime] = None
reset_token: Optional[str] = None
reset_expires: Optional[datetime] = None
guest_id: Optional[str] = None
deleted_at: Optional[datetime] = None
preferences: dict = field(default_factory=dict)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
last_login: Optional[datetime] = None
last_seen_at: Optional[datetime] = None
is_active: bool = True
is_banned: bool = False
ban_reason: Optional[str] = None
force_password_reset: bool = False
def is_admin(self) -> bool:
"""Check if user has admin role."""
return self.role == UserRole.ADMIN
def is_guest(self) -> bool:
"""Check if user has guest role."""
return self.role == UserRole.GUEST
def can_login(self) -> bool:
"""Check if user can log in."""
return self.is_active and self.deleted_at is None and not self.is_banned
def to_dict(self, include_sensitive: bool = False) -> dict:
"""
Serialize user to dictionary.
Args:
include_sensitive: Include password hash and tokens.
"""
d = {
"id": self.id,
"username": self.username,
"email": self.email,
"role": self.role.value,
"email_verified": self.email_verified,
"preferences": self.preferences,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_login": self.last_login.isoformat() if self.last_login else None,
"last_seen_at": self.last_seen_at.isoformat() if self.last_seen_at else None,
"is_active": self.is_active,
"is_banned": self.is_banned,
"ban_reason": self.ban_reason,
"force_password_reset": self.force_password_reset,
}
if include_sensitive:
d["password_hash"] = self.password_hash
d["verification_token"] = self.verification_token
d["verification_expires"] = (
self.verification_expires.isoformat() if self.verification_expires else None
)
d["reset_token"] = self.reset_token
d["reset_expires"] = (
self.reset_expires.isoformat() if self.reset_expires else None
)
d["guest_id"] = self.guest_id
d["deleted_at"] = self.deleted_at.isoformat() if self.deleted_at else None
return d
@classmethod
def from_dict(cls, d: dict) -> "User":
"""Deserialize user from dictionary."""
def parse_dt(val: Any) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val
return datetime.fromisoformat(val)
return cls(
id=d["id"],
username=d["username"],
password_hash=d.get("password_hash", ""),
email=d.get("email"),
role=UserRole(d.get("role", "user")),
email_verified=d.get("email_verified", False),
verification_token=d.get("verification_token"),
verification_expires=parse_dt(d.get("verification_expires")),
reset_token=d.get("reset_token"),
reset_expires=parse_dt(d.get("reset_expires")),
guest_id=d.get("guest_id"),
deleted_at=parse_dt(d.get("deleted_at")),
preferences=d.get("preferences", {}),
created_at=parse_dt(d.get("created_at")) or datetime.now(timezone.utc),
last_login=parse_dt(d.get("last_login")),
last_seen_at=parse_dt(d.get("last_seen_at")),
is_active=d.get("is_active", True),
is_banned=d.get("is_banned", False),
ban_reason=d.get("ban_reason"),
force_password_reset=d.get("force_password_reset", False),
)
@dataclass
class UserSession:
"""
An active user session.
Session tokens are hashed before storage for security.
Attributes:
id: UUID primary key.
user_id: Reference to user.
token_hash: SHA256 hash of session token.
device_info: Device/browser information.
ip_address: Client IP address.
created_at: When session was created.
expires_at: When session expires.
last_used_at: Last activity timestamp.
revoked_at: When session was revoked (if any).
"""
id: str
user_id: str
token_hash: str
device_info: dict = field(default_factory=dict)
ip_address: Optional[str] = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
last_used_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
revoked_at: Optional[datetime] = None
def is_valid(self) -> bool:
"""Check if session is still valid."""
now = datetime.now(timezone.utc)
return (
self.revoked_at is None
and self.expires_at > now
)
def to_dict(self) -> dict:
"""Serialize session to dictionary."""
return {
"id": self.id,
"user_id": self.user_id,
"token_hash": self.token_hash,
"device_info": self.device_info,
"ip_address": self.ip_address,
"created_at": self.created_at.isoformat() if self.created_at else None,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
"revoked_at": self.revoked_at.isoformat() if self.revoked_at else None,
}
@classmethod
def from_dict(cls, d: dict) -> "UserSession":
"""Deserialize session from dictionary."""
def parse_dt(val: Any) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val
return datetime.fromisoformat(val)
return cls(
id=d["id"],
user_id=d["user_id"],
token_hash=d["token_hash"],
device_info=d.get("device_info", {}),
ip_address=d.get("ip_address"),
created_at=parse_dt(d.get("created_at")) or datetime.now(timezone.utc),
expires_at=parse_dt(d.get("expires_at")) or datetime.now(timezone.utc),
last_used_at=parse_dt(d.get("last_used_at")) or datetime.now(timezone.utc),
revoked_at=parse_dt(d.get("revoked_at")),
)
@dataclass
class GuestSession:
"""
A guest session for tracking anonymous users.
Guests can play games without registering. Their session
can later be converted to a full user account.
Attributes:
id: Guest session ID (stored in client).
display_name: Display name for the guest.
created_at: When session was created.
last_seen_at: Last activity timestamp.
games_played: Number of games played as guest.
converted_to_user_id: User ID if converted to account.
expires_at: When guest session expires.
"""
id: str
display_name: Optional[str] = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
last_seen_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
games_played: int = 0
converted_to_user_id: Optional[str] = None
expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def is_converted(self) -> bool:
"""Check if guest has been converted to user."""
return self.converted_to_user_id is not None
def is_expired(self) -> bool:
"""Check if guest session has expired."""
return datetime.now(timezone.utc) > self.expires_at
def to_dict(self) -> dict:
"""Serialize guest session to dictionary."""
return {
"id": self.id,
"display_name": self.display_name,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_seen_at": self.last_seen_at.isoformat() if self.last_seen_at else None,
"games_played": self.games_played,
"converted_to_user_id": self.converted_to_user_id,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
}
@classmethod
def from_dict(cls, d: dict) -> "GuestSession":
"""Deserialize guest session from dictionary."""
def parse_dt(val: Any) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val
return datetime.fromisoformat(val)
return cls(
id=d["id"],
display_name=d.get("display_name"),
created_at=parse_dt(d.get("created_at")) or datetime.now(timezone.utc),
last_seen_at=parse_dt(d.get("last_seen_at")) or datetime.now(timezone.utc),
games_played=d.get("games_played", 0),
converted_to_user_id=d.get("converted_to_user_id"),
expires_at=parse_dt(d.get("expires_at")) or datetime.now(timezone.utc),
)

View File

@@ -1,3 +1,25 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
websockets==12.0
# Core dependencies
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
websockets>=12.0
python-dotenv>=1.0.0
# Database & caching
asyncpg>=0.29.0
redis>=5.0.0
# Authentication
bcrypt>=4.1.0
# Email service
resend>=2.0.0
# Production monitoring (optional)
sentry-sdk[fastapi]>=1.40.0
# Testing
pytest>=8.0.0
pytest-asyncio>=0.23.0
pytest-cov>=4.1.0
ruff>=0.1.0
mypy>=1.8.0

View File

@@ -1,57 +1,134 @@
"""Room management for multiplayer games."""
"""
Room management for multiplayer Golf games.
This module handles room creation, player management, and WebSocket
communication for multiplayer game sessions.
A Room contains:
- A unique 4-letter code for joining
- A collection of RoomPlayers (human or CPU)
- A Game instance with the actual game state
- Settings for number of decks, rounds, etc.
"""
import asyncio
import random
import string
from dataclasses import dataclass, field
from typing import Optional
from fastapi import WebSocket
from ai import assign_profile, assign_specific_profile, get_profile, release_profile, cleanup_room_profiles
from game import Game, Player
from ai import assign_profile, release_profile, get_profile, assign_specific_profile
@dataclass
class RoomPlayer:
"""
A player in a game room (lobby-level representation).
This is separate from game.Player - RoomPlayer tracks room-level info
like WebSocket connections and host status, while game.Player tracks
in-game state like cards and scores.
Attributes:
id: Unique player identifier (connection_id for multi-tab support).
name: Display name.
websocket: WebSocket connection (None for CPU players).
is_host: Whether this player controls game settings.
is_cpu: Whether this is an AI-controlled player.
auth_user_id: Authenticated user ID for stats/limits (None for guests).
"""
id: str
name: str
websocket: Optional[WebSocket] = None
is_host: bool = False
is_cpu: bool = False
auth_user_id: Optional[str] = None
@dataclass
class Room:
"""
A game room/lobby that can host a multiplayer Golf game.
Attributes:
code: 4-letter room code for joining (e.g., "ABCD").
players: Dict mapping player IDs to RoomPlayer objects.
game: The Game instance containing actual game state.
settings: Room settings (decks, rounds, etc.).
game_log_id: SQLite log ID for analytics (if logging enabled).
game_lock: asyncio.Lock for serializing game mutations to prevent race conditions.
"""
code: str
players: dict[str, RoomPlayer] = field(default_factory=dict)
game: Game = field(default_factory=Game)
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
game_log_id: Optional[str] = None # For SQLite logging
game_log_id: Optional[str] = None
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
cpu_turn_task: Optional[asyncio.Task] = None
def add_player(self, player_id: str, name: str, websocket: WebSocket) -> RoomPlayer:
def add_player(
self,
player_id: str,
name: str,
websocket: WebSocket,
auth_user_id: Optional[str] = None,
) -> RoomPlayer:
"""
Add a human player to the room.
The first player to join becomes the host.
Args:
player_id: Unique identifier for the player (connection_id).
name: Display name.
websocket: The player's WebSocket connection.
auth_user_id: Authenticated user ID for stats/limits (None for guests).
Returns:
The created RoomPlayer object.
"""
is_host = len(self.players) == 0
room_player = RoomPlayer(
id=player_id,
name=name,
websocket=websocket,
is_host=is_host,
auth_user_id=auth_user_id,
)
self.players[player_id] = room_player
# Add to game
game_player = Player(id=player_id, name=name)
self.game.add_player(game_player)
return room_player
def add_cpu_player(self, cpu_id: str, profile_name: Optional[str] = None) -> Optional[RoomPlayer]:
# Get a CPU profile (specific or random)
def add_cpu_player(
self,
cpu_id: str,
profile_name: Optional[str] = None,
) -> Optional[RoomPlayer]:
"""
Add a CPU player to the room.
Args:
cpu_id: Unique identifier for the CPU player.
profile_name: Specific AI profile to use, or None for random.
Returns:
The created RoomPlayer, or None if profile unavailable.
"""
if profile_name:
profile = assign_specific_profile(cpu_id, profile_name)
profile = assign_specific_profile(cpu_id, profile_name, self.code)
else:
profile = assign_profile(cpu_id)
profile = assign_profile(cpu_id, self.code)
if not profile:
return None # Profile not available
return None
room_player = RoomPlayer(
id=cpu_id,
@@ -62,39 +139,64 @@ class Room:
)
self.players[cpu_id] = room_player
# Add to game
game_player = Player(id=cpu_id, name=profile.name)
self.game.add_player(game_player)
return room_player
def remove_player(self, player_id: str) -> Optional[RoomPlayer]:
if player_id in self.players:
room_player = self.players.pop(player_id)
self.game.remove_player(player_id)
"""
Remove a player from the room.
# Release CPU profile back to the pool
if room_player.is_cpu:
release_profile(room_player.name)
Handles host reassignment if the host leaves, and releases
CPU profiles back to the pool.
# Assign new host if needed
if room_player.is_host and self.players:
next_host = next(iter(self.players.values()))
next_host.is_host = True
Args:
player_id: ID of the player to remove.
return room_player
return None
Returns:
The removed RoomPlayer, or None if not found.
"""
if player_id not in self.players:
return None
room_player = self.players.pop(player_id)
self.game.remove_player(player_id)
# Release CPU profile back to the room's pool
if room_player.is_cpu:
release_profile(room_player.name, self.code)
# Assign new host if needed
if room_player.is_host and self.players:
next_host = next(iter(self.players.values()))
next_host.is_host = True
return room_player
def get_player(self, player_id: str) -> Optional[RoomPlayer]:
"""Get a player by ID, or None if not found."""
return self.players.get(player_id)
def is_empty(self) -> bool:
"""Check if the room has no players."""
return len(self.players) == 0
def player_list(self) -> list[dict]:
"""
Get list of players for client display.
Returns:
List of dicts with id, name, is_host, is_cpu, and style (for CPUs).
"""
result = []
for p in self.players.values():
player_data = {"id": p.id, "name": p.name, "is_host": p.is_host, "is_cpu": p.is_cpu}
player_data = {
"id": p.id,
"name": p.name,
"is_host": p.is_host,
"is_cpu": p.is_cpu,
}
if p.is_cpu:
profile = get_profile(p.id)
if profile:
@@ -103,12 +205,21 @@ class Room:
return result
def get_cpu_players(self) -> list[RoomPlayer]:
"""Get all CPU players in the room."""
return [p for p in self.players.values() if p.is_cpu]
def human_player_count(self) -> int:
"""Count the number of human (non-CPU) players."""
return sum(1 for p in self.players.values() if not p.is_cpu)
async def broadcast(self, message: dict, exclude: Optional[str] = None):
async def broadcast(self, message: dict, exclude: Optional[str] = None) -> None:
"""
Send a message to all human players in the room.
Args:
message: JSON-serializable message dict.
exclude: Optional player ID to skip.
"""
for player_id, player in self.players.items():
if player_id != exclude and player.websocket and not player.is_cpu:
try:
@@ -116,7 +227,14 @@ class Room:
except Exception:
pass
async def send_to(self, player_id: str, message: dict):
async def send_to(self, player_id: str, message: dict) -> None:
"""
Send a message to a specific player.
Args:
player_id: ID of the recipient player.
message: JSON-serializable message dict.
"""
player = self.players.get(player_id)
if player and player.websocket and not player.is_cpu:
try:
@@ -126,29 +244,69 @@ class Room:
class RoomManager:
def __init__(self):
"""
Manages all active game rooms.
Provides room creation with unique codes, lookup, and cleanup.
A single RoomManager instance is used by the server.
"""
def __init__(self) -> None:
"""Initialize an empty room manager."""
self.rooms: dict[str, Room] = {}
def _generate_code(self) -> str:
while True:
def _generate_code(self, max_attempts: int = 100) -> str:
"""Generate a unique 4-letter room code."""
for _ in range(max_attempts):
code = "".join(random.choices(string.ascii_uppercase, k=4))
if code not in self.rooms:
return code
raise RuntimeError("Could not generate unique room code")
def create_room(self) -> Room:
"""
Create a new room with a unique code.
Returns:
The newly created Room.
"""
code = self._generate_code()
room = Room(code=code)
self.rooms[code] = room
return room
def get_room(self, code: str) -> Optional[Room]:
"""
Get a room by its code (case-insensitive).
Args:
code: The 4-letter room code.
Returns:
The Room if found, None otherwise.
"""
return self.rooms.get(code.upper())
def remove_room(self, code: str):
def remove_room(self, code: str) -> None:
"""
Delete a room.
Args:
code: The room code to remove.
"""
if code in self.rooms:
del self.rooms[code]
def find_player_room(self, player_id: str) -> Optional[Room]:
"""
Find which room a player is in.
Args:
player_id: The player ID to search for.
Returns:
The Room containing the player, or None.
"""
for room in self.rooms.values():
if player_id in room.players:
return room

View File

@@ -0,0 +1,9 @@
"""Routers package for Golf game API."""
from .auth import router as auth_router
from .admin import router as admin_router
__all__ = [
"auth_router",
"admin_router",
]

419
server/routers/admin.py Normal file
View File

@@ -0,0 +1,419 @@
"""
Admin API router for Golf game V2.
Provides endpoints for admin operations: user management, game moderation,
system statistics, invite codes, and audit logging.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from models.user import User
from services.admin_service import AdminService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"])
# =============================================================================
# Request/Response Models
# =============================================================================
class BanUserRequest(BaseModel):
"""Ban user request."""
reason: str
duration_days: Optional[int] = None
class ChangeRoleRequest(BaseModel):
"""Change user role request."""
role: str
class CreateInviteRequest(BaseModel):
"""Create invite code request."""
max_uses: int = 1
expires_days: int = 7
class EndGameRequest(BaseModel):
"""End game request."""
reason: str
# =============================================================================
# Dependencies
# =============================================================================
# These will be set by main.py during startup
_admin_service: Optional[AdminService] = None
def set_admin_service(service: AdminService) -> None:
"""Set the admin service instance (called from main.py)."""
global _admin_service
_admin_service = service
def get_admin_service_dep() -> AdminService:
"""Dependency to get admin service."""
if _admin_service is None:
raise HTTPException(status_code=503, detail="Admin service not initialized")
return _admin_service
# Import the auth dependency from the auth router
from routers.auth import require_admin_v2, get_client_ip
# =============================================================================
# User Management Endpoints
# =============================================================================
@router.get("/users")
async def list_users(
query: str = "",
limit: int = 50,
offset: int = 0,
include_banned: bool = True,
include_deleted: bool = False,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Search and list users.
Args:
query: Search by username or email.
limit: Maximum results to return.
offset: Results to skip.
include_banned: Include banned users.
include_deleted: Include soft-deleted users.
"""
users = await service.search_users(
query=query,
limit=limit,
offset=offset,
include_banned=include_banned,
include_deleted=include_deleted,
)
return {"users": [u.to_dict() for u in users]}
@router.get("/users/{user_id}")
async def get_user(
user_id: str,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Get detailed user information."""
user = await service.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user.to_dict()
@router.get("/users/{user_id}/ban-history")
async def get_user_ban_history(
user_id: str,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Get ban history for a user."""
history = await service.get_user_ban_history(user_id)
return {"history": history}
@router.post("/users/{user_id}/ban")
async def ban_user(
user_id: str,
request_body: BanUserRequest,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Ban a user.
Banning revokes all sessions and optionally removes from active games.
Admins cannot be banned.
"""
if user_id == admin.id:
raise HTTPException(status_code=400, detail="Cannot ban yourself")
success = await service.ban_user(
admin_id=admin.id,
user_id=user_id,
reason=request_body.reason,
duration_days=request_body.duration_days,
ip_address=get_client_ip(request),
)
if not success:
raise HTTPException(status_code=400, detail="Cannot ban user (user not found or is admin)")
return {"message": "User banned successfully"}
@router.post("/users/{user_id}/unban")
async def unban_user(
user_id: str,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Unban a user."""
success = await service.unban_user(
admin_id=admin.id,
user_id=user_id,
ip_address=get_client_ip(request),
)
if not success:
raise HTTPException(status_code=400, detail="Cannot unban user")
return {"message": "User unbanned successfully"}
@router.post("/users/{user_id}/force-password-reset")
async def force_password_reset(
user_id: str,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Force user to reset password on next login.
All existing sessions are revoked.
"""
success = await service.force_password_reset(
admin_id=admin.id,
user_id=user_id,
ip_address=get_client_ip(request),
)
if not success:
raise HTTPException(status_code=400, detail="Cannot force password reset")
return {"message": "Password reset required for user"}
@router.put("/users/{user_id}/role")
async def change_user_role(
user_id: str,
request_body: ChangeRoleRequest,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Change user role.
Valid roles: "user", "admin"
"""
if user_id == admin.id:
raise HTTPException(status_code=400, detail="Cannot change your own role")
if request_body.role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="Invalid role. Must be 'user' or 'admin'")
success = await service.change_user_role(
admin_id=admin.id,
user_id=user_id,
new_role=request_body.role,
ip_address=get_client_ip(request),
)
if not success:
raise HTTPException(status_code=400, detail="Cannot change user role")
return {"message": f"Role changed to {request_body.role}"}
@router.post("/users/{user_id}/impersonate")
async def impersonate_user(
user_id: str,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Start read-only impersonation of a user.
Returns the user's data as they would see it. This is for
debugging and support purposes only.
"""
user = await service.impersonate_user(
admin_id=admin.id,
user_id=user_id,
ip_address=get_client_ip(request),
)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"message": "Impersonation started (read-only)",
"user": user.to_dict(),
}
# =============================================================================
# Game Moderation Endpoints
# =============================================================================
@router.get("/games")
async def list_active_games(
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""List all active games."""
games = await service.get_active_games()
return {"games": games}
@router.get("/games/{game_id}")
async def get_game_details(
game_id: str,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Get full game state (admin view).
This view shows all cards, including face-down cards.
"""
game = await service.get_game_details(
admin_id=admin.id,
game_id=game_id,
ip_address=get_client_ip(request),
)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
return game
@router.post("/games/{game_id}/end")
async def end_game(
game_id: str,
request_body: EndGameRequest,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Force-end a stuck or problematic game.
The game will be marked as abandoned.
"""
success = await service.end_game(
admin_id=admin.id,
game_id=game_id,
reason=request_body.reason,
ip_address=get_client_ip(request),
)
if not success:
raise HTTPException(status_code=400, detail="Cannot end game")
return {"message": "Game ended successfully"}
# =============================================================================
# System Stats Endpoints
# =============================================================================
@router.get("/stats")
async def get_system_stats(
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Get current system statistics."""
stats = await service.get_system_stats()
return stats.to_dict()
# =============================================================================
# Audit Log Endpoints
# =============================================================================
@router.get("/audit")
async def get_audit_log(
limit: int = 100,
offset: int = 0,
admin_id: Optional[str] = None,
action: Optional[str] = None,
target_type: Optional[str] = None,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Get admin audit log.
Can filter by admin_id, action type, or target type.
"""
entries = await service.get_audit_log(
limit=limit,
offset=offset,
admin_id=admin_id,
action=action,
target_type=target_type,
)
return {"entries": [e.to_dict() for e in entries]}
# =============================================================================
# Invite Code Endpoints
# =============================================================================
@router.get("/invites")
async def list_invite_codes(
include_expired: bool = False,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""List all invite codes."""
codes = await service.get_invite_codes(include_expired=include_expired)
return {"codes": [c.to_dict() for c in codes]}
@router.post("/invites")
async def create_invite_code(
request_body: CreateInviteRequest,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Create a new invite code.
Args:
max_uses: Maximum number of times the code can be used.
expires_days: Number of days until the code expires.
"""
code = await service.create_invite_code(
admin_id=admin.id,
max_uses=request_body.max_uses,
expires_days=request_body.expires_days,
ip_address=get_client_ip(request),
)
return {"code": code, "message": "Invite code created successfully"}
@router.delete("/invites/{code}")
async def revoke_invite_code(
code: str,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Revoke an invite code."""
success = await service.revoke_invite_code(
admin_id=admin.id,
code=code,
ip_address=get_client_ip(request),
)
if not success:
raise HTTPException(status_code=404, detail="Invite code not found")
return {"message": "Invite code revoked successfully"}

529
server/routers/auth.py Normal file
View File

@@ -0,0 +1,529 @@
"""
Authentication API router for Golf game V2.
Provides endpoints for user registration, login, password management,
and session handling.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Request
from pydantic import BaseModel, EmailStr
from config import config
from models.user import User
from services.auth_service import AuthService
from services.admin_service import AdminService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
# =============================================================================
# Request/Response Models
# =============================================================================
class RegisterRequest(BaseModel):
"""Registration request."""
username: str
password: str
email: Optional[str] = None
invite_code: Optional[str] = None
class LoginRequest(BaseModel):
"""Login request."""
username: str
password: str
class VerifyEmailRequest(BaseModel):
"""Email verification request."""
token: str
class ResendVerificationRequest(BaseModel):
"""Resend verification email request."""
email: str
class ForgotPasswordRequest(BaseModel):
"""Forgot password request."""
email: str
class ResetPasswordRequest(BaseModel):
"""Password reset request."""
token: str
new_password: str
class ChangePasswordRequest(BaseModel):
"""Change password request."""
current_password: str
new_password: str
class UpdatePreferencesRequest(BaseModel):
"""Update preferences request."""
preferences: dict
class ConvertGuestRequest(BaseModel):
"""Convert guest to user request."""
guest_id: str
username: str
password: str
email: Optional[str] = None
class UserResponse(BaseModel):
"""User response (public fields only)."""
id: str
username: str
email: Optional[str]
role: str
email_verified: bool
preferences: dict
created_at: str
last_login: Optional[str]
class AuthResponse(BaseModel):
"""Authentication response with token."""
user: UserResponse
token: str
expires_at: str
class SessionResponse(BaseModel):
"""Session response."""
id: str
device_info: dict
ip_address: Optional[str]
created_at: str
last_used_at: str
# =============================================================================
# Dependencies
# =============================================================================
# These will be set by main.py during startup
_auth_service: Optional[AuthService] = None
_admin_service: Optional[AdminService] = None
def set_auth_service(service: AuthService) -> None:
"""Set the auth service instance (called from main.py)."""
global _auth_service
_auth_service = service
def set_admin_service_for_auth(service: AdminService) -> None:
"""Set the admin service instance for invite code validation (called from main.py)."""
global _admin_service
_admin_service = service
def get_auth_service_dep() -> AuthService:
"""Dependency to get auth service."""
if _auth_service is None:
raise HTTPException(status_code=503, detail="Auth service not initialized")
return _auth_service
async def get_current_user_v2(
authorization: Optional[str] = Header(None),
auth_service: AuthService = Depends(get_auth_service_dep),
) -> Optional[User]:
"""Get current user from Authorization header (optional)."""
if not authorization:
return None
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
token = parts[1]
return await auth_service.get_user_from_token(token)
async def require_user_v2(
user: Optional[User] = Depends(get_current_user_v2),
) -> User:
"""Require authenticated user."""
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account disabled")
return user
async def require_admin_v2(
user: User = Depends(require_user_v2),
) -> User:
"""Require admin user."""
if not user.is_admin():
raise HTTPException(status_code=403, detail="Admin access required")
return user
def get_client_ip(request: Request) -> Optional[str]:
"""Extract client IP from request."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return None
def get_device_info(request: Request) -> dict:
"""Extract device info from request headers."""
return {
"user_agent": request.headers.get("user-agent", ""),
}
def get_token_from_header(authorization: Optional[str] = Header(None)) -> Optional[str]:
"""Extract token from Authorization header."""
if not authorization:
return None
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
return parts[1]
# =============================================================================
# Registration Endpoints
# =============================================================================
@router.post("/register", response_model=AuthResponse)
async def register(
request_body: RegisterRequest,
request: Request,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Register a new user account."""
# Validate invite code when invite-only mode is enabled
if config.INVITE_ONLY:
if not request_body.invite_code:
raise HTTPException(status_code=400, detail="Invite code required")
if not _admin_service:
raise HTTPException(status_code=503, detail="Admin service not initialized")
if not await _admin_service.validate_invite_code(request_body.invite_code):
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
result = await auth_service.register(
username=request_body.username,
password=request_body.password,
email=request_body.email,
)
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
# Consume the invite code after successful registration
if config.INVITE_ONLY and request_body.invite_code:
await _admin_service.use_invite_code(request_body.invite_code)
if result.requires_verification:
# Return user info but note they need to verify
return {
"user": _user_to_response(result.user),
"token": "",
"expires_at": "",
"message": "Please check your email to verify your account",
}
# Auto-login after registration
login_result = await auth_service.login(
username=request_body.username,
password=request_body.password,
device_info=get_device_info(request),
ip_address=get_client_ip(request),
)
if not login_result.success:
raise HTTPException(status_code=500, detail="Registration succeeded but login failed")
return {
"user": _user_to_response(login_result.user),
"token": login_result.token,
"expires_at": login_result.expires_at.isoformat(),
}
@router.post("/verify-email")
async def verify_email(
request_body: VerifyEmailRequest,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Verify email address with token."""
result = await auth_service.verify_email(request_body.token)
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
return {"status": "ok", "message": "Email verified successfully"}
@router.post("/resend-verification")
async def resend_verification(
request_body: ResendVerificationRequest,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Resend verification email."""
await auth_service.resend_verification(request_body.email)
# Always return success to prevent email enumeration
return {"status": "ok", "message": "If the email exists, a verification link has been sent"}
# =============================================================================
# Login/Logout Endpoints
# =============================================================================
@router.post("/login", response_model=AuthResponse)
async def login(
request_body: LoginRequest,
request: Request,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Login with username/email and password."""
result = await auth_service.login(
username=request_body.username,
password=request_body.password,
device_info=get_device_info(request),
ip_address=get_client_ip(request),
)
if not result.success:
raise HTTPException(status_code=401, detail=result.error)
return {
"user": _user_to_response(result.user),
"token": result.token,
"expires_at": result.expires_at.isoformat(),
}
@router.post("/logout")
async def logout(
token: Optional[str] = Depends(get_token_from_header),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Logout current session."""
if token:
await auth_service.logout(token)
return {"status": "ok"}
@router.post("/logout-all")
async def logout_all(
user: User = Depends(require_user_v2),
token: Optional[str] = Depends(get_token_from_header),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Logout all sessions except current."""
count = await auth_service.logout_all(user.id, except_token=token)
return {"status": "ok", "sessions_revoked": count}
# =============================================================================
# Password Management Endpoints
# =============================================================================
@router.post("/forgot-password")
async def forgot_password(
request_body: ForgotPasswordRequest,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Request password reset email."""
await auth_service.forgot_password(request_body.email)
# Always return success to prevent email enumeration
return {"status": "ok", "message": "If the email exists, a reset link has been sent"}
@router.post("/reset-password")
async def reset_password(
request_body: ResetPasswordRequest,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Reset password with token."""
result = await auth_service.reset_password(
token=request_body.token,
new_password=request_body.new_password,
)
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
return {"status": "ok", "message": "Password reset successfully"}
@router.put("/password")
async def change_password(
request_body: ChangePasswordRequest,
user: User = Depends(require_user_v2),
token: Optional[str] = Depends(get_token_from_header),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Change password for current user."""
result = await auth_service.change_password(
user_id=user.id,
current_password=request_body.current_password,
new_password=request_body.new_password,
current_token=token,
)
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
return {"status": "ok", "message": "Password changed successfully"}
# =============================================================================
# User Profile Endpoints
# =============================================================================
@router.get("/me")
async def get_me(user: User = Depends(require_user_v2)):
"""Get current user info."""
return {"user": _user_to_response(user)}
@router.put("/me/preferences")
async def update_preferences(
request_body: UpdatePreferencesRequest,
user: User = Depends(require_user_v2),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Update user preferences."""
updated = await auth_service.update_preferences(user.id, request_body.preferences)
if not updated:
raise HTTPException(status_code=500, detail="Failed to update preferences")
return {"user": _user_to_response(updated)}
# =============================================================================
# Session Management Endpoints
# =============================================================================
@router.get("/sessions")
async def get_sessions(
user: User = Depends(require_user_v2),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Get all active sessions for current user."""
sessions = await auth_service.get_sessions(user.id)
return {
"sessions": [
{
"id": s.id,
"device_info": s.device_info,
"ip_address": s.ip_address,
"created_at": s.created_at.isoformat() if s.created_at else None,
"last_used_at": s.last_used_at.isoformat() if s.last_used_at else None,
}
for s in sessions
]
}
@router.delete("/sessions/{session_id}")
async def revoke_session(
session_id: str,
user: User = Depends(require_user_v2),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Revoke a specific session."""
success = await auth_service.revoke_session(user.id, session_id)
if not success:
raise HTTPException(status_code=404, detail="Session not found")
return {"status": "ok"}
# =============================================================================
# Guest Conversion Endpoint
# =============================================================================
@router.post("/convert-guest", response_model=AuthResponse)
async def convert_guest(
request_body: ConvertGuestRequest,
request: Request,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Convert guest session to full user account."""
result = await auth_service.convert_guest(
guest_id=request_body.guest_id,
username=request_body.username,
password=request_body.password,
email=request_body.email,
)
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
# Auto-login after conversion
login_result = await auth_service.login(
username=request_body.username,
password=request_body.password,
device_info=get_device_info(request),
ip_address=get_client_ip(request),
)
if not login_result.success:
raise HTTPException(status_code=500, detail="Conversion succeeded but login failed")
return {
"user": _user_to_response(login_result.user),
"token": login_result.token,
"expires_at": login_result.expires_at.isoformat(),
}
# =============================================================================
# Account Deletion Endpoint
# =============================================================================
@router.delete("/me")
async def delete_account(
user: User = Depends(require_user_v2),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Delete (soft delete) current user account."""
success = await auth_service.delete_account(user.id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete account")
return {"status": "ok", "message": "Account deleted"}
# =============================================================================
# Helpers
# =============================================================================
def _user_to_response(user: User) -> dict:
"""Convert User to response dict (public fields only)."""
return {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role.value,
"email_verified": user.email_verified,
"preferences": user.preferences,
"created_at": user.created_at.isoformat() if user.created_at else None,
"last_login": user.last_login.isoformat() if user.last_login else None,
}

171
server/routers/health.py Normal file
View File

@@ -0,0 +1,171 @@
"""
Health check endpoints for production deployment.
Provides:
- /health - Basic liveness check (is the app running?)
- /ready - Readiness check (can the app handle requests?)
- /metrics - Application metrics for monitoring
"""
import json
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Response
logger = logging.getLogger(__name__)
router = APIRouter(tags=["health"])
# Service references (set during app initialization)
_db_pool = None
_redis_client = None
_room_manager = None
def set_health_dependencies(
db_pool=None,
redis_client=None,
room_manager=None,
):
"""Set dependencies for health checks."""
global _db_pool, _redis_client, _room_manager
_db_pool = db_pool
_redis_client = redis_client
_room_manager = room_manager
@router.get("/health")
async def health_check():
"""
Basic liveness check - is the app running?
This endpoint should always return 200 if the process is alive.
Used by container orchestration for restart decisions.
"""
return {
"status": "ok",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
@router.get("/ready")
async def readiness_check():
"""
Readiness check - can the app handle requests?
Checks connectivity to required services (database, Redis).
Returns 503 if any critical service is unavailable.
"""
checks = {}
overall_healthy = True
# Check PostgreSQL
if _db_pool is not None:
try:
async with _db_pool.acquire() as conn:
await conn.fetchval("SELECT 1")
checks["database"] = {"status": "ok"}
except Exception as e:
logger.warning(f"Database health check failed: {e}")
checks["database"] = {"status": "error", "message": str(e)}
overall_healthy = False
else:
checks["database"] = {"status": "not_configured"}
# Check Redis
if _redis_client is not None:
try:
await _redis_client.ping()
checks["redis"] = {"status": "ok"}
except Exception as e:
logger.warning(f"Redis health check failed: {e}")
checks["redis"] = {"status": "error", "message": str(e)}
overall_healthy = False
else:
checks["redis"] = {"status": "not_configured"}
status_code = 200 if overall_healthy else 503
return Response(
content=json.dumps({
"status": "ok" if overall_healthy else "degraded",
"checks": checks,
"timestamp": datetime.now(timezone.utc).isoformat(),
}),
status_code=status_code,
media_type="application/json",
)
@router.get("/metrics")
async def metrics():
"""
Expose application metrics for monitoring.
Returns operational metrics useful for dashboards and alerting.
"""
metrics_data = {
"timestamp": datetime.now(timezone.utc).isoformat(),
}
# Room/game metrics from room manager
if _room_manager is not None:
try:
rooms = _room_manager.rooms
active_rooms = len(rooms)
total_players = sum(len(r.players) for r in rooms.values())
games_in_progress = sum(
1 for r in rooms.values()
if hasattr(r.game, 'phase') and r.game.phase.name not in ('WAITING', 'GAME_OVER')
)
metrics_data.update({
"active_rooms": active_rooms,
"total_players": total_players,
"games_in_progress": games_in_progress,
})
except Exception as e:
logger.warning(f"Failed to collect room metrics: {e}")
# Database metrics
if _db_pool is not None:
try:
async with _db_pool.acquire() as conn:
# Count active games (if games table exists)
try:
games_today = await conn.fetchval(
"SELECT COUNT(*) FROM game_events WHERE timestamp > NOW() - INTERVAL '1 day'"
)
metrics_data["events_today"] = games_today
except Exception:
pass # Table might not exist
# Count users (if users table exists)
try:
total_users = await conn.fetchval("SELECT COUNT(*) FROM users")
metrics_data["total_users"] = total_users
except Exception:
pass # Table might not exist
except Exception as e:
logger.warning(f"Failed to collect database metrics: {e}")
# Redis metrics
if _redis_client is not None:
try:
# Get connected players from Redis set if tracking
try:
connected = await _redis_client.scard("golf:connected_players")
metrics_data["connected_websockets"] = connected
except Exception:
pass
# Get active rooms from Redis
try:
active_rooms_redis = await _redis_client.scard("golf:rooms:active")
metrics_data["active_rooms_redis"] = active_rooms_redis
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to collect Redis metrics: {e}")
return metrics_data

501
server/routers/replay.py Normal file
View File

@@ -0,0 +1,501 @@
"""
Replay API router for Golf game.
Provides endpoints for:
- Viewing game replays
- Creating and managing share links
- Exporting/importing games
- Spectating live games
"""
import hashlib
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Depends, Header, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/replay", tags=["replay"])
# Service instances (set during app startup)
_replay_service = None
_auth_service = None
_spectator_manager = None
_room_manager = None
def set_replay_service(service) -> None:
"""Set the replay service instance."""
global _replay_service
_replay_service = service
def set_auth_service(service) -> None:
"""Set the auth service instance."""
global _auth_service
_auth_service = service
def set_spectator_manager(manager) -> None:
"""Set the spectator manager instance."""
global _spectator_manager
_spectator_manager = manager
def set_room_manager(manager) -> None:
"""Set the room manager instance."""
global _room_manager
_room_manager = manager
# -------------------------------------------------------------------------
# Auth Dependencies
# -------------------------------------------------------------------------
async def get_current_user(authorization: Optional[str] = Header(None)):
"""Get current user from Authorization header."""
if not authorization or not _auth_service:
return None
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
token = parts[1]
return await _auth_service.get_user_from_token(token)
async def require_auth(user=Depends(get_current_user)):
"""Require authenticated user."""
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
# -------------------------------------------------------------------------
# Request/Response Models
# -------------------------------------------------------------------------
class ShareLinkRequest(BaseModel):
"""Request to create a share link."""
title: Optional[str] = None
description: Optional[str] = None
expires_days: Optional[int] = None
class ImportGameRequest(BaseModel):
"""Request to import a game."""
export_data: dict
# -------------------------------------------------------------------------
# Replay Endpoints
# -------------------------------------------------------------------------
@router.get("/game/{game_id}")
async def get_replay(game_id: str, user=Depends(get_current_user)):
"""
Get full replay for a game.
Returns all frames with game state at each step.
Requires authentication and permission to view the game.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
# Check permission
user_id = user.id if user else None
if not await _replay_service.can_view_game(user_id, game_id):
raise HTTPException(status_code=403, detail="Cannot view this game")
try:
replay = await _replay_service.build_replay(game_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {
"game_id": replay.game_id,
"room_code": replay.room_code,
"frames": [
{
"index": f.event_index,
"event_type": f.event_type,
"event_data": f.event_data,
"timestamp": f.timestamp,
"state": f.game_state,
"player_id": f.player_id,
}
for f in replay.frames
],
"metadata": {
"players": replay.player_names,
"winner": replay.winner,
"final_scores": replay.final_scores,
"duration": replay.total_duration_seconds,
"total_rounds": replay.total_rounds,
"options": replay.options,
},
}
@router.get("/game/{game_id}/frame/{frame_index}")
async def get_replay_frame(game_id: str, frame_index: int, user=Depends(get_current_user)):
"""
Get a specific frame from a replay.
Useful for seeking without loading the entire replay.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
user_id = user.id if user else None
if not await _replay_service.can_view_game(user_id, game_id):
raise HTTPException(status_code=403, detail="Cannot view this game")
frame = await _replay_service.get_replay_frame(game_id, frame_index)
if not frame:
raise HTTPException(status_code=404, detail="Frame not found")
return {
"index": frame.event_index,
"event_type": frame.event_type,
"event_data": frame.event_data,
"timestamp": frame.timestamp,
"state": frame.game_state,
"player_id": frame.player_id,
}
# -------------------------------------------------------------------------
# Share Link Endpoints
# -------------------------------------------------------------------------
@router.post("/game/{game_id}/share")
async def create_share_link(
game_id: str,
request: ShareLinkRequest,
user=Depends(require_auth),
):
"""
Create shareable link for a game.
Only users who played in the game can create share links.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
# Validate expires_days
if request.expires_days is not None and (request.expires_days < 1 or request.expires_days > 365):
raise HTTPException(status_code=400, detail="expires_days must be between 1 and 365")
# Check if user played in the game
if not await _replay_service.can_view_game(user.id, game_id):
raise HTTPException(status_code=403, detail="Can only share games you played in")
try:
share_code = await _replay_service.create_share_link(
game_id=game_id,
user_id=user.id,
title=request.title,
description=request.description,
expires_days=request.expires_days,
)
except Exception as e:
logger.error(f"Failed to create share link: {e}")
raise HTTPException(status_code=500, detail="Failed to create share link")
return {
"share_code": share_code,
"share_url": f"/replay/{share_code}",
"expires_days": request.expires_days,
}
@router.get("/shared/{share_code}")
async def get_shared_replay(share_code: str):
"""
Get replay via share code (public endpoint).
No authentication required for public share links.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
shared = await _replay_service.get_shared_game(share_code)
if not shared:
raise HTTPException(status_code=404, detail="Shared game not found or expired")
try:
replay = await _replay_service.build_replay(str(shared["game_id"]))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {
"title": shared.get("title"),
"description": shared.get("description"),
"view_count": shared["view_count"],
"created_at": shared["created_at"].isoformat() if shared.get("created_at") else None,
"game_id": str(shared["game_id"]),
"room_code": replay.room_code,
"frames": [
{
"index": f.event_index,
"event_type": f.event_type,
"event_data": f.event_data,
"timestamp": f.timestamp,
"state": f.game_state,
"player_id": f.player_id,
}
for f in replay.frames
],
"metadata": {
"players": replay.player_names,
"winner": replay.winner,
"final_scores": replay.final_scores,
"duration": replay.total_duration_seconds,
"total_rounds": replay.total_rounds,
"options": replay.options,
},
}
@router.get("/shared/{share_code}/info")
async def get_shared_info(share_code: str):
"""
Get info about a shared game without full replay data.
Useful for preview/metadata display.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
shared = await _replay_service.get_shared_game(share_code)
if not shared:
raise HTTPException(status_code=404, detail="Shared game not found or expired")
return {
"title": shared.get("title"),
"description": shared.get("description"),
"view_count": shared["view_count"],
"created_at": shared["created_at"].isoformat() if shared.get("created_at") else None,
"room_code": shared.get("room_code"),
"num_players": shared.get("num_players"),
"num_rounds": shared.get("num_rounds"),
}
@router.delete("/shared/{share_code}")
async def delete_share_link(share_code: str, user=Depends(require_auth)):
"""Delete a share link (creator only)."""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
deleted = await _replay_service.delete_share_link(share_code, user.id)
if not deleted:
raise HTTPException(status_code=404, detail="Share link not found or not authorized")
return {"deleted": True}
@router.get("/my-shares")
async def get_my_shares(user=Depends(require_auth)):
"""Get all share links created by the current user."""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
shares = await _replay_service.get_user_shared_games(user.id)
return {
"shares": [
{
"share_code": s["share_code"],
"game_id": str(s["game_id"]),
"title": s.get("title"),
"view_count": s["view_count"],
"created_at": s["created_at"].isoformat() if s.get("created_at") else None,
"expires_at": s["expires_at"].isoformat() if s.get("expires_at") else None,
}
for s in shares
],
}
# -------------------------------------------------------------------------
# Export/Import Endpoints
# -------------------------------------------------------------------------
@router.get("/game/{game_id}/export")
async def export_game(game_id: str, user=Depends(require_auth)):
"""
Export game as downloadable JSON.
Returns the complete game data suitable for backup or sharing.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
if not await _replay_service.can_view_game(user.id, game_id):
raise HTTPException(status_code=403, detail="Cannot export this game")
try:
export_data = await _replay_service.export_game(game_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# Return as downloadable JSON
return JSONResponse(
content=export_data,
headers={
"Content-Disposition": f'attachment; filename="golf-game-{game_id[:8]}.json"'
},
)
@router.post("/import")
async def import_game(request: ImportGameRequest, user=Depends(require_auth)):
"""
Import a game from JSON export.
Creates a new game record from the exported data.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
try:
new_game_id = await _replay_service.import_game(request.export_data, user.id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Import failed: {e}")
raise HTTPException(status_code=500, detail="Failed to import game")
return {
"game_id": new_game_id,
"message": "Game imported successfully",
}
# -------------------------------------------------------------------------
# Game History
# -------------------------------------------------------------------------
@router.get("/history")
async def get_game_history(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
user=Depends(require_auth),
):
"""Get game history for the current user."""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
games = await _replay_service.get_user_game_history(user.id, limit, offset)
return {
"games": [
{
"game_id": str(g["id"]),
"room_code": g["room_code"],
"status": g["status"],
"completed_at": g["completed_at"].isoformat() if g.get("completed_at") else None,
"num_players": g["num_players"],
"num_rounds": g["num_rounds"],
"won": g.get("winner_id") == user.id,
}
for g in games
],
"limit": limit,
"offset": offset,
}
# -------------------------------------------------------------------------
# Spectator Endpoints
# -------------------------------------------------------------------------
@router.websocket("/spectate/{room_code}")
async def spectate_game(websocket: WebSocket, room_code: str):
"""
WebSocket endpoint for spectating live games.
Spectators receive real-time game state updates but cannot interact.
Supports optional authentication via token query parameter.
"""
await websocket.accept()
# Optional authentication for spectators
token = websocket.query_params.get("token")
spectator_user = None
if token and _auth_service:
try:
spectator_user = await _auth_service.get_user_from_token(token)
except Exception:
pass # Anonymous spectator
if not _spectator_manager or not _room_manager:
await websocket.close(code=4003, reason="Spectator service unavailable")
return
# Find the game by room code
room = _room_manager.get_room(room_code.upper())
if not room:
await websocket.close(code=4004, reason="Game not found")
return
game_id = room_code.upper() # Use room code as identifier for spectators
# Add spectator
added = await _spectator_manager.add_spectator(game_id, websocket)
if not added:
await websocket.close(code=4005, reason="Spectator limit reached")
return
try:
# Send initial game state
game_state = room.game.get_state(None) # No player perspective
await websocket.send_json({
"type": "spectator_joined",
"game_state": game_state,
"spectator_count": _spectator_manager.get_spectator_count(game_id),
"players": room.player_list(),
"authenticated": spectator_user is not None,
})
# Keep connection alive
while True:
data = await websocket.receive_text()
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug(f"Spectator connection error: {e}")
finally:
await _spectator_manager.remove_spectator(game_id, websocket)
@router.get("/spectate/{room_code}/count")
async def get_spectator_count(room_code: str):
"""Get the number of spectators for a game."""
if not _spectator_manager:
return {"count": 0}
count = _spectator_manager.get_spectator_count(room_code.upper())
return {"count": count}
@router.get("/spectate/active")
async def get_active_spectated_games():
"""Get list of games with active spectators."""
if not _spectator_manager:
return {"games": []}
games = _spectator_manager.get_games_with_spectators()
return {
"games": [
{"room_code": game_id, "spectator_count": count}
for game_id, count in games.items()
],
}

385
server/routers/stats.py Normal file
View File

@@ -0,0 +1,385 @@
"""
Stats and Leaderboards API router for Golf game.
Provides public endpoints for viewing leaderboards and player stats,
and authenticated endpoints for viewing personal stats and achievements.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Query
from pydantic import BaseModel
from models.user import User
from services.stats_service import StatsService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/stats", tags=["stats"])
# =============================================================================
# Request/Response Models
# =============================================================================
class LeaderboardEntryResponse(BaseModel):
"""Single leaderboard entry."""
rank: int
user_id: str
username: str
value: float
games_played: int
secondary_value: Optional[float] = None
class LeaderboardResponse(BaseModel):
"""Leaderboard response."""
metric: str
entries: list[LeaderboardEntryResponse]
total_players: Optional[int] = None
class PlayerStatsResponse(BaseModel):
"""Player statistics response."""
user_id: str
username: str
games_played: int
games_won: int
win_rate: float
rounds_played: int
rounds_won: int
avg_score: float
best_round_score: Optional[int]
worst_round_score: Optional[int]
knockouts: int
perfect_rounds: int
wolfpacks: int
current_win_streak: int
best_win_streak: int
first_game_at: Optional[str]
last_game_at: Optional[str]
achievements: list[str]
class PlayerRankResponse(BaseModel):
"""Player rank response."""
user_id: str
metric: str
rank: Optional[int]
qualified: bool # Whether player has enough games
class AchievementResponse(BaseModel):
"""Achievement definition response."""
id: str
name: str
description: str
icon: str
category: str
threshold: int
class UserAchievementResponse(BaseModel):
"""User achievement response."""
id: str
name: str
description: str
icon: str
earned_at: str
game_id: Optional[str]
# =============================================================================
# Dependencies
# =============================================================================
# Set by main.py during startup
_stats_service: Optional[StatsService] = None
def set_stats_service(service: StatsService) -> None:
"""Set the stats service instance (called from main.py)."""
global _stats_service
_stats_service = service
def get_stats_service_dep() -> StatsService:
"""Dependency to get stats service."""
if _stats_service is None:
raise HTTPException(status_code=503, detail="Stats service not initialized")
return _stats_service
# Auth dependencies - imported from auth router
_auth_service = None
def set_auth_service(service) -> None:
"""Set auth service for user lookup."""
global _auth_service
_auth_service = service
async def get_current_user_optional(
authorization: Optional[str] = Header(None),
) -> Optional[User]:
"""Get current user from Authorization header (optional)."""
if not authorization or not _auth_service:
return None
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
token = parts[1]
return await _auth_service.get_user_from_token(token)
async def require_user(
user: Optional[User] = Depends(get_current_user_optional),
) -> User:
"""Require authenticated user."""
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account disabled")
return user
# =============================================================================
# Public Endpoints (No Auth Required)
# =============================================================================
@router.get("/leaderboard", response_model=LeaderboardResponse)
async def get_leaderboard(
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
service: StatsService = Depends(get_stats_service_dep),
):
"""
Get leaderboard by metric.
Metrics:
- wins: Total games won
- win_rate: Win percentage (requires 5+ games)
- avg_score: Average points per round (lower is better)
- knockouts: Times going out first
- streak: Best win streak
Players must have 5+ games to appear on leaderboards.
"""
entries = await service.get_leaderboard(metric, limit, offset)
return {
"metric": metric,
"entries": [
{
"rank": e.rank,
"user_id": e.user_id,
"username": e.username,
"value": e.value,
"games_played": e.games_played,
"secondary_value": e.secondary_value,
}
for e in entries
],
}
@router.get("/players/{user_id}", response_model=PlayerStatsResponse)
async def get_player_stats(
user_id: str,
service: StatsService = Depends(get_stats_service_dep),
):
"""Get stats for a specific player (public profile)."""
stats = await service.get_player_stats(user_id)
if not stats:
raise HTTPException(status_code=404, detail="Player not found")
return {
"user_id": stats.user_id,
"username": stats.username,
"games_played": stats.games_played,
"games_won": stats.games_won,
"win_rate": stats.win_rate,
"rounds_played": stats.rounds_played,
"rounds_won": stats.rounds_won,
"avg_score": stats.avg_score,
"best_round_score": stats.best_round_score,
"worst_round_score": stats.worst_round_score,
"knockouts": stats.knockouts,
"perfect_rounds": stats.perfect_rounds,
"wolfpacks": stats.wolfpacks,
"current_win_streak": stats.current_win_streak,
"best_win_streak": stats.best_win_streak,
"first_game_at": stats.first_game_at.isoformat() if stats.first_game_at else None,
"last_game_at": stats.last_game_at.isoformat() if stats.last_game_at else None,
"achievements": stats.achievements,
}
@router.get("/players/{user_id}/rank", response_model=PlayerRankResponse)
async def get_player_rank(
user_id: str,
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
service: StatsService = Depends(get_stats_service_dep),
):
"""Get player's rank on a leaderboard."""
rank = await service.get_player_rank(user_id, metric)
return {
"user_id": user_id,
"metric": metric,
"rank": rank,
"qualified": rank is not None,
}
@router.get("/achievements", response_model=dict)
async def get_achievements(
service: StatsService = Depends(get_stats_service_dep),
):
"""Get all available achievements."""
achievements = await service.get_achievements()
return {
"achievements": [
{
"id": a.id,
"name": a.name,
"description": a.description,
"icon": a.icon,
"category": a.category,
"threshold": a.threshold,
}
for a in achievements
]
}
@router.get("/players/{user_id}/achievements", response_model=dict)
async def get_user_achievements(
user_id: str,
service: StatsService = Depends(get_stats_service_dep),
):
"""Get achievements earned by a player."""
achievements = await service.get_user_achievements(user_id)
return {
"user_id": user_id,
"achievements": [
{
"id": a.id,
"name": a.name,
"description": a.description,
"icon": a.icon,
"earned_at": a.earned_at.isoformat(),
"game_id": a.game_id,
}
for a in achievements
],
}
# =============================================================================
# Authenticated Endpoints
# =============================================================================
@router.get("/me", response_model=PlayerStatsResponse)
async def get_my_stats(
user: User = Depends(require_user),
service: StatsService = Depends(get_stats_service_dep),
):
"""Get current user's stats."""
stats = await service.get_player_stats(user.id)
if not stats:
# Return empty stats for new user
return {
"user_id": user.id,
"username": user.username,
"games_played": 0,
"games_won": 0,
"win_rate": 0.0,
"rounds_played": 0,
"rounds_won": 0,
"avg_score": 0.0,
"best_round_score": None,
"worst_round_score": None,
"knockouts": 0,
"perfect_rounds": 0,
"wolfpacks": 0,
"current_win_streak": 0,
"best_win_streak": 0,
"first_game_at": None,
"last_game_at": None,
"achievements": [],
}
return {
"user_id": stats.user_id,
"username": stats.username,
"games_played": stats.games_played,
"games_won": stats.games_won,
"win_rate": stats.win_rate,
"rounds_played": stats.rounds_played,
"rounds_won": stats.rounds_won,
"avg_score": stats.avg_score,
"best_round_score": stats.best_round_score,
"worst_round_score": stats.worst_round_score,
"knockouts": stats.knockouts,
"perfect_rounds": stats.perfect_rounds,
"wolfpacks": stats.wolfpacks,
"current_win_streak": stats.current_win_streak,
"best_win_streak": stats.best_win_streak,
"first_game_at": stats.first_game_at.isoformat() if stats.first_game_at else None,
"last_game_at": stats.last_game_at.isoformat() if stats.last_game_at else None,
"achievements": stats.achievements,
}
@router.get("/me/rank", response_model=PlayerRankResponse)
async def get_my_rank(
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
user: User = Depends(require_user),
service: StatsService = Depends(get_stats_service_dep),
):
"""Get current user's rank on a leaderboard."""
rank = await service.get_player_rank(user.id, metric)
return {
"user_id": user.id,
"metric": metric,
"rank": rank,
"qualified": rank is not None,
}
@router.get("/me/achievements", response_model=dict)
async def get_my_achievements(
user: User = Depends(require_user),
service: StatsService = Depends(get_stats_service_dep),
):
"""Get current user's achievements."""
achievements = await service.get_user_achievements(user.id)
return {
"user_id": user.id,
"achievements": [
{
"id": a.id,
"name": a.name,
"description": a.description,
"icon": a.icon,
"earned_at": a.earned_at.isoformat(),
"game_id": a.game_id,
}
for a in achievements
],
}

View File

@@ -26,7 +26,7 @@ def run_game_for_scores(num_players: int = 4) -> dict[str, int]:
game.add_player(player)
player_profiles[player.id] = profile
options = GameOptions(initial_flips=2, flip_on_discard=False, use_jokers=False)
options = GameOptions(initial_flips=2, flip_mode="never", use_jokers=False)
game.start_game(num_decks=1, num_rounds=1, options=options)
# Initial flips

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Create an admin user for the Golf game.
Usage:
python scripts/create_admin.py <username> <password> [email]
Example:
python scripts/create_admin.py admin secretpassword admin@example.com
"""
import asyncio
import sys
import os
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config import config
from stores.user_store import UserStore
from models.user import UserRole
import bcrypt
def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode(), salt)
return hashed.decode()
async def create_admin(username: str, password: str, email: str = None):
"""Create an admin user."""
if not config.POSTGRES_URL:
print("Error: POSTGRES_URL not configured in environment or .env file")
print("Make sure docker-compose is running and .env is set up")
sys.exit(1)
print(f"Connecting to database...")
store = await UserStore.create(config.POSTGRES_URL)
# Check if user already exists
existing = await store.get_user_by_username(username)
if existing:
print(f"User '{username}' already exists.")
if existing.role != UserRole.ADMIN:
# Upgrade to admin
print(f"Upgrading '{username}' to admin role...")
await store.update_user(existing.id, role=UserRole.ADMIN)
print(f"Done! User '{username}' is now an admin.")
else:
print(f"User '{username}' is already an admin.")
await store.close()
return
# Create new admin user
print(f"Creating admin user '{username}'...")
password_hash = hash_password(password)
user = await store.create_user(
username=username,
password_hash=password_hash,
email=email,
role=UserRole.ADMIN,
)
if user:
print(f"Admin user created successfully!")
print(f" Username: {user.username}")
print(f" Email: {user.email or '(none)'}")
print(f" Role: {user.role.value}")
print(f"\nYou can now login at /admin")
else:
print("Failed to create user (username or email may already exist)")
await store.close()
def main():
if len(sys.argv) < 3:
print(__doc__)
sys.exit(1)
username = sys.argv[1]
password = sys.argv[2]
email = sys.argv[3] if len(sys.argv) > 3 else None
if len(password) < 8:
print("Error: Password must be at least 8 characters")
sys.exit(1)
asyncio.run(create_admin(username, password, email))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,33 @@
"""Services package for Golf game V2 business logic."""
from .recovery_service import RecoveryService, RecoveryResult
from .email_service import EmailService, get_email_service
from .auth_service import AuthService, AuthResult, RegistrationResult, get_auth_service, close_auth_service
from .admin_service import (
AdminService,
UserDetails,
AuditEntry,
SystemStats,
InviteCode,
get_admin_service,
close_admin_service,
)
__all__ = [
"RecoveryService",
"RecoveryResult",
"EmailService",
"get_email_service",
"AuthService",
"AuthResult",
"RegistrationResult",
"get_auth_service",
"close_auth_service",
"AdminService",
"UserDetails",
"AuditEntry",
"SystemStats",
"InviteCode",
"get_admin_service",
"close_admin_service",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,654 @@
"""
Authentication service for Golf game.
Provides business logic for user registration, login, password management,
and session handling.
"""
import logging
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from typing import Optional
import bcrypt
from config import config
from models.user import User, UserRole, UserSession, GuestSession
from stores.user_store import UserStore
from services.email_service import EmailService
logger = logging.getLogger(__name__)
@dataclass
class AuthResult:
"""Result of an authentication operation."""
success: bool
user: Optional[User] = None
token: Optional[str] = None
expires_at: Optional[datetime] = None
error: Optional[str] = None
@dataclass
class RegistrationResult:
"""Result of a registration operation."""
success: bool
user: Optional[User] = None
requires_verification: bool = False
error: Optional[str] = None
class AuthService:
"""
Authentication service.
Handles all authentication business logic:
- User registration with optional email verification
- Login/logout with session management
- Password reset flow
- Guest-to-user conversion
- Account deletion (soft delete)
"""
def __init__(
self,
user_store: UserStore,
email_service: EmailService,
session_expiry_hours: int = 168,
require_email_verification: bool = False,
):
"""
Initialize auth service.
Args:
user_store: User persistence store.
email_service: Email sending service.
session_expiry_hours: Session lifetime in hours.
require_email_verification: Whether to require email verification.
"""
self.user_store = user_store
self.email_service = email_service
self.session_expiry_hours = session_expiry_hours
self.require_email_verification = require_email_verification
@classmethod
async def create(cls, user_store: UserStore) -> "AuthService":
"""
Create AuthService from config.
Args:
user_store: User persistence store.
"""
from services.email_service import get_email_service
return cls(
user_store=user_store,
email_service=get_email_service(),
session_expiry_hours=config.SESSION_EXPIRY_HOURS,
require_email_verification=config.REQUIRE_EMAIL_VERIFICATION,
)
# -------------------------------------------------------------------------
# Registration
# -------------------------------------------------------------------------
async def register(
self,
username: str,
password: str,
email: Optional[str] = None,
guest_id: Optional[str] = None,
) -> RegistrationResult:
"""
Register a new user account.
Args:
username: Desired username.
password: Plain text password.
email: Optional email address.
guest_id: Guest session ID if converting.
Returns:
RegistrationResult with user or error.
"""
# Validate inputs
if len(username) < 2 or len(username) > 50:
return RegistrationResult(success=False, error="Username must be 2-50 characters")
if len(password) < 8:
return RegistrationResult(success=False, error="Password must be at least 8 characters")
# Check for existing username
existing = await self.user_store.get_user_by_username(username)
if existing:
return RegistrationResult(success=False, error="Username already taken")
# Check for existing email
if email:
existing = await self.user_store.get_user_by_email(email)
if existing:
return RegistrationResult(success=False, error="Email already registered")
# Hash password
password_hash = self._hash_password(password)
# Generate verification token if needed
verification_token = None
verification_expires = None
if email and self.require_email_verification:
verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
# Create user
user = await self.user_store.create_user(
username=username,
password_hash=password_hash,
email=email,
role=UserRole.USER,
guest_id=guest_id,
verification_token=verification_token,
verification_expires=verification_expires,
)
if not user:
return RegistrationResult(success=False, error="Failed to create account")
# Mark guest as converted if applicable
if guest_id:
await self.user_store.mark_guest_converted(guest_id, user.id)
# Send verification email if needed
requires_verification = False
if email and self.require_email_verification and verification_token:
await self.email_service.send_verification_email(
to=email,
token=verification_token,
username=username,
)
await self.user_store.log_email(user.id, "verification", email)
requires_verification = True
return RegistrationResult(
success=True,
user=user,
requires_verification=requires_verification,
)
async def verify_email(self, token: str) -> AuthResult:
"""
Verify email with token.
Args:
token: Verification token from email.
Returns:
AuthResult with success status.
"""
user = await self.user_store.get_user_by_verification_token(token)
if not user:
return AuthResult(success=False, error="Invalid verification token")
# Check expiration
if user.verification_expires and user.verification_expires < datetime.now(timezone.utc):
return AuthResult(success=False, error="Verification token expired")
# Mark as verified
await self.user_store.clear_verification_token(user.id)
# Refresh user
user = await self.user_store.get_user_by_id(user.id)
return AuthResult(success=True, user=user)
async def resend_verification(self, email: str) -> bool:
"""
Resend verification email.
Args:
email: Email address to send to.
Returns:
True if email was sent.
"""
user = await self.user_store.get_user_by_email(email)
if not user or user.email_verified:
return False
# Generate new token
verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
await self.user_store.update_user(
user.id,
verification_token=verification_token,
verification_expires=verification_expires,
)
await self.email_service.send_verification_email(
to=email,
token=verification_token,
username=user.username,
)
await self.user_store.log_email(user.id, "verification", email)
return True
# -------------------------------------------------------------------------
# Login/Logout
# -------------------------------------------------------------------------
async def login(
self,
username: str,
password: str,
device_info: Optional[dict] = None,
ip_address: Optional[str] = None,
) -> AuthResult:
"""
Authenticate user and create session.
Args:
username: Username or email.
password: Plain text password.
device_info: Client device information.
ip_address: Client IP address.
Returns:
AuthResult with session token or error.
"""
# Try username first, then email
user = await self.user_store.get_user_by_username(username)
if not user:
user = await self.user_store.get_user_by_email(username)
if not user:
return AuthResult(success=False, error="Invalid credentials")
if not user.can_login():
return AuthResult(success=False, error="Account is disabled")
# Check email verification if required
if self.require_email_verification and user.email and not user.email_verified:
return AuthResult(success=False, error="Please verify your email first")
# Verify password
if not self._verify_password(password, user.password_hash):
return AuthResult(success=False, error="Invalid credentials")
# Create session
token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(hours=self.session_expiry_hours)
await self.user_store.create_session(
user_id=user.id,
token=token,
expires_at=expires_at,
device_info=device_info,
ip_address=ip_address,
)
# Update last login
await self.user_store.update_user(user.id, last_login=datetime.now(timezone.utc))
return AuthResult(
success=True,
user=user,
token=token,
expires_at=expires_at,
)
async def logout(self, token: str) -> bool:
"""
Invalidate a session.
Args:
token: Session token to invalidate.
Returns:
True if session was revoked.
"""
return await self.user_store.revoke_session_by_token(token)
async def logout_all(self, user_id: str, except_token: Optional[str] = None) -> int:
"""
Invalidate all sessions for a user.
Args:
user_id: User ID.
except_token: Optional token to keep active.
Returns:
Number of sessions revoked.
"""
return await self.user_store.revoke_all_sessions(user_id, except_token)
async def get_user_from_token(self, token: str) -> Optional[User]:
"""
Get user from session token.
Args:
token: Session token.
Returns:
User if valid session, None otherwise.
"""
session = await self.user_store.get_session_by_token(token)
if not session or not session.is_valid():
return None
# Update last used
await self.user_store.update_session_last_used(session.id)
user = await self.user_store.get_user_by_id(session.user_id)
if not user or not user.can_login():
return None
return user
# -------------------------------------------------------------------------
# Password Management
# -------------------------------------------------------------------------
async def forgot_password(self, email: str) -> bool:
"""
Initiate password reset flow.
Args:
email: Email address.
Returns:
True if reset email was sent (always returns True to prevent enumeration).
"""
user = await self.user_store.get_user_by_email(email)
if not user:
# Don't reveal if email exists
return True
# Generate reset token
reset_token = secrets.token_urlsafe(32)
reset_expires = datetime.now(timezone.utc) + timedelta(hours=1)
await self.user_store.update_user(
user.id,
reset_token=reset_token,
reset_expires=reset_expires,
)
await self.email_service.send_password_reset_email(
to=email,
token=reset_token,
username=user.username,
)
await self.user_store.log_email(user.id, "password_reset", email)
return True
async def reset_password(self, token: str, new_password: str) -> AuthResult:
"""
Reset password using token.
Args:
token: Reset token from email.
new_password: New password.
Returns:
AuthResult with success status.
"""
if len(new_password) < 8:
return AuthResult(success=False, error="Password must be at least 8 characters")
user = await self.user_store.get_user_by_reset_token(token)
if not user:
return AuthResult(success=False, error="Invalid reset token")
# Check expiration
if user.reset_expires and user.reset_expires < datetime.now(timezone.utc):
return AuthResult(success=False, error="Reset token expired")
# Update password
password_hash = self._hash_password(new_password)
await self.user_store.update_user(user.id, password_hash=password_hash)
await self.user_store.clear_reset_token(user.id)
# Revoke all sessions
await self.user_store.revoke_all_sessions(user.id)
# Send notification
if user.email:
await self.email_service.send_password_changed_notification(
to=user.email,
username=user.username,
)
await self.user_store.log_email(user.id, "password_changed", user.email)
return AuthResult(success=True, user=user)
async def change_password(
self,
user_id: str,
current_password: str,
new_password: str,
current_token: Optional[str] = None,
) -> AuthResult:
"""
Change password for authenticated user.
Args:
user_id: User ID.
current_password: Current password for verification.
new_password: New password.
current_token: Current session token to keep active.
Returns:
AuthResult with success status.
"""
if len(new_password) < 8:
return AuthResult(success=False, error="Password must be at least 8 characters")
user = await self.user_store.get_user_by_id(user_id)
if not user:
return AuthResult(success=False, error="User not found")
# Verify current password
if not self._verify_password(current_password, user.password_hash):
return AuthResult(success=False, error="Current password is incorrect")
# Update password
password_hash = self._hash_password(new_password)
await self.user_store.update_user(user.id, password_hash=password_hash)
# Revoke all sessions except current
await self.user_store.revoke_all_sessions(user.id, except_token=current_token)
# Send notification
if user.email:
await self.email_service.send_password_changed_notification(
to=user.email,
username=user.username,
)
await self.user_store.log_email(user.id, "password_changed", user.email)
return AuthResult(success=True, user=user)
# -------------------------------------------------------------------------
# User Profile
# -------------------------------------------------------------------------
async def update_preferences(self, user_id: str, preferences: dict) -> Optional[User]:
"""
Update user preferences.
Args:
user_id: User ID.
preferences: New preferences dict.
Returns:
Updated user or None.
"""
return await self.user_store.update_user(user_id, preferences=preferences)
async def get_sessions(self, user_id: str) -> list[UserSession]:
"""
Get all active sessions for a user.
Args:
user_id: User ID.
Returns:
List of active sessions.
"""
return await self.user_store.get_sessions_for_user(user_id)
async def revoke_session(self, user_id: str, session_id: str) -> bool:
"""
Revoke a specific session.
Args:
user_id: User ID (for authorization).
session_id: Session ID to revoke.
Returns:
True if session was revoked.
"""
# Verify session belongs to user
sessions = await self.user_store.get_sessions_for_user(user_id)
if not any(s.id == session_id for s in sessions):
return False
return await self.user_store.revoke_session(session_id)
# -------------------------------------------------------------------------
# Guest Conversion
# -------------------------------------------------------------------------
async def convert_guest(
self,
guest_id: str,
username: str,
password: str,
email: Optional[str] = None,
) -> RegistrationResult:
"""
Convert guest session to full user account.
Args:
guest_id: Guest session ID.
username: Desired username.
password: Password.
email: Optional email.
Returns:
RegistrationResult with user or error.
"""
# Verify guest exists and not already converted
guest = await self.user_store.get_guest_session(guest_id)
if not guest:
return RegistrationResult(success=False, error="Guest session not found")
if guest.is_converted():
return RegistrationResult(success=False, error="Guest already converted")
# Register with guest ID
return await self.register(
username=username,
password=password,
email=email,
guest_id=guest_id,
)
# -------------------------------------------------------------------------
# Account Deletion
# -------------------------------------------------------------------------
async def delete_account(self, user_id: str) -> bool:
"""
Soft delete user account.
Args:
user_id: User ID to delete.
Returns:
True if account was deleted.
"""
# Revoke all sessions
await self.user_store.revoke_all_sessions(user_id)
# Soft delete
user = await self.user_store.update_user(
user_id,
is_active=False,
deleted_at=datetime.now(timezone.utc),
)
return user is not None
# -------------------------------------------------------------------------
# Guest Sessions
# -------------------------------------------------------------------------
async def create_guest_session(
self,
guest_id: str,
display_name: Optional[str] = None,
) -> GuestSession:
"""
Create or get guest session.
Args:
guest_id: Guest session ID.
display_name: Display name for guest.
Returns:
GuestSession.
"""
existing = await self.user_store.get_guest_session(guest_id)
if existing:
await self.user_store.update_guest_last_seen(guest_id)
return existing
return await self.user_store.create_guest_session(guest_id, display_name)
# -------------------------------------------------------------------------
# Password Hashing
# -------------------------------------------------------------------------
def _hash_password(self, password: str) -> str:
"""Hash a password using bcrypt."""
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode(), salt)
return hashed.decode()
def _verify_password(self, password: str, password_hash: str) -> bool:
"""Verify a password against its hash."""
try:
return bcrypt.checkpw(password.encode(), password_hash.encode())
except Exception:
return False
# Global auth service instance
_auth_service: Optional[AuthService] = None
async def get_auth_service(user_store: UserStore) -> AuthService:
"""
Get or create the global auth service instance.
Args:
user_store: User persistence store.
Returns:
AuthService instance.
"""
global _auth_service
if _auth_service is None:
_auth_service = await AuthService.create(user_store)
return _auth_service
async def close_auth_service() -> None:
"""Close the global auth service."""
global _auth_service
_auth_service = None

View File

@@ -0,0 +1,215 @@
"""
Email service for Golf game authentication.
Provides email sending via Resend for verification, password reset, and notifications.
"""
import logging
from typing import Optional
from config import config
logger = logging.getLogger(__name__)
class EmailService:
"""
Email service using Resend API.
Handles all transactional emails for authentication:
- Email verification
- Password reset
- Password changed notification
"""
def __init__(self, api_key: str, from_address: str, base_url: str):
"""
Initialize email service.
Args:
api_key: Resend API key.
from_address: Sender email address.
base_url: Base URL for verification/reset links.
"""
self.api_key = api_key
self.from_address = from_address
self.base_url = base_url.rstrip("/")
self._client = None
@classmethod
def create(cls) -> "EmailService":
"""Create EmailService from config."""
return cls(
api_key=config.RESEND_API_KEY,
from_address=config.EMAIL_FROM,
base_url=config.BASE_URL,
)
@property
def client(self):
"""Lazy-load Resend client."""
if self._client is None:
try:
import resend
resend.api_key = self.api_key
self._client = resend
except ImportError:
logger.warning("resend package not installed, emails will be logged only")
self._client = None
return self._client
def is_configured(self) -> bool:
"""Check if email service is properly configured."""
return bool(self.api_key)
async def send_verification_email(
self,
to: str,
token: str,
username: str,
) -> Optional[str]:
"""
Send email verification email.
Args:
to: Recipient email address.
token: Verification token.
username: User's display name.
Returns:
Resend message ID if sent, None if not configured.
"""
if not self.is_configured():
logger.info(f"Email not configured. Would send verification to {to}")
return None
verify_url = f"{self.base_url}/verify-email?token={token}"
subject = "Verify your Golf Game account"
html = f"""
<h2>Welcome to Golf Game, {username}!</h2>
<p>Please verify your email address by clicking the link below:</p>
<p><a href="{verify_url}">Verify Email Address</a></p>
<p>Or copy and paste this URL into your browser:</p>
<p>{verify_url}</p>
<p>This link will expire in 24 hours.</p>
<p>If you didn't create this account, you can safely ignore this email.</p>
"""
return await self._send_email(to, subject, html)
async def send_password_reset_email(
self,
to: str,
token: str,
username: str,
) -> Optional[str]:
"""
Send password reset email.
Args:
to: Recipient email address.
token: Reset token.
username: User's display name.
Returns:
Resend message ID if sent, None if not configured.
"""
if not self.is_configured():
logger.info(f"Email not configured. Would send password reset to {to}")
return None
reset_url = f"{self.base_url}/reset-password?token={token}"
subject = "Reset your Golf Game password"
html = f"""
<h2>Password Reset Request</h2>
<p>Hi {username},</p>
<p>We received a request to reset your password. Click the link below to set a new password:</p>
<p><a href="{reset_url}">Reset Password</a></p>
<p>Or copy and paste this URL into your browser:</p>
<p>{reset_url}</p>
<p>This link will expire in 1 hour.</p>
<p>If you didn't request this, you can safely ignore this email. Your password will remain unchanged.</p>
"""
return await self._send_email(to, subject, html)
async def send_password_changed_notification(
self,
to: str,
username: str,
) -> Optional[str]:
"""
Send password changed notification email.
Args:
to: Recipient email address.
username: User's display name.
Returns:
Resend message ID if sent, None if not configured.
"""
if not self.is_configured():
logger.info(f"Email not configured. Would send password change notification to {to}")
return None
subject = "Your Golf Game password was changed"
html = f"""
<h2>Password Changed</h2>
<p>Hi {username},</p>
<p>Your password was successfully changed.</p>
<p>If you did not make this change, please contact support immediately.</p>
"""
return await self._send_email(to, subject, html)
async def _send_email(
self,
to: str,
subject: str,
html: str,
) -> Optional[str]:
"""
Send an email via Resend.
Args:
to: Recipient email address.
subject: Email subject.
html: HTML email body.
Returns:
Resend message ID if sent, None on error.
"""
if not self.client:
logger.warning(f"Resend not available. Email to {to}: {subject}")
return None
try:
params = {
"from": self.from_address,
"to": [to],
"subject": subject,
"html": html,
}
response = self.client.Emails.send(params)
message_id = response.get("id") if isinstance(response, dict) else getattr(response, "id", None)
logger.info(f"Email sent to {to}: {message_id}")
return message_id
except Exception as e:
logger.error(f"Failed to send email to {to}: {e}")
return None
# Global email service instance
_email_service: Optional[EmailService] = None
def get_email_service() -> EmailService:
"""Get or create the global email service instance."""
global _email_service
if _email_service is None:
_email_service = EmailService.create()
return _email_service

Some files were not shown because too many files have changed in this diff Show More