160 Commits
v3.1.3 ... main

Author SHA1 Message Date
adlee-was-taken
d5194f43ba docs(soak): full README + validation checklist
Replaces the Task 31 stub README with complete documentation:
quickstart, first-time setup (invite flagging, seeding, smoke),
usage examples for all three watch modes, CLI flag reference, env
var table, scenario descriptions, error handling summary, test
account filtering explanation, and architecture overview.

Adds CHECKLIST.md with post-deploy verification, bring-up,
scenario, watch mode, failure handling, and staging gate items.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:05:22 -04:00
adlee-was-taken
b9cc7d29cf Merge feat/soak-harness: multiplayer soak & UX test harness + v3.3.4
All checks were successful
Build & Deploy Staging / build-and-deploy (release) Successful in 24s
29 commits implementing a standalone Playwright-based soak runner
that drives 16 authenticated browser sessions across 4 concurrent
rooms. Two scenarios (populate + stress with chaos injection),
click-to-watch live video via CDP screencast, per-room failure
isolation, and test-account separation via is_test_account column.

Server-side: schema migration, register flow flagging, stats
include_test filter, admin panel badges + toggle.

Harness: tests/soak/ package with SessionPool, RoomCoordinator,
Watchdog, Dashboard (HTTP+WS), Screencaster, 27 Vitest tests.

Version bumped from v3.1.6 to v3.3.4.
2026-04-11 22:58:36 -04:00
adlee-was-taken
b8bc432175 feat(soak): artifacts, graceful shutdown, health probes, smoke script, v3.3.4
Batched remaining harness tasks (27-30, 33):

Task 27 — Artifact capture on failure: screenshots, HTML snapshots,
game state JSON, and console error tails are captured into
tests/soak/artifacts/<run-id>/ when a scenario throws. Successful
runs get a summary.json. Old runs (>7d) are pruned on startup.

Task 28 — Graceful shutdown: first SIGINT/SIGTERM flips the abort
signal (scenarios finish current turn then unwind). 10s after, a
hard-kill fires if cleanup hangs. Double Ctrl-C = immediate exit.
Exit codes: 0 success, 1 errors, 2 interrupted.

Task 29 — Periodic health probes: every 30s GET /health against the
target server. Three consecutive failures abort the run with
health_fatal, preventing staging outages from being misattributed
to harness bugs. Corrected endpoint from /api/health to /health
per server/routers/health.py.

Task 30 — Smoke test script: tests/soak/scripts/smoke.sh, a 60s
end-to-end canary that health-probes the target, seeds if needed,
and runs one minimal populate game.

Task 33 — Version bump to v3.3.4: both index.html footers (was
v3.1.6), new footer added to admin.html (had none), pyproject.toml.

Also fixes discovered during stress testing:
- SessionPool sets baseURL on all contexts so relative goto('/')
  resolves correctly between games (was "invalid URL" error)
- RoomCoordinator key is now unique per game-start (Date.now
  suffix) so Deferred promises don't carry stale room codes from
  previous games

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:57:15 -04:00
adlee-was-taken
d3b468575b feat(soak): per-room watchdog + heartbeat wiring + multi-game lobby fix
Watchdog class with 4 Vitest tests (27 total now), wired into
ctx.heartbeat in the runner. One watchdog per room with a 60s
timeout; firing logs an error, marks the room's dashboard tile
as errored, and triggers the abort signal so the scenario unwinds.
Watchdogs are explicitly stopped in the runner's finally block
so pending timers don't keep the node process alive on exit.

Also fixes a multi-game bug discovered during stress scenario
verification: after a game ends sessions stay parked on the
game_over screen, which hides the lobby and makes a subsequent
#create-room-btn click time out. runOneMultiplayerGame now
navigates every session to / before each game — localStorage
auth persists so nothing re-logs in.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:52:49 -04:00
adlee-was-taken
921a6ad984 feat(soak): stress scenario with chaos injection
Rapid short games with a parallel chaos loop that has a 5% per-turn
chance of firing one of:
  - rapid_clicks: 5 quick clicks at the player's own cards
  - tab_blur:    window blur/focus event pair
  - brief_offline: 300ms network outage via context.setOffline

Chaos counts roll up into ScenarioResult.customMetrics.chaos_fired.

Important detail: chaos loop has a 3-second initial delay so room
creation, joiners, and game start can complete without interference.
Chaos during lobby setup (especially brief_offline) was causing
#create-room-btn to go unstable.

Verified: stress smoke with --games-per-room=3, 4 accounts + 1 CPU,
first game completed with 37 turns and chaos events fired across all
three event types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:49:30 -04:00
adlee-was-taken
c307027dd0 feat(soak): --watch=tiled launches N headed host windows
SessionPool accepts headedHostCount; when > 0 it launches a second
Chromium in headed mode and creates the first N sessions in it.
Joiners (sessions N..count) stay headless in the main browser.

Each headed context gets a 960×900 viewport — tall enough to show
the full game table (deck + opponent row + own 2×3 card grid +
status area) without clipping. Horizontal tiling still fits two
windows side-by-side on a 1920-wide display.

window.moveTo is kept as a best-effort tile-placement hint, but
viewport from newContext() is what actually sizes the window
(window.resizeTo is a no-op on modern Chromium / Wayland).

Verified: 1-room tiled run plays a full game cleanly; 2-room
parallel tiled had one window get closed mid-run, which is
consistent with a user manually dismissing a window — tiled mode
is a best-effort hands-on debugging aid, not an automation mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:01:21 -04:00
adlee-was-taken
21fe53eaf7 feat(soak): click-to-watch live video via CDP screencast
Runner creates a Screencaster before sessions are acquired, then
wires its start/stop into DashboardServer.onStartStream / onStopStream
after sessions exist (the handlers close over a sessionsByKey map).
Clicking a player tile in the dashboard starts a CDP screencast on
that session's page, forwards JPEG frames as WS "frame" messages.
Closing the modal (or disconnecting the WS) stops all screencasts.

Verified end-to-end: programmatically connected WS, sent start_stream,
received 5 frames (13.7KB each), sent stop_stream, screencast_stopped
log line fired, run completed cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:54:52 -04:00
adlee-was-taken
34ce7d1d32 feat(soak): Screencaster — CDP Page.startScreencast wrapper
Attach/detach CDP sessions per Playwright Page, start/stop JPEG
screencasts with configurable quality and frame rate, forward each
frame to a callback. Used by the dashboard for click-to-watch
live video (wired in Task 23).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:06:17 -04:00
adlee-was-taken
796de876b7 feat(soak): wire --watch=dashboard in runner
Starts DashboardServer on port 7777 (or --dashboard-port), uses its
reporter as ctx.dashboard, auto-opens the URL via xdg-open/open/start.
Cleans up on exit. WS client connections logged as info events so
you can see when the browser attaches.

Verified: 2-account populate run with --watch=dashboard serves the
static page on :7777, accepts WS connections, cleanly shuts down
when the run completes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:05:44 -04:00
adlee-was-taken
a35e789eb9 feat(soak): dashboard status grid UI
Static HTML page served by DashboardServer. Renders the 2×2 room
grid with progress bars and player tiles, subscribes to WS events,
updates tiles live. Click-to-watch modal is wired but receives
frames once the CDP screencaster ships in Task 22.

Adds escapeHtml() on all user-controlled strings (roomId, player
key) — not strictly needed for our trusted bot traffic but cheap
XSS hardening against future scenarios that accept user input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:55:21 -04:00
adlee-was-taken
9d1d4f899b feat(soak): DashboardServer — vanilla http + ws
Serves one static HTML page, accepts WS connections, broadcasts
room_state/log/metric messages to all clients. Replays current
state to late-joining clients so refreshing the dashboard during
a run shows the right grid. Exposes a reporter() method that
returns a DashboardReporter scenarios can call without knowing
about sockets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:53:59 -04:00
adlee-was-taken
d1688aae0b feat(soak): runner.ts end-to-end with --watch=none
First full end-to-end milestone: parses CLI, builds SessionPool +
RoomCoordinator, loads a scenario by name, runs it, reports results,
cleans up. Watch modes other than "none" log a warning and fall back
until Tasks 19-24 implement them.

Smoke test passed against local dev:
  bun run soak -- --scenario=populate --accounts=2 --rooms=1
    --cpus-per-room=0 --games-per-room=1 --holes=1 --watch=none
  → Games completed: 1, Errors: 0, Duration: 78.2s, exit 0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:52:08 -04:00
adlee-was-taken
a6a276b509 fix(bot): GolfBot handles authenticated sessions + num-decks stepper
Two small fixes to tests/e2e/bot/golf-bot.ts needed to run the bot
from the soak harness with authenticated accounts:

1. createGame and joinGame now check whether #player-name is
   visible before filling it. Authenticated sessions hide that
   input (the server uses the logged-in username); guest sessions
   still fill it as before. Existing e2e tests behave identically
   since they register guests who always see the input.

2. startGame's 'decks' option was calling selectOption on
   #num-decks, which is a hidden input inside a stepper widget,
   not a <select>. Replaced with stepper-click logic that
   increments/decrements until the hidden input matches the
   target value.

End-to-end verified via the soak runner: 2 authenticated sessions
played a complete 1-hole game against local dev, 26 turns, exit 0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:52:07 -04:00
adlee-was-taken
6df81e6f8d feat(soak): CLI parsing + config precedence
parseArgs pulls --scenario/--rooms/--watch/etc from argv,
mergeConfig layers scenarioDefaults → env → CLI so CLI flags
always win. 12 Vitest unit tests cover both parse happy/edge
paths and the 4-way merge precedence matrix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:25:04 -04:00
adlee-was-taken
2c20b6c7b5 feat(soak): populate scenario + scenario registry
Partitions sessions into N rooms, runs gamesPerRoom games per room
in parallel via Promise.allSettled so a failure in one room never
unwinds the others. Errors roll up into ScenarioResult.errors.

Verified via tsx: listScenarios() returns [populate], getScenario()
resolves by name and returns undefined for unknown names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:23:56 -04:00
adlee-was-taken
722934bdf2 feat(soak): shared runOneMultiplayerGame helper
Encapsulates the host-creates/joiners-join/loop-until-done flow so
populate and stress scenarios don't duplicate it. Honors abort
signal and a max-duration timeout, heartbeats on every turn.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:23:00 -04:00
adlee-was-taken
2a86b3cc54 feat(soak): scripts/seed-accounts.ts CLI wrapper
Thin standalone entry for pre-seeding N accounts before the first
harness run. Wraps SessionPool.seed and writes .env.stresstest.

End-to-end verified: ran against local dev with --count=4, all 4
accounts landed in the DB with is_test_account=TRUE, cred file
written with correct format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:22:10 -04:00
adlee-was-taken
3bc0270eb9 feat(soak): SessionPool — seed, login, acquire contexts
Owns BrowserContexts, seeds via POST /api/auth/register with the
invite code on cold start, warm-starts via localStorage injection of
the cached JWT, falls back to POST /api/auth/login if the token is
rejected. Exposes acquire(n) for scenarios.

Infrastructure changes needed to import the real GolfBot class from
tests/e2e/bot/ without the Task-10 structural-interface workaround:
 - Add @playwright/test as devDep so value-imports in e2e/bot/*.ts
   resolve at runtime (Page/Locator/expect are pulled even as types)
 - Remove rootDir from tsconfig so TS follows cross-package imports;
   add a paths entry so TS can resolve @playwright/test from the soak
   package's node_modules when compiling files under tests/e2e/bot
 - Drop the local GolfBot structural interface + its placeholder
   GamePhase/StartGameOptions/TurnResult types; re-export the real
   class from types.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:19:39 -04:00
adlee-was-taken
066e482f06 feat(soak): structured JSONL logger with child contexts
Single file, no transport, writes one JSON line per call to stdout.
Child loggers inherit parent meta so scenarios can bind room/game
context once and forget about it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:12:27 -04:00
adlee-was-taken
02642840da feat(soak): RoomCoordinator with host→joiners handoff
Lazy Deferred per roomId with a timeout on await. Lets concurrent
joiner sessions block until their host announces the room code
without polling or page scraping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:11:05 -04:00
adlee-was-taken
1565046ab7 feat(soak): core types + Deferred primitive
Establishes the Scenario/Session/Logger/DashboardReporter contracts
the rest of the harness builds on. Deferred is the building block
for RoomCoordinator's host→joiners handoff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:09:36 -04:00
adlee-was-taken
5478a4299e feat(soak): scaffold tests/soak package
Placeholder runner, tsconfig with @bot alias to tests/e2e/bot,
gitignored .env.stresstest + artifacts. Real behavior follows
in Task 10 onward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:19:09 -04:00
adlee-was-taken
835a79cc0f docs: soak harness bring-up steps
Documents the one-time UPDATE invite_codes SET marks_as_test = TRUE
step required before running tests/soak against each environment,
schema verification queries, and the expected filter behavior post-run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:23:13 -04:00
adlee-was-taken
983518e93d feat(admin): visible Test/Test-seed badges + filter toggle
Users table shows [Test] next to soak-harness accounts, invite codes
list shows [Test-seed] next to codes that flag new accounts as test,
and a new "Include test accounts" checkbox lets admins hide bot
traffic from the user list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:20:09 -04:00
adlee-was-taken
917ef2a239 feat(server): admin users list surfaces is_test_account
UserDetails carries the new column, search_users selects and
optionally filters on it, and the /api/admin/users route accepts
?include_test=false to hide soak-harness accounts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:13:41 -04:00
adlee-was-taken
b5a25b4ae5 feat(server): stats queries support include_test filter
Leaderboard and rank queries take an optional include_test param
(default false). Real users never see soak-harness traffic unless
they explicitly opt in via ?include_test=true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:33:38 -04:00
adlee-was-taken
0891e6c979 feat(server): register flow flags accounts from test-seed invites
When a user registers with an invite_code whose marks_as_test=TRUE,
their users_v2.is_test_account is set to TRUE. Normal invite codes
and invite-less signups are unaffected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:25:00 -04:00
adlee-was-taken
1f20ac9535 feat(server): expose marks_as_test on InviteCode
Adds the field to the dataclass, SELECT list in get_invite_codes,
and a new get_invite_code_details helper that the register flow
will use to discover whether an invite should flag new accounts
as test accounts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:11:34 -04:00
adlee-was-taken
8e23adee14 feat(server): propagate is_test_account through User model & store
User dataclass, create_user, and all SELECT lists now round-trip the
new column. Value is always FALSE until Task 4 wires the register
flow to the invite code's marks_as_test flag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:01:53 -04:00
adlee-was-taken
3817566ed5 docs: rename test-account index to match users_v2 convention
Post-review fix for Task 1: code reviewer flagged that
idx_users_v2_is_test_account didn't match the idx_users_<suffix>
convention used by every other index in user_store.py. The
implementation commit (d163675) was amended to use
idx_users_test_account; this commit updates the plan and spec
docs so they stay in sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:52:55 -04:00
adlee-was-taken
d16367582c feat(server): add is_test_account + marks_as_test schema
New columns support separating soak-harness test traffic from real
user traffic in stats queries. Rebuilds leaderboard_overall matview
to include is_test_account so the fast path stays filterable.

Migration is idempotent via DO $$ / IF NOT EXISTS blocks inside
SCHEMA_SQL, which runs on every server startup — same mechanism
every existing post-v1 column migration uses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:52:18 -04:00
adlee-was-taken
e8051b256b docs(plan): harden soak-harness schema migration for deploy
Makes the deployment path explicit in Task 1: traces the existing
lifespan → get_user_store → initialize_schema → conn.execute(SCHEMA_SQL)
flow, notes that the DO $$/IF NOT EXISTS pattern is the same one
every post-v1 column migration uses, and explains why rollback is
safe (additive changes only).

Adds two new verification steps to Task 1:
 - Step 7: post-deploy psql checks against staging
 - Step 8: same against production

Adds a "Post-deploy schema verification" block to CHECKLIST.md so
the schema state is verified after every server restart against
each target environment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:40:28 -04:00
adlee-was-taken
cf916d7bc3 docs: implementation plan for multiplayer soak harness
33-task TDD plan across 11 phases implementing the soak & UX test
harness design. Server-side schema/filter/admin changes ship first
(independent), then the tests/soak/ TypeScript runner builds up
incrementally — first milestone is a --watch=none smoke run against
local dev after Task 18, then dashboard, live video, tiled mode,
stress scenario, failure handling, and staging bring-up. Final
task bumps project version to v3.3.4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:37:15 -04:00
adlee-was-taken
97036be319 docs: multiplayer soak & UX test harness design
Design for a standalone Playwright-based soak runner that drives 16
authenticated browser sessions across 4 concurrent rooms to populate
staging scoreboards and hunt stability bugs. Architected as a
pluggable scenario harness so future UX test scenarios (reconnect,
invite flow, admin workflows, mobile) slot in cleanly.

Also gitignores .superpowers/ (brainstorming session artifacts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:03:28 -04:00
adlee-was-taken
52d7118c33 Add favicon and prod deploy script
Some checks failed
Build & Deploy Staging / build-and-deploy (release) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:37:36 -04:00
adlee-was-taken
d7631ec671 Fix CI: remove checkout step, runner can't resolve gitea hostname
All checks were successful
Build & Deploy Staging / build-and-deploy (release) Successful in 1m27s
The build happens on the staging server via SSH, not in the runner
container, so checkout is unnecessary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:51:54 -04:00
adlee-was-taken
f6eeaed97d Fix CI/CD: use SSH-based build instead of Docker-in-Docker
Some checks failed
Build & Deploy Staging / build-and-deploy (release) Failing after 30s
act_runner doesn't reliably support docker/build-push-action.
Build the image on the staging server and push to registry from
there instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:49:35 -04:00
adlee-was-taken
ef54ac201a Add invite request system and Gitea Actions CI/CD pipeline
Some checks failed
Build & Deploy Staging / build (release) Waiting to run
Build & Deploy Staging / deploy (release) Has been cancelled
Invite request feature:
- Public form to request an invite when INVITE_REQUEST_ENABLED=true
- Stores requests in new invite_requests DB table
- Emails admins on new request, emails requester on approve/deny
- Admin panel tab to review, approve, and deny requests
- Approval auto-creates invite code and sends signup link

CI/CD pipeline:
- Build & push Docker image to Gitea registry on release
- Auto-deploy to staging with health check
- Manual workflow_dispatch for production deploys

Also includes client layout/sizing improvements for card grid
and opponent spacing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:38:52 -04:00
adlee-was-taken
0c0588f920 TUI: add double-escape quit and minor layout tweaks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:38:38 -04:00
adlee-was-taken
420928f11e Reconcile docker-compose.prod.yml drift with droplet (INC-0001/AI-10)
The droplet copy of this file had drifted from the repo across
multiple sessions. This commit brings the repo in line with what
is actually running in production.

Changes landing from the droplet:
- Add restart: unless-stopped to app, postgres, and redis services
  (the deploy.restart_policy block was swarm-only and silently
  ignored by compose v2; top-level restart: is the correct form)
- Drop the dead deploy.replicas: 1 and deploy.restart_policy:
  subfields from the app service (swarm-only noise)
- Remove the traefik: service block entirely; Traefik now runs as
  its own stack at /opt/traefik from the adlee-traefik repo
- Remove the letsencrypt: volume declaration (owned by adlee-traefik
  now via the external golfgame_letsencrypt volume)
- Change web network from "driver: bridge" to
  "name: traefik_web, external: true" so this stack attaches to the
  shared ingress network created by the traefik stack

This closes INC-0001/AI-10 and unblocks future deploys of this repo.

The other uncommitted client/*, tui_client/* changes in the working
tree are unrelated and intentionally left alone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:27:14 -04:00
adlee-was-taken
bc9445f06e Relicense project under GPL-3.0-or-later
Replaces the previous MIT license with GPL-3.0-or-later. Adds the full
GPL-3.0 license text at LICENSE, updates pyproject.toml metadata and
classifier, updates the README, and adds SPDX-License-Identifier headers
to all first-party server Python and client JavaScript sources.
Third-party anime.min.js is left untouched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:26:06 -04:00
adlee-was-taken
ea34ddf8e4 Fix swap animation stutter and remove 1s server-side dead delay
- Remove unused card_revealed broadcast + 1s asyncio.sleep in swap handler
  (client never handled this message, causing pure dead wait before game_state)
- Defer swap-out (opacity:0) on hand cards to onStart callback so overlay
  covers the card before hiding it — eliminates visual gap for all players
- Defer heldCardFloating visibility hide to onStart — held card stays visible
  until animation overlay replaces it
- Thread onStart callback through animateUnifiedSwap → _runUnifiedSwap

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

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

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

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

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

Also update YOUR TURN badge to cyan with suit symbols.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:28:28 -05:00
adlee-was-taken
3d02d739e5 Set prod log level default to WARNING
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:00:33 -05:00
adlee-was-taken
3ca52eb7d1 Bump version to 3.1.6, update docs
Update version across pyproject.toml, FastAPI app, HTML footers,
and V3.17 doc. Mark kicked-ball bug as resolved in docs.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:02:17 -05:00
adlee-was-taken
4dff1da875 Only trigger single-line mode at <=449px, shift everywhere else
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:59:51 -05:00
adlee-was-taken
8f21a40a6a Make logo-row inline on mobile for single-line header layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:58:28 -05:00
adlee-was-taken
0ae999aca6 Revert flex approach, use default translateX with max-width reset for mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:57:49 -05:00
adlee-was-taken
a87cd7f4b0 Use inline-flex column on h1 to left-align logo row with title
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:56:38 -05:00
adlee-was-taken
eb072dbfb4 Set logo-row shift to 750px breakpoint (mobile ends ~750px)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:54:10 -05:00
adlee-was-taken
4c16147ace Set logo-row shift breakpoint to 900px to match actual layout break
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:51:40 -05:00
adlee-was-taken
cac1e26bac Split the difference: logo-row shift at 600px breakpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:49:21 -05:00
adlee-was-taken
31dcb70fc8 Bump logo-row shift breakpoint to 768px so mobile stays centered
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:47:31 -05:00
adlee-was-taken
15339d390f Use min-width breakpoint for logo shift, tighten logo-title gap
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:45:38 -05:00
adlee-was-taken
c523b144f5 Tighten logo-golfer gap and shift row further left on landscape
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:40:52 -05:00
adlee-was-taken
0f3ae992f9 Wrap logo+golfer in .logo-row and translateX left on landscape
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:36:00 -05:00
adlee-was-taken
ce6b276c11 Increase logo left shift to -3.5rem to align with GolfCards text
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:33:16 -05:00
adlee-was-taken
231e666407 Fix logo shift direction: move left on landscape, not right
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:31:38 -05:00
adlee-was-taken
7842de3a96 Shift logo+golfer group right on landscape via margin-left
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:29:47 -05:00
adlee-was-taken
aab41c5413 Restore logo-golfer-ball order in header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:23:49 -05:00
adlee-was-taken
625320992e Move golfer emoji left of logo, make .club inline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:22:53 -05:00
adlee-was-taken
61713f28c8 Style GolfCards title with .club on second line
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:13:40 -05:00
adlee-was-taken
0eac6d443c Rename lobby title from Golf to GolfCards.Club
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:11:39 -05:00
adlee-was-taken
dc936d7e1c Add v3.1.5 footer with copyright to lobby and waiting room
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:08:39 -05:00
adlee-was-taken
1cdf1cf281 Tune round-end pause and reduce deck shake frequency
Cut lastPlayPause to 2s and increase shake interval by 80% (3s→5.4s)
so the draw/discard nudge feels less nagging.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:21:22 -05:00
adlee-was-taken
4c23f2b4a9 Increase mobile portrait opponent row gap to 9px
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:16:32 -05:00
144 changed files with 17598 additions and 400 deletions

View File

@@ -20,6 +20,24 @@ DEBUG=false
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL # Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Per-module log level overrides (optional)
# These override LOG_LEVEL for specific modules.
# LOG_LEVEL_GAME=DEBUG # Core game logic
# LOG_LEVEL_AI=DEBUG # AI decisions (very verbose at DEBUG)
# LOG_LEVEL_HANDLERS=DEBUG # WebSocket message handlers
# LOG_LEVEL_ROOM=DEBUG # Room/lobby management
# LOG_LEVEL_AUTH=DEBUG # Auth stack (auth, routers.auth, services.auth_service)
# LOG_LEVEL_STORES=DEBUG # Database/Redis operations
# --- Preset examples ---
# Staging (debug game logic, quiet everything else):
# LOG_LEVEL=INFO
# LOG_LEVEL_GAME=DEBUG
# LOG_LEVEL_AI=DEBUG
#
# Production (minimal logging):
# LOG_LEVEL=WARNING
# Environment name (development, staging, production) # Environment name (development, staging, production)
ENVIRONMENT=development ENVIRONMENT=development
@@ -57,6 +75,18 @@ SECRET_KEY=
# Enable invite-only mode (requires invitation to register) # Enable invite-only mode (requires invitation to register)
INVITE_ONLY=true INVITE_ONLY=true
# Allow visitors to request an invite from the login page (only relevant when INVITE_ONLY=true)
INVITE_REQUEST_ENABLED=false
# Metered open signups (public beta)
# 0 = disabled (invite-only enforced), -1 = unlimited, N = max open signups per day
# When set > 0, users can register without an invite code up to the daily limit.
# Invite codes always work regardless of this limit.
DAILY_OPEN_SIGNUPS=0
# Max signups per IP address per day (0 = unlimited)
DAILY_SIGNUPS_PER_IP=3
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true) # Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
# Remove these after first login! # Remove these after first login!
# BOOTSTRAP_ADMIN_USERNAME=admin # BOOTSTRAP_ADMIN_USERNAME=admin

View File

@@ -0,0 +1,50 @@
name: Deploy Production
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to deploy (e.g. v3.3.0)'
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -e
TAG="${{ github.event.inputs.tag }}"
IMAGE="git.adlee.work/alee/golfgame"
cd /opt/golfgame
# Pull the image that passed staging
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.adlee.work -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker pull "$IMAGE:$TAG"
docker tag "$IMAGE:$TAG" golfgame-app:latest
# Update code for compose/env changes
git fetch origin
git checkout "$TAG"
# Restart app
docker compose -f docker-compose.prod.yml up -d app
# Wait for healthy
echo "Waiting for health check..."
for i in $(seq 1 30); do
if docker compose -f docker-compose.prod.yml ps app | grep -q "healthy"; then
echo "Production deploy successful — $TAG"
exit 0
fi
sleep 2
done
echo "CRITICAL: app not healthy after 60s"
docker compose -f docker-compose.prod.yml logs --tail=30 app
exit 1

View File

@@ -0,0 +1,50 @@
name: Build & Deploy Staging
on:
release:
types: [published]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Build, push, and deploy to staging
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -e
TAG="${{ github.ref_name }}"
IMAGE="git.adlee.work/alee/golfgame"
cd /opt/golfgame
# Pull latest code and checkout the release tag
git fetch origin
git checkout "$TAG"
# Build the image
docker build -t "$IMAGE:$TAG" -t "$IMAGE:latest" -t golfgame-app:latest .
# Push to Gitea container registry
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.adlee.work -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker push "$IMAGE:$TAG"
docker push "$IMAGE:latest"
# Restart app (no --build, image already tagged)
docker compose -f docker-compose.staging.yml up -d app
# Wait for healthy
echo "Waiting for health check..."
for i in $(seq 1 30); do
if docker compose -f docker-compose.staging.yml ps app | grep -q "healthy"; then
echo "Staging deploy successful — $TAG"
exit 0
fi
sleep 2
done
echo "WARNING: app not healthy after 60s"
docker compose -f docker-compose.staging.yml logs --tail=20 app
exit 1

28
.gitignore vendored
View File

@@ -136,7 +136,31 @@ celerybeat.pid
# Environments # Environments
.env .env
.env.*
!.env.example
.envrc .envrc
# Private keys and certificates
*.pem
*.key
*.p12
*.pfx
*.jks
*.keystore
# Service credentials
credentials.json
service-account.json
*-credentials.json
# SSH keys
id_rsa
id_ecdsa
id_ed25519
# Other sensitive files
*.secrets
.htpasswd
.venv .venv
env/ env/
venv/ venv/
@@ -190,6 +214,7 @@ cython_debug/
# Claude Code # Claude Code
.claude/ .claude/
.superpowers/
# Virtualenv in project root # Virtualenv in project root
bin/ bin/
@@ -201,6 +226,9 @@ pyvenv.cfg
# Personal notes # Personal notes
lookfah.md lookfah.md
# Internal docs (deployment info, credentials references, etc.)
internal/
# Ruff stuff: # Ruff stuff:
.ruff_cache/ .ruff_cache/

304
.secrets.baseline Normal file
View File

@@ -0,0 +1,304 @@
{
"version": "1.5.0",
"plugins_used": [
{
"name": "ArtifactoryDetector"
},
{
"name": "AWSKeyDetector"
},
{
"name": "AzureStorageKeyDetector"
},
{
"name": "Base64HighEntropyString",
"limit": 4.5
},
{
"name": "BasicAuthDetector"
},
{
"name": "CloudantDetector"
},
{
"name": "DiscordBotTokenDetector"
},
{
"name": "GitHubTokenDetector"
},
{
"name": "GitLabTokenDetector"
},
{
"name": "HexHighEntropyString",
"limit": 3.0
},
{
"name": "IbmCloudIamDetector"
},
{
"name": "IbmCosHmacDetector"
},
{
"name": "IPPublicDetector"
},
{
"name": "JwtTokenDetector"
},
{
"name": "KeywordDetector",
"keyword_exclude": ""
},
{
"name": "MailchimpDetector"
},
{
"name": "NpmDetector"
},
{
"name": "OpenAIDetector"
},
{
"name": "PrivateKeyDetector"
},
{
"name": "PypiTokenDetector"
},
{
"name": "SendGridDetector"
},
{
"name": "SlackDetector"
},
{
"name": "SoftlayerDetector"
},
{
"name": "SquareOAuthDetector"
},
{
"name": "StripeDetector"
},
{
"name": "TelegramBotTokenDetector"
},
{
"name": "TwilioKeyDetector"
}
],
"filters_used": [
{
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
},
{
"path": "detect_secrets.filters.common.is_baseline_file",
"filename": ".secrets.baseline"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2
},
{
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
},
{
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
},
{
"path": "detect_secrets.filters.heuristic.is_lock_file"
},
{
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
},
{
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
},
{
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
},
{
"path": "detect_secrets.filters.heuristic.is_sequential_string"
},
{
"path": "detect_secrets.filters.heuristic.is_swagger_file"
},
{
"path": "detect_secrets.filters.heuristic.is_templated_secret"
},
{
"path": "detect_secrets.filters.regex.should_exclude_file",
"pattern": [
"\\.env\\.example$",
"server/\\.env\\.example$"
]
}
],
"results": {
"INSTALL.md": [
{
"type": "Secret Keyword",
"filename": "INSTALL.md",
"hashed_secret": "365e24291fd19bba10a0d8504c0ed90d5c8bef7f",
"is_verified": false,
"line_number": 75
},
{
"type": "Basic Auth Credentials",
"filename": "INSTALL.md",
"hashed_secret": "4f4944a7117fd2e95169da2b40af33b68a65a161",
"is_verified": false,
"line_number": 114
},
{
"type": "Secret Keyword",
"filename": "INSTALL.md",
"hashed_secret": "c35bdb821a941808a150db95d0f934f449bbff17",
"is_verified": false,
"line_number": 182
},
{
"type": "Basic Auth Credentials",
"filename": "INSTALL.md",
"hashed_secret": "c35bdb821a941808a150db95d0f934f449bbff17",
"is_verified": false,
"line_number": 225
},
{
"type": "Secret Keyword",
"filename": "INSTALL.md",
"hashed_secret": "001c1654cb8dff7c4ddb1ae6d2203d0dd15a6096",
"is_verified": false,
"line_number": 391
},
{
"type": "Secret Keyword",
"filename": "INSTALL.md",
"hashed_secret": "53fe8c55272f9c3ceebb5e6058788e8981a359cb",
"is_verified": false,
"line_number": 397
}
],
"docker-compose.dev.yml": [
{
"type": "Secret Keyword",
"filename": "docker-compose.dev.yml",
"hashed_secret": "4f4944a7117fd2e95169da2b40af33b68a65a161",
"is_verified": false,
"line_number": 44
}
],
"docs/v2/V2_BUILD_PLAN.md": [
{
"type": "Basic Auth Credentials",
"filename": "docs/v2/V2_BUILD_PLAN.md",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 301
}
],
"scripts/docker-build.sh": [
{
"type": "Basic Auth Credentials",
"filename": "scripts/docker-build.sh",
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
"is_verified": false,
"line_number": 40
}
],
"scripts/install.sh": [
{
"type": "Basic Auth Credentials",
"filename": "scripts/install.sh",
"hashed_secret": "4f4944a7117fd2e95169da2b40af33b68a65a161",
"is_verified": false,
"line_number": 156
},
{
"type": "Basic Auth Credentials",
"filename": "scripts/install.sh",
"hashed_secret": "7205a0abf00d1daec13c63ece029057c974795a9",
"is_verified": false,
"line_number": 267
}
],
"server/RULES.md": [
{
"type": "Secret Keyword",
"filename": "server/RULES.md",
"hashed_secret": "a6778f1880744bd1a342a8e3789135412d8f9da2",
"is_verified": false,
"line_number": 904
},
{
"type": "Secret Keyword",
"filename": "server/RULES.md",
"hashed_secret": "aafdc23870ecbcd3d557b6423a8982134e17927e",
"is_verified": false,
"line_number": 949
}
],
"server/config.py": [
{
"type": "Basic Auth Credentials",
"filename": "server/config.py",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 124
}
],
"server/game_analyzer.py": [
{
"type": "Basic Auth Credentials",
"filename": "server/game_analyzer.py",
"hashed_secret": "4f4944a7117fd2e95169da2b40af33b68a65a161",
"is_verified": false,
"line_number": 617
}
],
"server/test_auth.py": [
{
"type": "Secret Keyword",
"filename": "server/test_auth.py",
"hashed_secret": "cbfdac6008f9cab4083784cbd1874f76618d2a97",
"is_verified": false,
"line_number": 39
},
{
"type": "Secret Keyword",
"filename": "server/test_auth.py",
"hashed_secret": "f0578f1e7174b1a41c4ea8c6e17f7a8a3b88c92a",
"is_verified": false,
"line_number": 51
},
{
"type": "Secret Keyword",
"filename": "server/test_auth.py",
"hashed_secret": "8be52126a6fde450a7162a3651d589bb51e9579d",
"is_verified": false,
"line_number": 65
},
{
"type": "Secret Keyword",
"filename": "server/test_auth.py",
"hashed_secret": "74913f5cd5f61ec0bcfdb775414c2fb3d161b620",
"is_verified": false,
"line_number": 75
},
{
"type": "Secret Keyword",
"filename": "server/test_auth.py",
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
"is_verified": false,
"line_number": 92
},
{
"type": "Secret Keyword",
"filename": "server/test_auth.py",
"hashed_secret": "1e99b09f6eb835305555cc43c3e0768b1a39226b",
"is_verified": false,
"line_number": 104
}
]
},
"generated_at": "2026-04-05T13:26:03Z"
}

674
LICENSE Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -203,4 +203,4 @@ From testing (1000+ games):
## License ## License
MIT GPL-3.0-or-later — see [LICENSE](LICENSE) for the full text.

View File

@@ -37,6 +37,7 @@
<a href="#" data-panel="users" class="nav-link">Users</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="games" class="nav-link">Games</a>
<a href="#" data-panel="invites" class="nav-link">Invites</a> <a href="#" data-panel="invites" class="nav-link">Invites</a>
<a href="#" data-panel="invite-requests" class="nav-link">Requests</a>
<a href="#" data-panel="audit" class="nav-link">Audit Log</a> <a href="#" data-panel="audit" class="nav-link">Audit Log</a>
</div> </div>
<div class="nav-user"> <div class="nav-user">
@@ -113,6 +114,10 @@
<input type="checkbox" id="include-banned" checked> <input type="checkbox" id="include-banned" checked>
Include banned Include banned
</label> </label>
<label class="checkbox-label">
<input type="checkbox" id="include-test" checked>
Include test accounts
</label>
</div> </div>
<table id="users-table" class="data-table"> <table id="users-table" class="data-table">
<thead> <thead>
@@ -191,6 +196,35 @@
</table> </table>
</section> </section>
<!-- Invite Requests Panel -->
<section id="invite-requests-panel" class="panel hidden">
<h2>Invite Requests</h2>
<div class="panel-toolbar">
<div class="filter-bar">
<select id="request-status-filter">
<option value="pending">Pending</option>
<option value="">All</option>
<option value="approved">Approved</option>
<option value="denied">Denied</option>
</select>
<button id="request-filter-btn" class="btn">Filter</button>
</div>
</div>
<table id="invite-requests-table" class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Message</th>
<th>Submitted</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Audit Log Panel --> <!-- Audit Log Panel -->
<section id="audit-panel" class="panel hidden"> <section id="audit-panel" class="panel hidden">
<h2>Audit Log</h2> <h2>Audit Log</h2>
@@ -207,12 +241,15 @@
<option value="end_game">End Game</option> <option value="end_game">End Game</option>
<option value="create_invite">Create Invite</option> <option value="create_invite">Create Invite</option>
<option value="revoke_invite">Revoke Invite</option> <option value="revoke_invite">Revoke Invite</option>
<option value="approve_invite_request">Approve Request</option>
<option value="deny_invite_request">Deny Request</option>
</select> </select>
<select id="audit-target-filter"> <select id="audit-target-filter">
<option value="">All Targets</option> <option value="">All Targets</option>
<option value="user">Users</option> <option value="user">Users</option>
<option value="game">Games</option> <option value="game">Games</option>
<option value="invite_code">Invites</option> <option value="invite_code">Invites</option>
<option value="invite_request">Invite Requests</option>
</select> </select>
<button id="audit-filter-btn" class="btn">Filter</button> <button id="audit-filter-btn" class="btn">Filter</button>
</div> </div>
@@ -363,6 +400,8 @@
<!-- Toast Container --> <!-- Toast Container -->
<div id="toast-container"></div> <div id="toast-container"></div>
<footer class="app-footer" style="text-align: center; padding: 16px; color: #888; font-size: 12px;">v3.3.4 &copy; Aaron D. Lee</footer>
<script src="admin.js"></script> <script src="admin.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/** /**
* Golf Admin Dashboard * Golf Admin Dashboard
* JavaScript for admin interface functionality * JavaScript for admin interface functionality
@@ -66,12 +67,13 @@ async function getStats() {
return apiRequest('/api/admin/stats'); return apiRequest('/api/admin/stats');
} }
async function getUsers(query = '', offset = 0, includeBanned = true) { async function getUsers(query = '', offset = 0, includeBanned = true, includeTest = true) {
const params = new URLSearchParams({ const params = new URLSearchParams({
query, query,
offset, offset,
limit: PAGE_SIZE, limit: PAGE_SIZE,
include_banned: includeBanned, include_banned: includeBanned,
include_test: includeTest,
}); });
return apiRequest(`/api/admin/users?${params}`); return apiRequest(`/api/admin/users?${params}`);
} }
@@ -197,6 +199,9 @@ function showPanel(panelId) {
case 'invites': case 'invites':
loadInvites(); loadInvites();
break; break;
case 'invite-requests':
loadInviteRequests();
break;
case 'audit': case 'audit':
loadAuditLog(); loadAuditLog();
break; break;
@@ -302,15 +307,19 @@ async function loadUsers() {
try { try {
const query = document.getElementById('user-search').value; const query = document.getElementById('user-search').value;
const includeBanned = document.getElementById('include-banned').checked; const includeBanned = document.getElementById('include-banned').checked;
const data = await getUsers(query, usersPage * PAGE_SIZE, includeBanned); const includeTest = document.getElementById('include-test').checked;
const data = await getUsers(query, usersPage * PAGE_SIZE, includeBanned, includeTest);
const tbody = document.querySelector('#users-table tbody'); const tbody = document.querySelector('#users-table tbody');
tbody.innerHTML = ''; tbody.innerHTML = '';
data.users.forEach(user => { data.users.forEach(user => {
const testBadge = user.is_test_account
? ' <span class="badge badge-info" title="Soak harness test account">Test</span>'
: '';
tbody.innerHTML += ` tbody.innerHTML += `
<tr> <tr>
<td>${escapeHtml(user.username)}</td> <td>${escapeHtml(user.username)}${testBadge}</td>
<td>${escapeHtml(user.email || '-')}</td> <td>${escapeHtml(user.email || '-')}</td>
<td><span class="badge badge-${user.role === 'admin' ? 'info' : 'muted'}">${user.role}</span></td> <td><span class="badge badge-${user.role === 'admin' ? 'info' : 'muted'}">${user.role}</span></td>
<td>${getStatusBadge(user)}</td> <td>${getStatusBadge(user)}</td>
@@ -443,10 +452,13 @@ async function loadInvites() {
: invite.remaining_uses <= 0 : invite.remaining_uses <= 0
? '<span class="badge badge-warning">Used Up</span>' ? '<span class="badge badge-warning">Used Up</span>'
: '<span class="badge badge-success">Active</span>'; : '<span class="badge badge-success">Active</span>';
const testSeedBadge = invite.marks_as_test
? ' <span class="badge badge-info" title="Creates test accounts">Test-seed</span>'
: '';
tbody.innerHTML += ` tbody.innerHTML += `
<tr> <tr>
<td><code>${escapeHtml(invite.code)}</code></td> <td><code>${escapeHtml(invite.code)}</code>${testSeedBadge}</td>
<td>${invite.use_count} / ${invite.max_uses}</td> <td>${invite.use_count} / ${invite.max_uses}</td>
<td>${invite.remaining_uses}</td> <td>${invite.remaining_uses}</td>
<td>${escapeHtml(invite.created_by_username)}</td> <td>${escapeHtml(invite.created_by_username)}</td>
@@ -642,6 +654,80 @@ async function promptRevokeInvite(code) {
} }
} }
// =============================================================================
// Invite Requests
// =============================================================================
async function loadInviteRequests() {
const status = document.getElementById('request-status-filter').value;
const params = new URLSearchParams();
if (status) params.set('status', status);
try {
const data = await apiRequest(`/api/admin/invite-requests?${params}`);
const tbody = document.querySelector('#invite-requests-table tbody');
tbody.innerHTML = '';
if (data.requests.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">No invite requests</td></tr>';
return;
}
data.requests.forEach(req => {
const statusBadge = req.status === 'approved'
? '<span class="badge badge-success">Approved</span>'
: req.status === 'denied'
? '<span class="badge badge-danger">Denied</span>'
: '<span class="badge badge-warning">Pending</span>';
const actions = req.status === 'pending'
? `<button class="btn btn-small btn-primary" data-action="approve-request" data-id="${req.id}">Approve</button>
<button class="btn btn-small btn-danger" data-action="deny-request" data-id="${req.id}">Deny</button>`
: `<span class="text-muted">${req.reviewed_by_username || '-'}</span>`;
tbody.innerHTML += `
<tr>
<td>${escapeHtml(req.name)}</td>
<td>${escapeHtml(req.email)}</td>
<td>${req.message ? escapeHtml(req.message).substring(0, 80) : '<span class="text-muted">-</span>'}</td>
<td>${formatDate(req.created_at)}</td>
<td>${statusBadge}</td>
<td>${actions}</td>
</tr>`;
});
} catch (error) {
showToast('Failed to load invite requests: ' + error.message, 'error');
}
}
async function handleApproveRequest(requestId) {
if (!confirm('Approve this invite request? An invite code will be created and emailed to the requester.')) return;
try {
const data = await apiRequest(`/api/admin/invite-requests/${requestId}/approve`, {
method: 'POST',
});
showToast(`Request approved! Invite code: ${data.code}`, 'success');
loadInviteRequests();
} catch (error) {
showToast('Failed to approve request: ' + error.message, 'error');
}
}
async function handleDenyRequest(requestId) {
if (!confirm('Deny this invite request? The requester will be notified.')) return;
try {
await apiRequest(`/api/admin/invite-requests/${requestId}/deny`, {
method: 'POST',
});
showToast('Request denied', 'success');
loadInviteRequests();
} catch (error) {
showToast('Failed to deny request: ' + error.message, 'error');
}
}
// ============================================================================= // =============================================================================
// Auth // Auth
// ============================================================================= // =============================================================================
@@ -749,6 +835,10 @@ document.addEventListener('DOMContentLoaded', () => {
usersPage = 0; usersPage = 0;
loadUsers(); loadUsers();
}); });
document.getElementById('include-test').addEventListener('change', () => {
usersPage = 0;
loadUsers();
});
document.getElementById('users-prev').addEventListener('click', () => { document.getElementById('users-prev').addEventListener('click', () => {
if (usersPage > 0) { if (usersPage > 0) {
usersPage--; usersPage--;
@@ -785,6 +875,9 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('create-invite-btn').addEventListener('click', handleCreateInvite); document.getElementById('create-invite-btn').addEventListener('click', handleCreateInvite);
document.getElementById('include-expired').addEventListener('change', loadInvites); document.getElementById('include-expired').addEventListener('change', loadInvites);
// Invite requests panel
document.getElementById('request-filter-btn').addEventListener('click', loadInviteRequests);
// Audit panel // Audit panel
document.getElementById('audit-filter-btn').addEventListener('click', () => { document.getElementById('audit-filter-btn').addEventListener('click', () => {
auditPage = 0; auditPage = 0;
@@ -825,6 +918,8 @@ document.addEventListener('DOMContentLoaded', () => {
else if (action === 'end-game') promptEndGame(btn.dataset.id); else if (action === 'end-game') promptEndGame(btn.dataset.id);
else if (action === 'copy-invite') copyInviteLink(btn.dataset.code); else if (action === 'copy-invite') copyInviteLink(btn.dataset.code);
else if (action === 'revoke-invite') promptRevokeInvite(btn.dataset.code); else if (action === 'revoke-invite') promptRevokeInvite(btn.dataset.code);
else if (action === 'approve-request') handleApproveRequest(parseInt(btn.dataset.id));
else if (action === 'deny-request') handleDenyRequest(parseInt(btn.dataset.id));
}); });
// Check auth on load // Check auth on load

View File

@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// AnimationQueue - Sequences card animations properly // AnimationQueue - Sequences card animations properly
// Ensures animations play in order without overlap // Ensures animations play in order without overlap
@@ -31,14 +32,17 @@ class AnimationQueue {
}; };
} }
// Add movements to the queue and start processing // Add movements to the queue and start processing.
// The onComplete callback only fires after the LAST movement in this batch —
// intermediate movements don't trigger it. This is intentional: callers want
// to know when the whole sequence is done, not each individual step.
async enqueue(movements, onComplete) { async enqueue(movements, onComplete) {
if (!movements || movements.length === 0) { if (!movements || movements.length === 0) {
if (onComplete) onComplete(); if (onComplete) onComplete();
return; return;
} }
// Add completion callback to last movement // Attach callback to last movement only
const movementsWithCallback = movements.map((m, i) => ({ const movementsWithCallback = movements.map((m, i) => ({
...m, ...m,
onComplete: i === movements.length - 1 ? onComplete : null onComplete: i === movements.length - 1 ? onComplete : null
@@ -185,7 +189,9 @@ class AnimationQueue {
await this.delay(this.timing.flipDuration); await this.delay(this.timing.flipDuration);
} }
// Step 2: Quick crossfade swap // Step 2: Quick crossfade swap.
// 150ms is short enough to feel instant but long enough for the eye to
// register the transition. Shorter looks like a glitch, longer looks laggy.
handCard.classList.add('fade-out'); handCard.classList.add('fade-out');
heldCard.classList.add('fade-out'); heldCard.classList.add('fade-out');
await this.delay(150); await this.delay(150);

View File

@@ -30,7 +30,14 @@ class GolfGame {
this.soundEnabled = true; this.soundEnabled = true;
this.audioCtx = null; this.audioCtx = null;
// Swap animation state // --- Animation coordination flags ---
// These flags form a system: they block renderGame() from touching the discard pile
// while an animation is in flight. If any flag gets stuck true, the discard pile
// freezes and the UI looks broken. Every flag MUST be cleared in every code path:
// animation callbacks, error handlers, fallbacks, and the `your_turn` safety net.
// If you're debugging a frozen discard pile, check these first.
// Swap animation state — local player's swap defers state updates until animation completes
this.swapAnimationInProgress = false; this.swapAnimationInProgress = false;
this.swapAnimationCardEl = null; this.swapAnimationCardEl = null;
this.swapAnimationFront = null; this.swapAnimationFront = null;
@@ -44,19 +51,19 @@ class GolfGame {
// Animation lock - prevent overlapping animations on same elements // Animation lock - prevent overlapping animations on same elements
this.animatingPositions = new Set(); this.animatingPositions = new Set();
// Track opponent swap animation in progress (to apply swap-out class after render) // Blocks discard update: opponent swap animation in progress
this.opponentSwapAnimation = null; // { playerId, position } this.opponentSwapAnimation = null; // { playerId, position }
// Track draw pulse animation in progress (defer held card display until pulse completes) // Blocks held card display: draw pulse animation hasn't finished yet
this.drawPulseAnimation = false; this.drawPulseAnimation = false;
// Track local discard animation in progress (prevent renderGame from updating discard) // Blocks discard update: local player discarding drawn card to pile
this.localDiscardAnimating = false; this.localDiscardAnimating = false;
// Track opponent discard animation in progress (prevent renderGame from updating discard) // Blocks discard update: opponent discarding without swap
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
// Track deal animation in progress (suppress flip prompts until dealing complete) // Blocks discard update + suppresses flip prompts: deal animation in progress
this.dealAnimationInProgress = false; this.dealAnimationInProgress = false;
// Track round winners for visual highlight // Track round winners for visual highlight
@@ -74,6 +81,7 @@ class GolfGame {
this.initCardTooltips(); this.initCardTooltips();
this.bindEvents(); this.bindEvents();
this.initMobileDetection(); this.initMobileDetection();
this.initDesktopScorecard();
this.checkUrlParams(); this.checkUrlParams();
} }
@@ -104,9 +112,11 @@ class GolfGame {
this.isMobile = e.matches; this.isMobile = e.matches;
document.body.classList.toggle('mobile-portrait', e.matches); document.body.classList.toggle('mobile-portrait', e.matches);
setAppHeight(); setAppHeight();
// Close any open drawers on layout change // Close any open drawers/overlays on layout change
if (!e.matches) { if (!e.matches) {
this.closeDrawers(); this.closeDrawers();
} else {
this.closeDesktopScorecard();
} }
}; };
mql.addEventListener('change', update); mql.addEventListener('change', update);
@@ -147,6 +157,31 @@ class GolfGame {
if (bottomBar) bottomBar.classList.remove('hidden'); if (bottomBar) bottomBar.classList.remove('hidden');
} }
initDesktopScorecard() {
if (!this.desktopScorecardBtn) return;
this.desktopScorecardBtn.addEventListener('click', () => {
const isOpen = this.desktopScorecardOverlay.classList.contains('open');
if (isOpen) {
this.closeDesktopScorecard();
} else {
this.desktopScorecardOverlay.classList.add('open');
this.desktopScorecardBtn.classList.add('active');
this.desktopScorecardBackdrop.classList.add('visible');
}
});
this.desktopScorecardBackdrop.addEventListener('click', () => {
this.closeDesktopScorecard();
});
}
closeDesktopScorecard() {
if (this.desktopScorecardOverlay) this.desktopScorecardOverlay.classList.remove('open');
if (this.desktopScorecardBtn) this.desktopScorecardBtn.classList.remove('active');
if (this.desktopScorecardBackdrop) this.desktopScorecardBackdrop.classList.remove('visible');
}
initAudio() { initAudio() {
// Initialize audio context on first user interaction // Initialize audio context on first user interaction
const initCtx = () => { const initCtx = () => {
@@ -379,7 +414,7 @@ class GolfGame {
// Only show tooltips on your turn // Only show tooltips on your turn
if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return; if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return;
const value = this.getCardPointValue(cardData); const value = this.getCardPointValueForTooltip(cardData);
const special = this.getCardSpecialNote(cardData); const special = this.getCardSpecialNote(cardData);
let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`; let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`;
@@ -409,14 +444,15 @@ class GolfGame {
if (this.tooltip) this.tooltip.classList.add('hidden'); if (this.tooltip) this.tooltip.classList.add('hidden');
} }
getCardPointValue(cardData) { getCardPointValueForTooltip(cardData) {
const values = this.gameState?.card_values || this.getDefaultCardValues(); const values = this.gameState?.card_values || this.getDefaultCardValues();
return values[cardData.rank] ?? 0; const rules = this.gameState?.scoring_rules || {};
return this.getCardPointValue(cardData, values, rules);
} }
getCardSpecialNote(cardData) { getCardSpecialNote(cardData) {
const rank = cardData.rank; const rank = cardData.rank;
const value = this.getCardPointValue(cardData); const value = this.getCardPointValueForTooltip(cardData);
if (value < 0) return 'Negative - keep it!'; if (value < 0) return 'Negative - keep it!';
if (rank === 'K' && value === 0) return 'Safe card'; if (rank === 'K' && value === 0) return 'Safe card';
if (rank === 'K' && value === -2) return 'Super King!'; if (rank === 'K' && value === -2) return 'Super King!';
@@ -536,6 +572,13 @@ class GolfGame {
this.gameUsername = document.getElementById('game-username'); this.gameUsername = document.getElementById('game-username');
this.gameLogoutBtn = document.getElementById('game-logout-btn'); this.gameLogoutBtn = document.getElementById('game-logout-btn');
this.authBar = document.getElementById('auth-bar'); this.authBar = document.getElementById('auth-bar');
// Desktop scorecard overlay elements
this.desktopScorecardBtn = document.getElementById('desktop-scorecard-btn');
this.desktopScorecardOverlay = document.getElementById('desktop-scorecard-overlay');
this.desktopScorecardBackdrop = document.getElementById('desktop-scorecard-backdrop');
this.desktopStandingsList = document.getElementById('desktop-standings-list');
this.desktopScoreTable = document.getElementById('desktop-score-table')?.querySelector('tbody');
} }
bindEvents() { bindEvents() {
@@ -817,16 +860,39 @@ class GolfGame {
hasDrawn: newState.has_drawn_card hasDrawn: newState.has_drawn_card
}); });
// V3_03: Intercept round_over transition to defer card reveals // V3_03: Intercept round_over transition to defer card reveals.
// The problem: the last turn's swap animation flips a card, and then
// the round-end reveal animation would flip it again. We snapshot the
// old state, patch it to mark the swap position as already face-up,
// and use that as the "before" for the reveal animation.
const roundJustEnded = oldState?.phase !== 'round_over' && const roundJustEnded = oldState?.phase !== 'round_over' &&
newState.phase === 'round_over'; newState.phase === 'round_over';
if (roundJustEnded && oldState) { if (roundJustEnded && oldState) {
// Save pre-reveal state for the reveal animation // Update state first so animations can read new card data
this.preRevealState = JSON.parse(JSON.stringify(oldState));
this.postRevealState = newState;
// Update state but DON'T render yet - reveal animation will handle it
this.gameState = newState; this.gameState = newState;
// Fire animations for the last turn (swap/discard) before deferring
try {
this.triggerAnimationsForStateChange(oldState, newState);
} catch (e) {
console.error('Animation error on round end:', e);
}
// Build preRevealState from oldState, but mark swap position as
// already handled so reveal animation doesn't double-flip it.
// Without this patch, the card visually flips twice in a row.
const preReveal = JSON.parse(JSON.stringify(oldState));
if (this.opponentSwapAnimation) {
const { playerId, position } = this.opponentSwapAnimation;
const player = preReveal.players.find(p => p.id === playerId);
if (player?.cards[position]) {
player.cards[position].face_up = true;
}
}
this.preRevealState = preReveal;
this.postRevealState = newState;
break; break;
} }
@@ -923,16 +989,16 @@ class GolfGame {
this.displayHeldCard(data.card, true); this.displayHeldCard(data.card, true);
this.renderGame(); this.renderGame();
} }
this.showToast('Swap with a card or discard', '', 3000); this.showToast('Swap with a card or discard', 'your-turn', 3000);
break; break;
case 'can_flip': case 'can_flip':
this.waitingForFlip = true; this.waitingForFlip = true;
this.flipIsOptional = data.optional || false; this.flipIsOptional = data.optional || false;
if (this.flipIsOptional) { if (this.flipIsOptional) {
this.showToast('Flip a card or skip', '', 3000); this.showToast('Flip a card or skip', 'your-turn', 3000);
} else { } else {
this.showToast('Flip a face-down card', '', 3000); this.showToast('Flip a face-down card', 'your-turn', 3000);
} }
this.renderGame(); this.renderGame();
break; break;
@@ -950,7 +1016,7 @@ class GolfGame {
// Host ended the game or player was kicked // Host ended the game or player was kicked
this._intentionalClose = true; this._intentionalClose = true;
if (this.ws) this.ws.close(); if (this.ws) this.ws.close();
this.showScreen('lobby'); this.showLobby();
if (data.reason) { if (data.reason) {
this.showError(data.reason); this.showError(data.reason);
} }
@@ -975,7 +1041,7 @@ class GolfGame {
case 'queue_left': case 'queue_left':
this.stopMatchmakingTimer(); this.stopMatchmakingTimer();
this.showScreen('lobby'); this.showLobby();
break; break;
case 'error': case 'error':
@@ -995,7 +1061,7 @@ class GolfGame {
cancelMatchmaking() { cancelMatchmaking() {
this.send({ type: 'queue_leave' }); this.send({ type: 'queue_leave' });
this.stopMatchmakingTimer(); this.stopMatchmakingTimer();
this.showScreen('lobby'); this.showLobby();
} }
startMatchmakingTimer() { startMatchmakingTimer() {
@@ -1313,14 +1379,16 @@ class GolfGame {
this.heldCardFloating.classList.add('hidden'); this.heldCardFloating.classList.add('hidden');
this.heldCardFloating.style.cssText = ''; this.heldCardFloating.style.cssText = '';
// Pre-emptively skip the flip animation - the server may broadcast the new state // Three-part race guard. All three are needed, and they protect different things:
// before our animation completes, and we don't want renderGame() to trigger // 1. skipNextDiscardFlip: prevents the CSS flip-in animation from firing
// the flip-in animation (which starts with opacity: 0, causing a flash) // (it starts at opacity:0, which causes a visible flash)
// 2. lastDiscardKey: prevents renderGame() from detecting a "change" to the
// discard pile and re-rendering it mid-animation
// 3. localDiscardAnimating: blocks renderGame() from touching the discard DOM
// entirely until our animation callback fires
// Remove any one of these and you get a different flavor of visual glitch.
this.skipNextDiscardFlip = true; this.skipNextDiscardFlip = true;
// Also update lastDiscardKey so renderGame() won't see a "change"
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`; this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
// Block renderGame from updating discard during animation (prevents race condition)
this.localDiscardAnimating = true; this.localDiscardAnimating = true;
// Animate held card to discard using anime.js // Animate held card to discard using anime.js
@@ -1431,11 +1499,8 @@ class GolfGame {
this.swapAnimationCardEl = handCardEl; this.swapAnimationCardEl = handCardEl;
this.swapAnimationHandCardEl = handCardEl; this.swapAnimationHandCardEl = handCardEl;
// Hide originals during animation // Hide discard button during animation (held card hidden later by onStart)
handCardEl.classList.add('swap-out'); this.discardBtn.classList.add('hidden');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
// Store drawn card data before clearing // Store drawn card data before clearing
const drawnCardData = this.drawnCard; const drawnCardData = this.drawnCard;
@@ -1458,6 +1523,12 @@ class GolfGame {
{ {
rotation: 0, rotation: 0,
wasHandFaceDown: false, wasHandFaceDown: false,
onStart: () => {
handCardEl.classList.add('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
},
onComplete: () => { onComplete: () => {
handCardEl.classList.remove('swap-out'); handCardEl.classList.remove('swap-out');
if (this.heldCardFloating) { if (this.heldCardFloating) {
@@ -1521,6 +1592,12 @@ class GolfGame {
{ {
rotation: 0, rotation: 0,
wasHandFaceDown: true, wasHandFaceDown: true,
onStart: () => {
if (handCardEl) handCardEl.classList.add('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
},
onComplete: () => { onComplete: () => {
if (handCardEl) handCardEl.classList.remove('swap-out'); if (handCardEl) handCardEl.classList.remove('swap-out');
if (this.heldCardFloating) { if (this.heldCardFloating) {
@@ -1574,12 +1651,28 @@ class GolfGame {
if (this.pendingGameState) { if (this.pendingGameState) {
const oldState = this.gameState; const oldState = this.gameState;
this.gameState = this.pendingGameState; const newState = this.pendingGameState;
this.pendingGameState = null; this.pendingGameState = null;
this.checkForNewPairs(oldState, this.gameState);
// Check if the deferred state is a round_over transition
const roundJustEnded = oldState?.phase !== 'round_over' &&
newState.phase === 'round_over';
if (roundJustEnded && oldState) {
// Same intercept as the game_state handler: store pre/post
// reveal states so runRoundEndReveal can animate the reveal
this.gameState = newState;
const preReveal = JSON.parse(JSON.stringify(oldState));
this.preRevealState = preReveal;
this.postRevealState = newState;
// Don't renderGame - let the reveal sequence handle it
} else {
this.gameState = newState;
this.checkForNewPairs(oldState, newState);
this.renderGame(); this.renderGame();
} }
} }
}
flipCard(position) { flipCard(position) {
this.send({ type: 'flip_card', position }); this.send({ type: 'flip_card', position });
@@ -2063,6 +2156,16 @@ class GolfGame {
async runRoundEndReveal(scores, rankings) { async runRoundEndReveal(scores, rankings) {
const T = window.TIMING?.reveal || {}; const T = window.TIMING?.reveal || {};
// preRevealState may not be set yet if the game_state was deferred
// (e.g., local swap animation was in progress). Wait briefly for it.
if (!this.preRevealState) {
const waitStart = Date.now();
while (!this.preRevealState && Date.now() - waitStart < 3000) {
await this.delay(100);
}
}
const oldState = this.preRevealState; const oldState = this.preRevealState;
const newState = this.postRevealState || this.gameState; const newState = this.postRevealState || this.gameState;
@@ -2072,22 +2175,35 @@ class GolfGame {
return; return;
} }
// First, render the game with the OLD state (pre-reveal) so cards show face-down // Compute what needs revealing (before renderGame changes the DOM)
this.gameState = newState;
// But render with pre-reveal card visuals
this.revealAnimationInProgress = true;
// Render game to show current layout (opponents, etc)
this.renderGame();
// Compute what needs revealing
const revealsByPlayer = this.getCardsToReveal(oldState, newState); const revealsByPlayer = this.getCardsToReveal(oldState, newState);
// Get reveal order: knocker first, then clockwise // Get reveal order: knocker first, then clockwise
const knockerId = newState.finisher_id; const knockerId = newState.finisher_id;
const revealOrder = this.getRevealOrder(newState.players, knockerId); const revealOrder = this.getRevealOrder(newState.players, knockerId);
// Initial pause // Wait for the last player's animation (swap/discard/draw) to finish
// so the final play is visible before the reveal sequence starts
const maxWait = 3000;
const start = Date.now();
while (Date.now() - start < maxWait) {
if (!this.isDrawAnimating && !this.opponentSwapAnimation &&
!this.opponentDiscardAnimating && !this.localDiscardAnimating &&
!this.swapAnimationInProgress) {
break;
}
await this.delay(100);
}
// Extra pause so the final play registers visually before we
// re-render the board (renderGame below resets card positions)
await this.delay(T.lastPlayPause || 2500);
// Now render with pre-reveal state (face-down cards) for the reveal sequence
this.gameState = newState;
this.revealAnimationInProgress = true;
this.renderGame();
this.setStatus('Revealing cards...', 'reveal'); this.setStatus('Revealing cards...', 'reveal');
await this.delay(T.initialPause || 500); await this.delay(T.initialPause || 500);
@@ -2355,7 +2471,13 @@ class GolfGame {
}; };
} }
// Fire-and-forget animation triggers based on state changes // Fire-and-forget animation triggers based on state diffs.
// Two-step detection:
// STEP 1: Did someone draw? (drawn_card goes null -> something)
// STEP 2: Did someone finish their turn? (discard pile changed + turn advanced)
// Critical: if STEP 1 detects a draw-from-discard, STEP 2 must be skipped.
// The discard pile changed because a card was REMOVED, not ADDED. Without this
// suppression, we'd fire a phantom discard animation for a card nobody discarded.
triggerAnimationsForStateChange(oldState, newState) { triggerAnimationsForStateChange(oldState, newState) {
if (!oldState) return; if (!oldState) return;
@@ -2414,8 +2536,13 @@ class GolfGame {
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
// Set isDrawAnimating to block renderGame from updating discard pile // Set isDrawAnimating to block renderGame from updating discard pile
this.isDrawAnimating = true; this.isDrawAnimating = true;
// Force discard DOM to show the card being drawn before animation starts
// (previous animation may have blocked renderGame from updating it)
if (oldDiscard) {
this.updateDiscardPileDisplay(oldDiscard);
}
console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true'); console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true');
window.drawAnimations.animateDrawDiscard(drawnCard, () => { window.drawAnimations.animateDrawDiscard(oldDiscard || drawnCard, () => {
console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating'); console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating');
this.isDrawAnimating = false; this.isDrawAnimating = false;
onAnimComplete(); onAnimComplete();
@@ -2425,7 +2552,7 @@ class GolfGame {
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
this.isDrawAnimating = true; this.isDrawAnimating = true;
console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true'); console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true, drawnCard:', drawnCard ? `${drawnCard.rank} of ${drawnCard.suit}` : 'NULL', 'discardTop:', newDiscard ? `${newDiscard.rank} of ${newDiscard.suit}` : 'EMPTY');
window.drawAnimations.animateDrawDeck(drawnCard, () => { window.drawAnimations.animateDrawDeck(drawnCard, () => {
console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating'); console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating');
this.isDrawAnimating = false; this.isDrawAnimating = false;
@@ -2458,18 +2585,18 @@ class GolfGame {
} }
// STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances) // STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances)
// Skip if we just detected a draw - the discard change was from REMOVING a card, not adding one // Skip if we just detected a draw — see comment at top of function.
if (discardChanged && wasOtherPlayer && !justDetectedDraw) { if (discardChanged && wasOtherPlayer && !justDetectedDraw) {
// Check if the previous player actually SWAPPED (has a new face-up card) // Figure out if the previous player SWAPPED (a card in their hand changed)
// vs just discarding the drawn card (no hand change) // or just discarded their drawn card (hand is identical).
// Three cases to detect a swap:
// Case 1: face-down -> face-up (normal swap into hidden position)
// Case 2: both face-up but different card (swap into already-revealed position)
// Case 3: card identity null -> known (race condition: face_up flag lagging behind)
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId); const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
const newPlayer = newState.players.find(p => p.id === previousPlayerId); const newPlayer = newState.players.find(p => p.id === previousPlayerId);
if (oldPlayer && newPlayer) { if (oldPlayer && newPlayer) {
// Find the position that changed
// Could be: face-down -> face-up (new reveal)
// Or: different card at same position (replaced visible card)
// Or: card identity became known (null -> value, indicates swap)
let swappedPosition = -1; let swappedPosition = -1;
let wasFaceUp = false; // Track if old card was already face-up let wasFaceUp = false; // Track if old card was already face-up
@@ -2506,6 +2633,7 @@ class GolfGame {
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards); const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
if (swappedPosition >= 0 && wasOtherPlayer) { if (swappedPosition >= 0 && wasOtherPlayer) {
console.log('[DEBUG] Swap detected:', { playerId: previousPlayerId, position: swappedPosition, wasFaceUp, newDiscard: newDiscard?.rank });
// Opponent swapped - animate from the actual position that changed // Opponent swapped - animate from the actual position that changed
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp); this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
// Show CPU swap announcement // Show CPU swap announcement
@@ -2802,21 +2930,9 @@ class GolfGame {
return; return;
} }
// Hide the source card during animation
sourceCardEl.classList.add('swap-out');
// Use unified swap animation // Use unified swap animation
if (window.cardAnimations) { if (window.cardAnimations) {
// For opponent swaps, size the held card to match the opponent card const heldRect = window.cardAnimations.getHoldingRect();
// rather than the deck size (default holding rect uses deck dimensions,
// which looks oversized next to small opponent cards on mobile)
const holdingRect = window.cardAnimations.getHoldingRect();
const heldRect = holdingRect ? {
left: holdingRect.left,
top: holdingRect.top,
width: sourceRect.width,
height: sourceRect.height
} : null;
window.cardAnimations.animateUnifiedSwap( window.cardAnimations.animateUnifiedSwap(
discardCard, // handCardData - card going to discard discardCard, // handCardData - card going to discard
@@ -2826,23 +2942,32 @@ class GolfGame {
{ {
rotation: sourceRotation, rotation: sourceRotation,
wasHandFaceDown: !wasFaceUp, wasHandFaceDown: !wasFaceUp,
onStart: () => {
sourceCardEl.classList.add('swap-out');
},
onComplete: () => { onComplete: () => {
sourceCardEl.classList.remove('swap-out'); if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating'); console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating');
// Don't re-render during reveal animation - it handles its own rendering
if (!this.revealAnimationInProgress) {
this.renderGame(); this.renderGame();
} }
} }
}
); );
} else { } else {
// Fallback // Fallback
setTimeout(() => { setTimeout(() => {
sourceCardEl.classList.remove('swap-out'); if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
console.log('[DEBUG] Swap animation fallback complete - clearing flags'); console.log('[DEBUG] Swap animation fallback complete - clearing flags');
// Don't re-render during reveal animation - it handles its own rendering
if (!this.revealAnimationInProgress) {
this.renderGame(); this.renderGame();
}
}, 500); }, 500);
} }
} }
@@ -2864,6 +2989,11 @@ class GolfGame {
if (window.cardAnimations) { if (window.cardAnimations) {
window.cardAnimations.animateInitialFlip(cardEl, cardData, () => { window.cardAnimations.animateInitialFlip(cardEl, cardData, () => {
this.animatingPositions.delete(key); this.animatingPositions.delete(key);
// Unhide the current card element (may have been rebuilt by renderGame)
const currentCards = this.playerCards.querySelectorAll('.card');
if (currentCards[position]) {
currentCards[position].style.visibility = '';
}
}); });
} else { } else {
// Fallback if card animations not available // Fallback if card animations not available
@@ -2968,7 +3098,7 @@ class GolfGame {
this.hideToast(); this.hideToast();
} else { } else {
const remaining = requiredFlips - uniquePositions.length; const remaining = requiredFlips - uniquePositions.length;
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000); this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, 'your-turn', 5000);
} }
return; return;
} }
@@ -3066,6 +3196,14 @@ class GolfGame {
} }
showLobby() { showLobby() {
if (window.cardAnimations) {
window.cardAnimations.cancelAll();
}
this.dealAnimationInProgress = false;
this.isDrawAnimating = false;
this.localDiscardAnimating = false;
this.opponentDiscardAnimating = false;
this.opponentSwapAnimation = false;
this.showScreen(this.lobbyScreen); this.showScreen(this.lobbyScreen);
this.lobbyError.textContent = ''; this.lobbyError.textContent = '';
this.roomCode = null; this.roomCode = null;
@@ -3138,6 +3276,24 @@ class GolfGame {
`<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`; `<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`;
} }
this.activeRulesBar.classList.remove('hidden'); this.activeRulesBar.classList.remove('hidden');
// Update mobile rules indicator
const mobileRulesBtn = document.getElementById('mobile-rules-btn');
const mobileRulesIcon = document.getElementById('mobile-rules-icon');
const mobileRulesContent = document.getElementById('mobile-rules-content');
if (mobileRulesBtn && mobileRulesIcon && mobileRulesContent) {
const isHouseRules = rules.length > 0;
mobileRulesIcon.textContent = isHouseRules ? '!' : 'RULES';
mobileRulesBtn.classList.toggle('house-rules', isHouseRules);
if (!isHouseRules) {
mobileRulesContent.innerHTML = '<div class="mobile-rules-content-list"><span class="rule-tag standard">Standard Rules</span></div>';
} else {
const tagHtml = (unrankedTag ? '<span class="rule-tag unranked">Unranked</span>' : '') +
rules.map(renderTag).join('');
mobileRulesContent.innerHTML = `<div class="mobile-rules-content-list">${tagHtml}</div>`;
}
}
} }
// V3_14: Map display names to rule keys // V3_14: Map display names to rule keys
@@ -3544,7 +3700,9 @@ class GolfGame {
const cardHeight = deckRect.height; const cardHeight = deckRect.height;
// Position card centered, overlapping both piles (lower than before) // Position card centered, overlapping both piles (lower than before)
const overlapOffset = cardHeight * 0.35; // More overlap = lower position // On mobile portrait, place held card fully above the deck/discard area
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
const cardLeft = centerX - cardWidth / 2; const cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset; const cardTop = deckRect.top - overlapOffset;
this.heldCardFloating.style.left = `${cardLeft}px`; this.heldCardFloating.style.left = `${cardLeft}px`;
@@ -3556,11 +3714,21 @@ class GolfGame {
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`; this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
} }
// Position discard button attached to right side of held card // Position discard button
const buttonLeft = cardLeft + cardWidth; // Right edge of card (no gap) if (isMobilePortrait) {
const buttonTop = cardTop + cardHeight * 0.3; // Vertically centered on card // Below the held card, centered
const btnRect = this.discardBtn.getBoundingClientRect();
const buttonLeft = cardLeft + (cardWidth - (btnRect.width || 70)) / 2;
const buttonTop = cardTop + cardHeight + 4;
this.discardBtn.style.left = `${buttonLeft}px`; this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`; this.discardBtn.style.top = `${buttonTop}px`;
} else {
// Right side of held card (desktop)
const buttonLeft = cardLeft + cardWidth;
const buttonTop = cardTop + cardHeight * 0.3;
this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`;
}
if (card.rank === '★') { if (card.rank === '★') {
this.heldCardFloating.classList.add('joker'); this.heldCardFloating.classList.add('joker');
@@ -3606,7 +3774,8 @@ class GolfGame {
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4; const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width; const cardWidth = deckRect.width;
const cardHeight = deckRect.height; const cardHeight = deckRect.height;
const overlapOffset = cardHeight * 0.35; const isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
const cardLeft = centerX - cardWidth / 2; const cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset; const cardTop = deckRect.top - overlapOffset;
@@ -3778,14 +3947,19 @@ class GolfGame {
if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds; if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds;
// Show/hide final turn badge with enhanced urgency // Show/hide final turn badge with enhanced urgency
// Note: markKnocker() is deferred until after opponent areas are rebuilt below
const isFinalTurn = this.gameState.phase === 'final_turn'; const isFinalTurn = this.gameState.phase === 'final_turn';
if (isFinalTurn) { if (isFinalTurn) {
this.updateFinalTurnDisplay(); this.gameScreen.classList.add('final-turn-active');
this.finalTurnBadge.classList.remove('hidden');
if (!this.finalTurnAnnounced) {
this.playSound('alert');
this.finalTurnAnnounced = true;
}
} else { } else {
this.finalTurnBadge.classList.add('hidden'); this.finalTurnBadge.classList.add('hidden');
this.gameScreen.classList.remove('final-turn-active'); this.gameScreen.classList.remove('final-turn-active');
this.finalTurnAnnounced = false; this.finalTurnAnnounced = false;
this.clearKnockerMark();
} }
// Toggle not-my-turn class to disable hover effects when it's not player's turn // Toggle not-my-turn class to disable hover effects when it's not player's turn
@@ -3807,7 +3981,7 @@ class GolfGame {
: this.gameState.current_player_id; : this.gameState.current_player_id;
const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId); const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId);
if (displayedPlayer && displayedPlayerId !== this.playerId) { if (displayedPlayer && displayedPlayerId !== this.playerId) {
this.setStatus(`${displayedPlayer.name}'s turn`); this.setStatus(`${displayedPlayer.name}'s turn`, 'opponent-turn');
} }
// Update player header (name + score like opponents) // Update player header (name + score like opponents)
@@ -3882,7 +4056,11 @@ class GolfGame {
// Not holding - show normal discard pile // Not holding - show normal discard pile
this.discard.classList.remove('picked-up'); this.discard.classList.remove('picked-up');
// Skip discard update during any discard-related animation - animation handles the visual // The discard pile is touched by four different animation paths.
// Each flag represents a different in-flight animation that "owns" the discard DOM.
// renderGame() must not update the discard while any of these are active, or you'll
// see the card content flash/change underneath the animation overlay.
// Priority order doesn't matter — any one of them is reason enough to skip.
const skipReason = this.localDiscardAnimating ? 'localDiscardAnimating' : const skipReason = this.localDiscardAnimating ? 'localDiscardAnimating' :
this.opponentSwapAnimation ? 'opponentSwapAnimation' : this.opponentSwapAnimation ? 'opponentSwapAnimation' :
this.opponentDiscardAnimating ? 'opponentDiscardAnimating' : this.opponentDiscardAnimating ? 'opponentDiscardAnimating' :
@@ -3906,7 +4084,9 @@ class GolfGame {
const discardCard = this.gameState.discard_top; const discardCard = this.gameState.discard_top;
const cardKey = `${discardCard.rank}-${discardCard.suit}`; const cardKey = `${discardCard.rank}-${discardCard.suit}`;
// Only animate discard flip during active gameplay, not at round/game end // Only animate discard flip during active gameplay, not at round/game end.
// lastDiscardKey is pre-set by discardDrawn() to prevent a false "change"
// detection when the server confirms what we already animated locally.
const isActivePlay = this.gameState.phase !== 'round_over' && const isActivePlay = this.gameState.phase !== 'round_over' &&
this.gameState.phase !== 'game_over'; this.gameState.phase !== 'game_over';
const shouldAnimate = isActivePlay && this.lastDiscardKey && const shouldAnimate = isActivePlay && this.lastDiscardKey &&
@@ -4094,7 +4274,13 @@ class GolfGame {
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index)); cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
// V3_13: Bind tooltip events for face-up cards // V3_13: Bind tooltip events for face-up cards
this.bindCardTooltipEvents(cardEl.firstChild, displayCard); this.bindCardTooltipEvents(cardEl.firstChild, displayCard);
this.playerCards.appendChild(cardEl.firstChild); const appendedCard = cardEl.firstChild;
this.playerCards.appendChild(appendedCard);
// Hide card if flip animation overlay is active on this position
if (this.animatingPositions.has(`local-${index}`)) {
appendedCard.style.visibility = 'hidden';
}
}); });
} }
@@ -4151,6 +4337,13 @@ class GolfGame {
// Update scoreboard panel // Update scoreboard panel
this.updateScorePanel(); this.updateScorePanel();
// Mark knocker AFTER opponent areas are rebuilt (otherwise innerHTML='' wipes it)
if (this.gameState.phase === 'final_turn') {
this.markKnocker(this.gameState.finisher_id);
} else {
this.clearKnockerMark();
}
// Initialize anime.js hover listeners on newly created cards // Initialize anime.js hover listeners on newly created cards
if (window.cardAnimations) { if (window.cardAnimations) {
window.cardAnimations.initHoverListeners(this.playerCards); window.cardAnimations.initHoverListeners(this.playerCards);
@@ -4197,6 +4390,11 @@ class GolfGame {
`; `;
this.scoreTable.appendChild(tr); this.scoreTable.appendChild(tr);
}); });
// Mirror to desktop overlay
if (this.desktopScoreTable) {
this.desktopScoreTable.innerHTML = this.scoreTable.innerHTML;
}
} }
updateStandings() { updateStandings() {
@@ -4234,7 +4432,7 @@ class GolfGame {
return `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won} wins</span></div>`; return `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won} wins</span></div>`;
}).join(''); }).join('');
this.standingsList.innerHTML = ` const standingsContent = `
<div class="standings-section"> <div class="standings-section">
<div class="standings-title">By Score</div> <div class="standings-title">By Score</div>
${pointsHtml} ${pointsHtml}
@@ -4244,6 +4442,10 @@ class GolfGame {
${holesHtml} ${holesHtml}
</div> </div>
`; `;
this.standingsList.innerHTML = standingsContent;
if (this.desktopStandingsList) {
this.desktopStandingsList.innerHTML = standingsContent;
}
} }
renderCard(card, clickable, selected) { renderCard(card, clickable, selected) {
@@ -4323,6 +4525,11 @@ class GolfGame {
this.scoreTable.appendChild(tr); this.scoreTable.appendChild(tr);
}); });
// Mirror to desktop overlay
if (this.desktopScoreTable) {
this.desktopScoreTable.innerHTML = this.scoreTable.innerHTML;
}
// Show rankings announcement only for final results // Show rankings announcement only for final results
const existingAnnouncement = document.getElementById('rankings-announcement'); const existingAnnouncement = document.getElementById('rankings-announcement');
if (existingAnnouncement) existingAnnouncement.remove(); if (existingAnnouncement) existingAnnouncement.remove();
@@ -4638,11 +4845,14 @@ class AuthManager {
this.signupFormContainer = document.getElementById('signup-form-container'); this.signupFormContainer = document.getElementById('signup-form-container');
this.signupForm = document.getElementById('signup-form'); this.signupForm = document.getElementById('signup-form');
this.signupInviteCode = document.getElementById('signup-invite-code'); this.signupInviteCode = document.getElementById('signup-invite-code');
this.inviteCodeGroup = document.getElementById('invite-code-group');
this.inviteCodeHint = document.getElementById('invite-code-hint');
this.signupUsername = document.getElementById('signup-username'); this.signupUsername = document.getElementById('signup-username');
this.signupEmail = document.getElementById('signup-email'); this.signupEmail = document.getElementById('signup-email');
this.signupPassword = document.getElementById('signup-password'); this.signupPassword = document.getElementById('signup-password');
this.signupError = document.getElementById('signup-error'); this.signupError = document.getElementById('signup-error');
this.showSignupLink = document.getElementById('show-signup'); this.showSignupLink = document.getElementById('show-signup');
this.signupInfo = null; // populated by fetchSignupInfo()
this.showLoginLink = document.getElementById('show-login'); this.showLoginLink = document.getElementById('show-login');
this.showForgotLink = document.getElementById('show-forgot'); this.showForgotLink = document.getElementById('show-forgot');
this.forgotFormContainer = document.getElementById('forgot-form-container'); this.forgotFormContainer = document.getElementById('forgot-form-container');
@@ -4657,6 +4867,15 @@ class AuthManager {
this.resetPasswordConfirm = document.getElementById('reset-password-confirm'); this.resetPasswordConfirm = document.getElementById('reset-password-confirm');
this.resetError = document.getElementById('reset-error'); this.resetError = document.getElementById('reset-error');
this.resetSuccess = document.getElementById('reset-success'); this.resetSuccess = document.getElementById('reset-success');
this.requestInviteContainer = document.getElementById('request-invite-container');
this.requestInviteForm = document.getElementById('request-invite-form');
this.requestInviteName = document.getElementById('request-invite-name');
this.requestInviteEmail = document.getElementById('request-invite-email');
this.requestInviteMessage = document.getElementById('request-invite-message');
this.requestInviteError = document.getElementById('request-invite-error');
this.requestInviteSuccess = document.getElementById('request-invite-success');
this.requestBackSignup = document.getElementById('request-back-signup');
this.requestBackLogin = document.getElementById('request-back-login');
} }
bindEvents() { bindEvents() {
@@ -4687,10 +4906,22 @@ class AuthManager {
}); });
this.forgotForm?.addEventListener('submit', (e) => this.handleForgotPassword(e)); this.forgotForm?.addEventListener('submit', (e) => this.handleForgotPassword(e));
this.resetForm?.addEventListener('submit', (e) => this.handleResetPassword(e)); this.resetForm?.addEventListener('submit', (e) => this.handleResetPassword(e));
this.requestInviteForm?.addEventListener('submit', (e) => this.handleRequestInvite(e));
this.requestBackSignup?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('signup');
});
this.requestBackLogin?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('login');
});
// Check URL for reset token or invite code on page load // Check URL for reset token or invite code on page load
this.checkResetToken(); this.checkResetToken();
this.checkInviteCode(); this.checkInviteCode();
// Fetch signup availability info (metered open signups)
this.fetchSignupInfo();
} }
showModal(form = 'login') { showModal(form = 'login') {
@@ -4709,6 +4940,7 @@ class AuthManager {
this.signupFormContainer.classList.add('hidden'); this.signupFormContainer.classList.add('hidden');
this.forgotFormContainer?.classList.add('hidden'); this.forgotFormContainer?.classList.add('hidden');
this.resetFormContainer?.classList.add('hidden'); this.resetFormContainer?.classList.add('hidden');
this.requestInviteContainer?.classList.add('hidden');
this.clearErrors(); this.clearErrors();
if (form === 'login') { if (form === 'login') {
@@ -4723,6 +4955,9 @@ class AuthManager {
} else if (form === 'reset') { } else if (form === 'reset') {
this.resetFormContainer?.classList.remove('hidden'); this.resetFormContainer?.classList.remove('hidden');
this.resetPassword?.focus(); this.resetPassword?.focus();
} else if (form === 'request-invite') {
this.requestInviteContainer?.classList.remove('hidden');
this.requestInviteName?.focus();
} }
} }
@@ -4739,6 +4974,8 @@ class AuthManager {
if (this.forgotSuccess) this.forgotSuccess.textContent = ''; if (this.forgotSuccess) this.forgotSuccess.textContent = '';
if (this.resetError) this.resetError.textContent = ''; if (this.resetError) this.resetError.textContent = '';
if (this.resetSuccess) this.resetSuccess.textContent = ''; if (this.resetSuccess) this.resetSuccess.textContent = '';
if (this.requestInviteError) this.requestInviteError.textContent = '';
if (this.requestInviteSuccess) this.requestInviteSuccess.textContent = '';
} }
async handleLogin(e) { async handleLogin(e) {
@@ -4855,6 +5092,54 @@ class AuthManager {
} }
} }
async fetchSignupInfo() {
try {
const resp = await fetch('/api/auth/signup-info');
if (resp.ok) {
this.signupInfo = await resp.json();
this.updateInviteCodeField();
}
} catch (err) {
// Fail silently — invite field stays required by default
}
}
updateInviteCodeField() {
if (!this.signupInfo || !this.signupInviteCode) return;
const { invite_required, open_signups_enabled, remaining_today, unlimited } = this.signupInfo;
if (invite_required) {
this.signupInviteCode.required = true;
this.signupInviteCode.placeholder = 'Invite Code (required)';
if (this.inviteCodeHint) {
if (this.signupInfo.invite_request_enabled) {
this.inviteCodeHint.innerHTML = 'Don\'t have one? <a href="#" id="show-request-invite">Request an invite</a>';
document.getElementById('show-request-invite')?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('request-invite');
});
} else {
this.inviteCodeHint.textContent = '';
}
}
} else if (open_signups_enabled) {
this.signupInviteCode.required = false;
this.signupInviteCode.placeholder = 'Invite Code (optional)';
if (this.inviteCodeHint) {
if (unlimited) {
this.inviteCodeHint.textContent = 'Open registration — no invite needed';
} else if (remaining_today !== null && remaining_today > 0) {
this.inviteCodeHint.textContent = `${remaining_today} open signup${remaining_today !== 1 ? 's' : ''} left today`;
} else if (remaining_today === 0) {
this.signupInviteCode.required = true;
this.signupInviteCode.placeholder = 'Invite Code (required)';
this.inviteCodeHint.textContent = 'Daily signups full — invite code required';
}
}
}
}
async handleForgotPassword(e) { async handleForgotPassword(e) {
e.preventDefault(); e.preventDefault();
this.clearErrors(); this.clearErrors();
@@ -4914,4 +5199,33 @@ class AuthManager {
this.resetError.textContent = 'Connection error'; this.resetError.textContent = 'Connection error';
} }
} }
async handleRequestInvite(e) {
e.preventDefault();
this.clearErrors();
const name = this.requestInviteName.value.trim();
const email = this.requestInviteEmail.value.trim();
const message = this.requestInviteMessage.value.trim() || null;
try {
const response = await fetch('/api/auth/request-invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message }),
});
const data = await response.json();
if (!response.ok) {
this.requestInviteError.textContent = data.detail || 'Request failed';
return;
}
this.requestInviteSuccess.textContent = data.message;
this.requestInviteForm.reset();
} catch (err) {
this.requestInviteError.textContent = 'Connection error';
}
}
} }

View File

@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// CardAnimations - Unified anime.js-based animation system // CardAnimations - Unified anime.js-based animation system
// Replaces draw-animations.js and handles ALL card animations // Replaces draw-animations.js and handles ALL card animations
@@ -43,10 +44,15 @@ class CardAnimations {
const discardRect = this.getDiscardRect(); const discardRect = this.getDiscardRect();
if (!deckRect || !discardRect) return null; if (!deckRect || !discardRect) return null;
// Center the held card between deck and discard pile
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4; const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width; const cardWidth = deckRect.width;
const cardHeight = deckRect.height; const cardHeight = deckRect.height;
const overlapOffset = cardHeight * 0.35; const isMobilePortrait = document.body.classList.contains('mobile-portrait');
// Overlap percentages: how much the held card peeks above the deck/discard row.
// 48% on mobile (tighter vertical space, needs more overlap to fit),
// 35% on desktop (more breathing room). Tuned by eye, not by math.
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
return { return {
left: centerX - cardWidth / 2, left: centerX - cardWidth / 2,
@@ -155,12 +161,20 @@ class CardAnimations {
} }
this.activeAnimations.clear(); this.activeAnimations.clear();
// Remove all animation card elements (including those marked as animating) // Remove all animation overlay elements
document.querySelectorAll('.draw-anim-card').forEach(el => { document.querySelectorAll('.draw-anim-card, .traveling-card, .deal-anim-container').forEach(el => {
delete el.dataset.animating; delete el.dataset.animating;
el.remove(); el.remove();
}); });
// Restore visibility on any cards hidden during animations
document.querySelectorAll('.card[style*="opacity: 0"], .card[style*="opacity:0"]').forEach(el => {
el.style.opacity = '';
});
document.querySelectorAll('.card[style*="visibility: hidden"], .card[style*="visibility:hidden"]').forEach(el => {
el.style.visibility = '';
});
// Restore discard pile visibility if it was hidden during animation // Restore discard pile visibility if it was hidden during animation
const discardPile = document.getElementById('discard'); const discardPile = document.getElementById('discard');
if (discardPile && discardPile.style.opacity === '0') { if (discardPile && discardPile.style.opacity === '0') {
@@ -211,6 +225,7 @@ class CardAnimations {
} }
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) { _animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
console.log('[DEBUG] _animateDrawDeckCard called with cardData:', cardData ? `${cardData.rank} of ${cardData.suit}` : 'NULL');
const deckColor = this.getDeckColor(); const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(deckRect, true, deckColor); const animCard = this.createAnimCard(deckRect, true, deckColor);
animCard.dataset.animating = 'true'; // Mark as actively animating animCard.dataset.animating = 'true'; // Mark as actively animating
@@ -219,6 +234,9 @@ class CardAnimations {
if (cardData) { if (cardData) {
this.setCardContent(animCard, cardData); this.setCardContent(animCard, cardData);
// Debug: verify what was actually set on the front face
const front = animCard.querySelector('.draw-anim-front');
console.log('[DEBUG] Draw anim card front content:', front?.innerHTML);
} }
this.playSound('draw-deck'); this.playSound('draw-deck');
@@ -407,6 +425,7 @@ class CardAnimations {
} }
// Animate initial flip at game start - smooth flip only, no lift // Animate initial flip at game start - smooth flip only, no lift
// Uses overlay sized to match the source card exactly
animateInitialFlip(cardElement, cardData, onComplete) { animateInitialFlip(cardElement, cardData, onComplete) {
if (!cardElement) { if (!cardElement) {
if (onComplete) onComplete(); if (onComplete) onComplete();
@@ -420,8 +439,16 @@ class CardAnimations {
const animCard = this.createAnimCard(rect, true, deckColor); const animCard = this.createAnimCard(rect, true, deckColor);
this.setCardContent(animCard, cardData); this.setCardContent(animCard, cardData);
// Hide original card during animation // Match the front face styling to player hand cards (not deck/discard cards)
cardElement.style.opacity = '0'; const front = animCard.querySelector('.draw-anim-front');
if (front) {
front.style.background = 'linear-gradient(145deg, #fff 0%, #f5f5f5 100%)';
front.style.border = '2px solid #ddd';
front.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.3)';
}
// Hide original card during animation (overlay covers it)
cardElement.style.visibility = 'hidden';
const inner = animCard.querySelector('.draw-anim-inner'); const inner = animCard.querySelector('.draw-anim-inner');
const duration = window.TIMING?.card?.flip || 320; const duration = window.TIMING?.card?.flip || 320;
@@ -436,16 +463,19 @@ class CardAnimations {
begin: () => this.playSound('flip'), begin: () => this.playSound('flip'),
complete: () => { complete: () => {
animCard.remove(); animCard.remove();
cardElement.style.opacity = '1'; cardElement.style.visibility = '';
if (onComplete) onComplete(); if (onComplete) onComplete();
} }
}); });
// Register a no-op entry so cancelAll() can find and stop this animation.
// The actual anime.js instance doesn't need to be tracked (fire-and-forget),
// but we need SOMETHING in the map or cleanup won't know we're animating.
this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} }); this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} });
} catch (e) { } catch (e) {
console.error('Initial flip animation error:', e); console.error('Initial flip animation error:', e);
animCard.remove(); animCard.remove();
cardElement.style.opacity = '1'; cardElement.style.visibility = '';
if (onComplete) onComplete(); if (onComplete) onComplete();
} }
} }
@@ -750,28 +780,40 @@ class CardAnimations {
const id = 'turnPulse'; const id = 'turnPulse';
this.stopTurnPulse(element); this.stopTurnPulse(element);
// Quick shake animation // Quick shake animation - target cards only, not labels
const T = window.TIMING?.turnPulse || {};
const cards = element.querySelectorAll(':scope > .pile-wrapper > .card, :scope > .pile-wrapper > .discard-stack > #discard');
const doShake = () => { const doShake = () => {
if (!this.activeAnimations.has(id)) return; if (!this.activeAnimations.has(id)) return;
anime({ anime({
targets: element, targets: cards.length ? cards : element,
translateX: [0, -8, 8, -6, 4, 0], translateX: [0, -6, 6, -4, 3, 0],
duration: 400, duration: T.duration || 300,
easing: 'easeInOutQuad' easing: 'easeInOutQuad'
}); });
}; };
// Do initial shake, then repeat every 3 seconds // Two-phase timing: wait initialDelay, then shake on an interval.
// Edge case: if stopTurnPulse() is called between the timeout firing and
// the interval being stored on the entry, the interval would leak. That's
// why we re-check activeAnimations.has(id) after the timeout fires — if
// stop was called during the delay, we bail before creating the interval.
const timeout = setTimeout(() => {
if (!this.activeAnimations.has(id)) return;
doShake(); doShake();
const interval = setInterval(doShake, 3000); const interval = setInterval(doShake, T.interval || 3000);
this.activeAnimations.set(id, { interval }); const entry = this.activeAnimations.get(id);
if (entry) entry.interval = interval;
}, T.initialDelay || 5000);
this.activeAnimations.set(id, { timeout });
} }
stopTurnPulse(element) { stopTurnPulse(element) {
const id = 'turnPulse'; const id = 'turnPulse';
const existing = this.activeAnimations.get(id); const existing = this.activeAnimations.get(id);
if (existing) { if (existing) {
if (existing.timeout) clearTimeout(existing.timeout);
if (existing.interval) clearInterval(existing.interval); if (existing.interval) clearInterval(existing.interval);
if (existing.pause) existing.pause(); if (existing.pause) existing.pause();
this.activeAnimations.delete(id); this.activeAnimations.delete(id);
@@ -1064,7 +1106,7 @@ class CardAnimations {
// heldRect: position of the held card (or null to use default holding position) // heldRect: position of the held card (or null to use default holding position)
// options: { rotation, wasHandFaceDown, onComplete } // options: { rotation, wasHandFaceDown, onComplete }
animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) { animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) {
const { rotation = 0, wasHandFaceDown = false, onComplete } = options; const { rotation = 0, wasHandFaceDown = false, onComplete, onStart } = options;
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 }; const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
const discardRect = this.getDiscardRect(); const discardRect = this.getDiscardRect();
@@ -1084,27 +1126,27 @@ class CardAnimations {
return; return;
} }
// Wait for any in-progress draw animation to complete // Collision detection: if a draw animation is still in flight (its overlay cards
// Check if there's an active draw animation by looking for overlay cards // are still in the DOM), we can't start the swap yet — both animations touch the
// same visual space. 350ms is enough for the draw to finish its arc and land.
// This happens when the server sends the swap state update before the draw
// animation's callback fires (network is faster than anime.js, sometimes).
const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]'); const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]');
if (existingDrawCards.length > 0) { if (existingDrawCards.length > 0) {
// Draw animation still in progress - wait a bit and retry
setTimeout(() => { setTimeout(() => {
// Clean up the draw animation overlay
existingDrawCards.forEach(el => { existingDrawCards.forEach(el => {
delete el.dataset.animating; delete el.dataset.animating;
el.remove(); el.remove();
}); });
// Now run the swap animation this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart);
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete); }, 350);
}, 100);
return; return;
} }
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete); this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart);
} }
_runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete) { _runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart) {
// Create the two traveling cards // Create the two traveling cards
const travelingHand = this.createCardFromData(handCardData, handRect, rotation); const travelingHand = this.createCardFromData(handCardData, handRect, rotation);
const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0); const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0);
@@ -1113,6 +1155,9 @@ class CardAnimations {
document.body.appendChild(travelingHand); document.body.appendChild(travelingHand);
document.body.appendChild(travelingHeld); document.body.appendChild(travelingHeld);
// Now that overlays cover the originals, hide them
if (onStart) onStart();
this.playSound('card'); this.playSound('card');
// If hand card was face-down, flip it first // If hand card was face-down, flip it first
@@ -1178,6 +1223,9 @@ class CardAnimations {
], ],
width: discardRect.width, width: discardRect.width,
height: discardRect.height, height: discardRect.height,
// Counter-rotate from the card's grid tilt back to 0. The -3 intermediate
// value adds a slight overshoot that makes the arc feel physical.
// Do not "simplify" this to [rotation, 0]. It will look robotic.
rotate: [rotation, rotation - 3, 0], rotate: [rotation, rotation - 3, 0],
duration: T.arc, duration: T.arc,
easing: this.getEasing('arc'), easing: this.getEasing('arc'),
@@ -1515,6 +1563,7 @@ class CardAnimations {
// Create container for animation cards // Create container for animation cards
const container = document.createElement('div'); const container = document.createElement('div');
container.className = 'deal-anim-container';
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;'; container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
document.body.appendChild(container); document.body.appendChild(container);

View File

@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// CardManager - Manages persistent card DOM elements // CardManager - Manages persistent card DOM elements
// Cards are REAL elements that exist in ONE place and move between locations // Cards are REAL elements that exist in ONE place and move between locations
@@ -100,12 +101,14 @@ class CardManager {
} }
} }
// Get the deck color class for a card based on its deck_id // Get the deck color class for a card based on its deck_id.
// Reads from window.currentDeckColors, which app.js sets from game state.
// This global coupling is intentional — card-manager shouldn't know about
// game state directly, and passing it through every call site isn't worth it.
getDeckColorClass(cardData) { getDeckColorClass(cardData) {
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) { if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
return null; return null;
} }
// Get deck colors from game state (set by app.js)
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold']; const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red'; const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
return `deck-${colorName}`; return `deck-${colorName}`;
@@ -126,7 +129,10 @@ class CardManager {
cardEl.style.width = `${rect.width}px`; cardEl.style.width = `${rect.width}px`;
cardEl.style.height = `${rect.height}px`; cardEl.style.height = `${rect.height}px`;
// On mobile, scale font proportional to card width so rank/suit fit // On mobile, scale font proportional to card width so rank/suit fit.
// This must stay in sync with the CSS .card font-size on desktop — if CSS
// sets a fixed size and we set an inline style, the inline wins. Clearing
// fontSize on desktop lets the CSS rule take over.
if (document.body.classList.contains('mobile-portrait')) { if (document.body.classList.contains('mobile-portrait')) {
cardEl.style.fontSize = `${rect.width * 0.35}px`; cardEl.style.fontSize = `${rect.width * 0.35}px`;
} else { } else {
@@ -235,7 +241,9 @@ class CardManager {
await this.delay(flipDuration); await this.delay(flipDuration);
} }
// Step 2: Move card to discard // Step 2: Move card to discard.
// The +50ms buffer accounts for CSS transition timing jitter — without it,
// we occasionally remove the 'moving' class before the transition finishes.
cardEl.classList.add('moving'); cardEl.classList.add('moving');
this.positionCard(cardEl, discardRect); this.positionCard(cardEl, discardRect);
await this.delay(duration + 50); await this.delay(duration + 50);

View File

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

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Golf Card Game</title> <title>Golf Card Game</title>
<link rel="icon" type="image/svg+xml" href="golfball-logo.svg">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
@@ -16,10 +17,10 @@
<!-- Lobby Screen --> <!-- Lobby Screen -->
<div id="lobby-screen" class="screen active"> <div id="lobby-screen" class="screen active">
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span> <span class="golf-title">Golf</span></h1> <h1><span class="logo-row"><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-container"><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span></span></span> <span class="golf-title">GolfCards<span class="golf-title-tld">.club</span></span></h1>
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p> <p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
<div class="alpha-banner">Alpha &mdash; Things may break. Stats may be wiped.</div> <div class="alpha-banner">Beta Testing - Bear with us while, stuff.</div>
<!-- Auth prompt for unauthenticated users --> <!-- Auth prompt for unauthenticated users -->
<div id="auth-prompt" class="auth-prompt"> <div id="auth-prompt" class="auth-prompt">
@@ -53,6 +54,8 @@
</div> </div>
<p id="lobby-error" class="error"></p> <p id="lobby-error" class="error"></p>
<footer class="app-footer">v3.3.4 &copy; Aaron D. Lee</footer>
</div> </div>
<!-- Matchmaking Screen --> <!-- Matchmaking Screen -->
@@ -80,16 +83,16 @@
<div class="waiting-layout"> <div class="waiting-layout">
<div class="waiting-left-col"> <div class="waiting-left-col">
<div class="players-list"> <div class="players-list">
<div class="players-list-header">
<h3>Players</h3> <h3>Players</h3>
<div id="cpu-controls-section" class="cpu-controls hidden">
<span class="cpu-controls-label">CPU:</span>
<button id="remove-cpu-btn" class="cpu-ctrl-btn btn-danger" title="Remove CPU"></button>
<button id="add-cpu-btn" class="cpu-ctrl-btn btn-success" title="Add CPU">+</button>
</div>
</div>
<ul id="players-list"></ul> <ul id="players-list"></ul>
</div> </div>
<div id="cpu-controls-section" class="cpu-controls-section hidden">
<h4>Add CPU Opponents</h4>
<div class="cpu-controls">
<button id="remove-cpu-btn" class="btn btn-small btn-danger" title="Remove last CPU"></button>
<button id="add-cpu-btn" class="btn btn-small btn-success" title="Add CPU player">+</button>
</div>
</div>
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button> <button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
</div> </div>
@@ -284,6 +287,8 @@
<p id="waiting-message" class="info">Waiting for host to start the game...</p> <p id="waiting-message" class="info">Waiting for host to start the game...</p>
</div> </div>
<footer class="app-footer">v3.3.4 &copy; Aaron D. Lee</footer>
</div> </div>
<!-- Game Screen --> <!-- Game Screen -->
@@ -318,7 +323,6 @@
<div class="game-table"> <div class="game-table">
<div id="opponents-row" class="opponents-row"></div> <div id="opponents-row" class="opponents-row"></div>
<div class="player-row">
<div class="table-center"> <div class="table-center">
<div class="deck-area"> <div class="deck-area">
<!-- Held card slot (left of deck) --> <!-- Held card slot (left of deck) -->
@@ -328,7 +332,12 @@
</div> </div>
<span class="held-label">Holding</span> <span class="held-label">Holding</span>
</div> </div>
<div class="pile-wrapper">
<span class="pile-label">DRAW</span>
<div id="deck" class="card card-back"></div> <div id="deck" class="card card-back"></div>
</div>
<div class="pile-wrapper">
<span class="pile-label">DISCARD</span>
<div class="discard-stack"> <div class="discard-stack">
<div id="discard" class="card"> <div id="discard" class="card">
<span id="discard-content"></span> <span id="discard-content"></span>
@@ -343,7 +352,9 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="player-row">
<div class="player-section"> <div class="player-section">
<div class="player-area"> <div class="player-area">
<h4 id="player-header"><span class="player-name">You</span><span id="your-score" class="player-showing">0</span></h4> <h4 id="player-header"><span class="player-name">You</span><span id="your-score" class="player-showing">0</span></h4>
@@ -398,26 +409,52 @@
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
</div> </div>
<!-- Mobile bottom bar (hidden on desktop) --> <!-- Mobile bottom bar (hidden on desktop) -->
<div id="mobile-bottom-bar"> <div id="mobile-bottom-bar">
<div class="mobile-round-info">Hole <span id="mobile-current-round">1</span>/<span id="mobile-total-rounds">9</span></div> <div class="mobile-round-info">Hole <span id="mobile-current-round">1</span>/<span id="mobile-total-rounds">9</span></div>
<button class="mobile-bar-btn" data-drawer="standings-panel">Standings</button> <button class="mobile-bar-btn mobile-rules-btn" id="mobile-rules-btn" data-drawer="rules-drawer"><span id="mobile-rules-icon">RULES</span></button>
<button class="mobile-bar-btn" data-drawer="scoreboard">Scores</button> <button class="mobile-bar-btn" data-drawer="standings-panel">Scorecard</button>
<button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button> <button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button>
</div> </div>
<!-- Mobile rules drawer -->
<div id="rules-drawer" class="side-panel rules-drawer-panel">
<h4>Active Rules</h4>
<div id="mobile-rules-content"></div>
</div>
<!-- Drawer backdrop for mobile --> <!-- Drawer backdrop for mobile -->
<div id="drawer-backdrop" class="drawer-backdrop"></div> <div id="drawer-backdrop" class="drawer-backdrop"></div>
<!-- Desktop scorecard button + overlay -->
<button id="desktop-scorecard-btn">Scorecard</button>
<div id="desktop-scorecard-overlay" class="side-panel desktop-scorecard-overlay">
<h4>Current Standings</h4>
<div id="desktop-standings-list" class="standings-list"></div>
<h4>Scores</h4>
<table id="desktop-score-table">
<thead>
<tr>
<th>Player</th>
<th>Hole</th>
<th>Tot</th>
<th>W</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div id="desktop-scorecard-backdrop" class="desktop-scorecard-backdrop"></div>
</div> </div>
<!-- Rules Screen --> <!-- Rules Screen -->
<div id="rules-screen" class="screen"> <div id="rules-screen" class="screen">
<div class="rules-container"> <div class="rules-container">
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
<div class="rules-header"> <div class="rules-header">
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
<h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1> <h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1>
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p> <p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
</div> </div>
@@ -733,9 +770,8 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<!-- Leaderboard Screen --> <!-- Leaderboard Screen -->
<div id="leaderboard-screen" class="screen"> <div id="leaderboard-screen" class="screen">
<div class="leaderboard-container"> <div class="leaderboard-container">
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">&laquo; Back</button>
<div class="leaderboard-header"> <div class="leaderboard-header">
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">&laquo; Back</button>
<h1>Leaderboard</h1> <h1>Leaderboard</h1>
<p class="leaderboard-subtitle">Top players ranked by performance</p> <p class="leaderboard-subtitle">Top players ranked by performance</p>
</div> </div>
@@ -878,8 +914,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div id="signup-form-container" class="hidden"> <div id="signup-form-container" class="hidden">
<h3>Sign Up</h3> <h3>Sign Up</h3>
<form id="signup-form"> <form id="signup-form">
<div class="form-group"> <div class="form-group" id="invite-code-group">
<input type="text" id="signup-invite-code" placeholder="Invite Code" required> <input type="text" id="signup-invite-code" placeholder="Invite Code">
<small id="invite-code-hint" class="form-hint"></small>
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20"> <input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
@@ -895,6 +932,28 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
</form> </form>
<p class="auth-switch">Already have an account? <a href="#" id="show-login">Login</a></p> <p class="auth-switch">Already have an account? <a href="#" id="show-login">Login</a></p>
</div> </div>
<!-- Request Invite Form -->
<div id="request-invite-container" class="hidden">
<h3>Request an Invite</h3>
<p class="auth-hint">Registration is invite-only. Request access and we'll get back to you.</p>
<form id="request-invite-form">
<div class="form-group">
<input type="text" id="request-invite-name" placeholder="Your name" required maxlength="100">
</div>
<div class="form-group">
<input type="email" id="request-invite-email" placeholder="Email" required>
</div>
<div class="form-group">
<textarea id="request-invite-message" placeholder="Why do you want to join? (optional)" rows="3" maxlength="500"></textarea>
</div>
<p id="request-invite-error" class="error"></p>
<p id="request-invite-success" class="success"></p>
<button type="submit" class="btn btn-primary btn-full">Request Invite</button>
</form>
<p class="auth-switch">Already have an invite? <a href="#" id="request-back-signup">Sign up</a></p>
<p class="auth-switch">Already have an account? <a href="#" id="request-back-login">Login</a></p>
</div>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/** /**
* Leaderboard component for Golf game. * Leaderboard component for Golf game.
* Handles leaderboard display, metric switching, and player stats modal. * Handles leaderboard display, metric switching, and player stats modal.

View File

@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Golf Card Game - Replay Viewer // Golf Card Game - Replay Viewer
class ReplayViewer { class ReplayViewer {

View File

@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// StateDiffer - Detects what changed between game states // StateDiffer - Detects what changed between game states
// Generates movement instructions for the animation queue // Generates movement instructions for the animation queue

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -17,6 +17,7 @@
services: services:
app: app:
restart: unless-stopped
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -28,11 +29,20 @@ services:
- RESEND_API_KEY=${RESEND_API_KEY:-} - RESEND_API_KEY=${RESEND_API_KEY:-}
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>} - EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
- SENTRY_DSN=${SENTRY_DSN:-} - SENTRY_DSN=${SENTRY_DSN:-}
- ENVIRONMENT=production - ENVIRONMENT=${ENVIRONMENT:-production}
- LOG_LEVEL=INFO - LOG_LEVEL=${LOG_LEVEL:-WARNING}
- LOG_LEVEL_GAME=${LOG_LEVEL_GAME:-}
- LOG_LEVEL_AI=${LOG_LEVEL_AI:-}
- LOG_LEVEL_HANDLERS=${LOG_LEVEL_HANDLERS:-}
- LOG_LEVEL_ROOM=${LOG_LEVEL_ROOM:-}
- LOG_LEVEL_AUTH=${LOG_LEVEL_AUTH:-}
- LOG_LEVEL_STORES=${LOG_LEVEL_STORES:-}
- BASE_URL=${BASE_URL:-https://golf.example.com} - BASE_URL=${BASE_URL:-https://golf.example.com}
- RATE_LIMIT_ENABLED=true - RATE_LIMIT_ENABLED=true
- INVITE_ONLY=true - INVITE_ONLY=true
- INVITE_REQUEST_ENABLED=true
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-} - BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-} - BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
- MATCHMAKING_ENABLED=true - MATCHMAKING_ENABLED=true
@@ -42,10 +52,6 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
deploy: deploy:
replicas: 1
restart_policy:
condition: on-failure
max_attempts: 3
resources: resources:
limits: limits:
memory: 256M memory: 256M
@@ -56,7 +62,7 @@ services:
- web - web
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=golfgame_web" - "traefik.docker.network=traefik_web"
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)" - "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)"
- "traefik.http.routers.golf.entrypoints=websecure" - "traefik.http.routers.golf.entrypoints=websecure"
- "traefik.http.routers.golf.tls=true" - "traefik.http.routers.golf.tls=true"
@@ -76,6 +82,7 @@ services:
- "traefik.http.services.golf.loadbalancer.sticky.cookie.name=golf_server" - "traefik.http.services.golf.loadbalancer.sticky.cookie.name=golf_server"
postgres: postgres:
restart: unless-stopped
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_DB: golf POSTGRES_DB: golf
@@ -98,6 +105,7 @@ services:
memory: 64M memory: 64M
redis: redis:
restart: unless-stopped
image: redis:7-alpine image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 32mb --maxmemory-policy allkeys-lru command: redis-server --appendonly yes --maxmemory 32mb --maxmemory-policy allkeys-lru
volumes: volumes:
@@ -116,45 +124,14 @@ services:
reservations: reservations:
memory: 16M 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: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
letsencrypt:
networks: networks:
internal: internal:
driver: bridge driver: bridge
web: web:
driver: bridge name: traefik_web
external: true

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

@@ -0,0 +1,149 @@
# 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
- INVITE_REQUEST_ENABLED=false
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
- MATCHMAKING_ENABLED=true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
deploy:
replicas: 1
restart_policy:
condition: on-failure
max_attempts: 3
resources:
limits:
memory: 128M
reservations:
memory: 48M
networks:
- internal
- web
labels:
- "traefik.enable=true"
- "traefik.docker.network=golfgame_web"
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-staging.golfcards.club}`)"
- "traefik.http.routers.golf.entrypoints=websecure"
- "traefik.http.routers.golf.tls=true"
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
- "traefik.http.services.golf.loadbalancer.server.port=8000"
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"
- "traefik.http.services.golf.loadbalancer.sticky.cookie.name=golf_server"
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: golf
POSTGRES_USER: golf
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U golf -d golf"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
deploy:
resources:
limits:
memory: 96M
reservations:
memory: 48M
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 16mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
deploy:
resources:
limits:
memory: 32M
reservations:
memory: 16M
traefik:
image: traefik:v3.6
environment:
- DOCKER_API_VERSION=1.44
command:
- "--api.dashboard=true"
- "--api.insecure=true"
- "--accesslog=true"
- "--log.level=WARN"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks:
- web
deploy:
resources:
limits:
memory: 48M
volumes:
postgres_data:
redis_data:
letsencrypt:
networks:
internal:
driver: bridge
web:
driver: bridge

View File

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

View File

@@ -0,0 +1,125 @@
# Soak Harness Bring-Up
One-time setup steps before running `tests/soak` against an environment.
## Prerequisites
- An invite code exists with 16+ available uses
- You have psql access to the target DB (or admin SQL access via some other means)
## 1. Flag the invite code as test-seed
Any account registered with a `marks_as_test=TRUE` invite code gets
`users_v2.is_test_account=TRUE`, which keeps it out of real-user stats.
### Staging
Invite code: `5VC2MCCN` (16 uses, provisioned 2026-04-10).
```sql
UPDATE invite_codes SET marks_as_test = TRUE WHERE code = '5VC2MCCN';
SELECT code, max_uses, use_count, marks_as_test FROM invite_codes WHERE code = '5VC2MCCN';
```
Expected: `marks_as_test | t`.
From your workstation:
```bash
ssh root@129.212.150.189 \
'docker compose -f /opt/golfgame/docker-compose.staging.yml exec -T postgres psql -U postgres -d golfgame' <<'SQL'
UPDATE invite_codes SET marks_as_test = TRUE WHERE code = '5VC2MCCN';
SELECT code, max_uses, use_count, marks_as_test FROM invite_codes WHERE code = '5VC2MCCN';
SQL
```
### Production
Invite code — to be provisioned when production seeding is needed. Same pattern:
```bash
ssh root@165.245.152.51 \
'docker compose -f /opt/golfgame/docker-compose.prod.yml exec -T postgres psql -U postgres -d golfgame' <<'SQL'
UPDATE invite_codes SET marks_as_test = TRUE WHERE code = '<PROD_INVITE_CODE>';
SQL
```
### Local dev
The local dev environment uses the `SOAKTEST` invite code. Create it once
(if you wiped the DB since the last run):
```sql
INSERT INTO invite_codes (code, created_by, expires_at, max_uses, is_active, marks_as_test)
SELECT 'SOAKTEST', id, NOW() + INTERVAL '10 years', 100, TRUE, TRUE
FROM users_v2 LIMIT 1
ON CONFLICT (code) DO UPDATE SET marks_as_test = TRUE;
```
Note: `created_by` references any existing user (the FK doesn't require admin role).
If your dev DB has zero users, register one first via the UI or `curl`.
Connection string for local dev:
```bash
PGPASSWORD=devpassword psql -h localhost -U golf -d golf
```
## 2. Verify the schema migrations applied
The server-side changes (columns, matview rebuild, stats filter) run automatically
on server startup via `SCHEMA_SQL` in `server/stores/user_store.py`. After the
server-side changes deploy, verify against each target environment:
```sql
-- All four should return matching rows
\d users_v2 -- look for is_test_account column
\d invite_codes -- look for marks_as_test column
\d leaderboard_overall -- look for is_test_account column
\di idx_users_test_account
```
If `leaderboard_overall` doesn't show the new column, the matview rebuild
didn't run. Check server startup logs for errors around `initialize_schema`.
## 3. Run the harness
Once the harness is built (Tasks 9+ of the implementation plan):
```bash
cd tests/soak
npm install
# First run only: seed 16 accounts via the invite code
TEST_URL=https://staging.adlee.work SOAK_INVITE_CODE=5VC2MCCN npm run seed
# Smoke test against local dev
TEST_URL=http://localhost:8000 SOAK_INVITE_CODE=SOAKTEST npm run smoke
# Populate staging scoreboard
TEST_URL=https://staging.adlee.work SOAK_INVITE_CODE=5VC2MCCN npm run soak:populate
# Stress test
TEST_URL=https://staging.adlee.work SOAK_INVITE_CODE=5VC2MCCN npm run soak:stress
```
See `tests/soak/README.md` for the full flag reference.
## 4. Verify test account filtering works end-to-end
After a soak run, the soak-seeded accounts should be visible to admins but
hidden from public stats:
```bash
# Should return no soak_* usernames (test accounts hidden by default)
curl -s "https://staging.adlee.work/api/stats/leaderboard?metric=wins" | jq '.entries[] | select(.username | startswith("soak_"))'
# Should return the soak_* accounts when explicitly requested
curl -s "https://staging.adlee.work/api/stats/leaderboard?metric=wins&include_test=true" | jq '.entries[] | select(.username | startswith("soak_"))'
```
In the admin panel:
- Users tab shows a `[Test]` badge next to soak accounts
- Invite codes tab shows `[Test-seed]` next to the flagged code
- "Include test accounts" checkbox (default checked) toggles visibility

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,638 @@
# Multiplayer Soak & UX Test Harness — Design
**Date:** 2026-04-10
**Status:** Design approved, pending implementation plan
## Context
Golf Card Game is a real-time multiplayer WebSocket application with event-sourced game state, a leaderboard system, and an aggressive animation pipeline. Current test coverage is:
- `server/` — pytest unit/integration tests
- `tests/e2e/specs/` — Playwright tests exercising single-context flows (full game, stress with rapid clicks, visual regression, v3 features)
What's missing: a way to exercise the system with **many concurrent authenticated users playing real multiplayer games** for long durations. We can't currently:
1. Populate staging scoreboards with realistic game history for demos and visual verification
2. Hunt race conditions, WebSocket leaks, and room cleanup bugs under sustained concurrent load
3. Validate multiplayer UX end-to-end across rooms without manual coordination
4. Exercise authentication, room lifecycle, and stats aggregation as a cohesive system
This spec defines a **multi-scenario soak and UX test harness**: a standalone Playwright-based runner that drives 16 authenticated browser sessions across 4 concurrent rooms playing many games against each other (plus optional CPU opponents). It starts as a soak tool with two scenarios (`populate`, `stress`) and grows into the project's general-purpose multi-user UX test platform.
## Goals
1. **Scoreboard population** — run long multi-round games against staging with varied CPU personalities to produce realistic scoreboard data
2. **Stability stress** — run rapid short games with chaos injection to surface race conditions and cleanup bugs
3. **Extensibility** — new scenarios (reconnect, invite flow, admin workflow, mobile) slot in without runner changes
4. **Watchability** — a dashboard mode with click-to-watch live video of any player, usable for demos and debugging
5. **Per-run isolation** — test account traffic must be cleanly separable from real user traffic in stats queries
## Non-goals
- Replacing the existing `tests/e2e/specs/` Playwright tests (they serve a different purpose — single-context edge cases)
- Distributed runner across multiple machines
- Concurrent scenario execution (one scenario per run for MVP)
- Grafana/OTEL integration
- Auto-promoting findings to regression tests
## Constraints
- **Staging auth gate** — staging runs `INVITE_ONLY=true`; seeding must go through the register endpoint with an invite code
- **Invite code `5VC2MCCN`** — provisioned with 16 uses, used once per test account on first-ever run, cached afterward
- **Per-IP rate limiting** — `DAILY_SIGNUPS_PER_IP=20` on prod, lower default elsewhere; seeding must stay within budget
- **Room idle cleanup** — `ROOM_IDLE_TIMEOUT_SECONDS=300` means the scenario must keep rooms active or tolerate cleanup cascades
- **Existing bot code** — `tests/e2e/bot/golf-bot.ts` already provides `createGame`, `joinGame`, `addCPU`, `playTurn`, `playGame`; the harness reuses it verbatim
## Architecture
### Module layout
```
runner.ts (entry)
├─ SessionPool owns 16 BrowserContexts, seeds/logs in, allocates
├─ Scenario pluggable interface, per-scenario file
├─ RoomCoordinator host→joiners room-code handoff via Deferred<string>
├─ Dashboard (optional) HTTP + WS server, status grid + click-to-watch video
└─ GolfBot (reused) tests/e2e/bot/golf-bot.ts, unchanged
```
Default: one browser, 16 contexts (lowest RAM, fastest startup). `WATCH=tiled` is the exception — it launches two browsers, one headed (hosts) and one headless (joiners), because Chromium's headed/headless flag is browser-scoped, not context-scoped. See the `tiled` implementation detail below.
### Location
New sibling directory `tests/soak/` — does not modify `tests/e2e/`. Shares `GolfBot` via direct import from `../e2e/bot/`.
Rationale: Playwright Test is designed for short isolated tests. A single `test()` running 16 contexts for hours fights the test model (worker limits, all-or-nothing failure, single giant trace file). A standalone node script gives first-class CLI flags, full control over the event loop, clean home for the dashboard server, and reuses the `GolfBot` class unchanged. Existing `tests/e2e/specs/stress.spec.ts` stays as-is for single-context edge cases.
## Components
### SessionPool
Owns the lifecycle of 16 authenticated `BrowserContext`s.
**Responsibilities:**
- On first run: register 16 accounts via `POST /api/auth/register` with invite code `5VC2MCCN`, cache credentials to `.env.stresstest`
- On subsequent runs: read cached credentials, create contexts, inject auth into each (localStorage token, or re-login via cached password if token rejected)
- Expose `acquire({ count }): Promise<Session[]>` — scenarios request N authenticated sessions without caring how they got there
- On scenario completion: close all contexts cleanly
**`Session` shape:**
```typescript
interface Session {
context: BrowserContext;
page: Page;
bot: GolfBot;
account: Account; // { username, password, token }
key: string; // stable identifier, e.g., "soak_07"
}
```
**`.env.stresstest` format** (gitignored, local-only, plaintext — this is a test tool):
```
SOAK_ACCOUNT_00=soak_00_a7bx:Hunter2!xK9mQ:eyJhbGc...
SOAK_ACCOUNT_01=soak_01_c3pz:Kc82!wQm4Rt:eyJhbGc...
...
SOAK_ACCOUNT_15=soak_15_m9fy:Px7!eR4sTn2:eyJhbGc...
```
Line format: `username:password:token`. Password kept so the pool can recover from token expiry automatically.
### Scenario interface
```typescript
export interface ScenarioNeeds {
accounts: number;
rooms?: number;
cpusPerRoom?: number;
}
export interface ScenarioContext {
config: ScenarioConfig; // CLI flags merged with scenario defaults
sessions: Session[]; // pre-authenticated, pre-navigated
coordinator: RoomCoordinator;
dashboard: DashboardReporter; // no-op when watch mode doesn't use it
logger: Logger;
signal: AbortSignal; // graceful shutdown
heartbeat(roomId: string): void; // resets the per-room watchdog
}
export interface ScenarioResult {
gamesCompleted: number;
errors: ScenarioError[];
durationMs: number;
customMetrics?: Record<string, number>;
}
export interface Scenario {
name: string;
description: string;
defaultConfig: ScenarioConfig;
needs: ScenarioNeeds;
run(ctx: ScenarioContext): Promise<ScenarioResult>;
}
```
Scenarios are plain objects exported as default from files in `tests/soak/scenarios/`. The runner discovers them via a registry (`scenarios/index.ts`) that maps name → module. No filesystem scanning, no magic.
### RoomCoordinator
~30 lines. Solves host→joiners room-code handoff:
```typescript
class RoomCoordinator {
private rooms = new Map<string, Deferred<string>>();
announce(roomId: string, code: string) { this.get(roomId).resolve(code); }
async await(roomId: string): Promise<string> { return this.get(roomId).promise; }
private get(roomId: string) {
if (!this.rooms.has(roomId)) this.rooms.set(roomId, deferred());
return this.rooms.get(roomId)!;
}
}
```
Usage:
```typescript
// Host
const code = await host.bot.createGame(host.account.username);
coordinator.announce('room-1', code);
// Joiners (concurrent)
const code = await coordinator.await('room-1');
await joiner.bot.joinGame(code, joiner.account.username);
```
No polling, no sleeps, no cross-page scraping.
### Dashboard
Optional — only instantiated when `WATCH=dashboard`.
**Server side** (`dashboard/server.ts`): vanilla `http` + `ws` module. Serves a single static HTML page, accepts WebSocket connections, relays messages between scenarios and the browser.
**Client side** (`dashboard/index.html` + `dashboard.js`): 2×2 room grid, per-player tiles with live status (current player, score, held card, phase, moves), progress bars per hole, activity log at the bottom. No framework, ~300 lines total.
**Click-to-watch**: clicking a player tile sends `start_stream(sessionKey)` over WS. The runner attaches a CDP session to that player's page via `context.newCDPSession(page)`, calls `Page.startScreencast` with `{format: 'jpeg', quality: 60, maxWidth: 640, maxHeight: 360, everyNthFrame: 2}`, and forwards each `Page.screencastFrame` event to the dashboard as `{ sessionKey, jpeg_b64 }`. The dashboard renders it into an `<img>` that swaps `src` on each frame.
Returning to the grid sends `stop_stream(sessionKey)` and the runner detaches the CDP session. On WS disconnect, all active screencasts stop. This keeps CPU cost zero except while someone is actively watching.
**`DashboardReporter` interface exposed to scenarios:**
```typescript
interface DashboardReporter {
update(roomId: string, state: Partial<RoomState>): void;
log(level: 'info'|'warn'|'error', msg: string, meta?: object): void;
incrementMetric(name: string, by?: number): void;
}
```
When `WATCH` is not `dashboard`, all three methods are no-ops; structured logs still go to stdout.
### Runner
`runner.ts` is the CLI entry point. Parses flags, resolves config precedence, launches browser(s), instantiates `SessionPool` + `RoomCoordinator` + (optional) `Dashboard`, loads the requested scenario by name, executes it, reports results, cleans up.
## Scenarios
### Scenario 1: `populate`
**Goal:** produce realistic scoreboard data for staging demos.
**Config:**
```typescript
{
name: 'populate',
description: 'Long multi-round games to populate scoreboards',
needs: { accounts: 16, rooms: 4, cpusPerRoom: 1 },
defaultConfig: {
gamesPerRoom: 10,
holes: 9,
decks: 2,
cpuPersonalities: ['Sofia', 'Marcus', 'Kenji', 'Priya'],
thinkTimeMs: [800, 2200],
interGamePauseMs: 3000,
},
}
```
**Shape:** 4 rooms × 4 accounts + 1 CPU each. Each room runs `gamesPerRoom` sequential games. Inside a room: host creates game → joiners join → host adds CPU → host starts game → all sessions loop on `isMyTurn()` + `playTurn()` with randomized human-like think time between turns. Between games, rooms pause briefly to mimic natural pacing.
### Scenario 2: `stress`
**Goal:** hunt race conditions and stability bugs.
**Config:**
```typescript
{
name: 'stress',
description: 'Rapid short games for stability & race condition hunting',
needs: { accounts: 16, rooms: 4, cpusPerRoom: 2 },
defaultConfig: {
gamesPerRoom: 50,
holes: 1,
decks: 1,
thinkTimeMs: [50, 150],
interGamePauseMs: 200,
chaosChance: 0.05,
},
}
```
**Shape:** same as `populate` but tight loops, 1-hole games, and a chaos injector that fires with 5% probability per turn. Chaos events:
- Rapid concurrent clicks on multiple cards
- Random tab-navigation away and back
- Simultaneous click on card + discard button
- Brief WebSocket drop via Playwright's `context.setOffline()` followed by reconnect
Each chaos event is logged with enough context to reproduce (room, player, turn, event type).
### Future scenarios (not MVP, design anticipates them)
- `reconnect` — 2 accounts, deliberate mid-game disconnect, verify recovery
- `invite-flow` — 0 accounts (fresh signups), exercise invite request → approval → first-game pipeline
- `admin-workflow` — 1 admin account, drive the admin panel
- `mobile-populate` — reuses `populate` with `devices['iPhone 13']` context options
- `replay-viewer` — watches completed games via the replay UI
Each is a new file in `tests/soak/scenarios/`, zero runner changes.
## Data flow
### Cold start (first-ever run)
1. Runner reads `.env.stresstest` → file missing
2. `SessionPool.seedAccounts()`:
- For `i` in `0..15`: `POST /api/auth/register` with `{ username, password, email, invite_code: '5VC2MCCN' }`
- Receive `{ user, token, expires_at }`, write to `.env.stresstest`
3. Server sets `is_test_account=true` automatically because the invite code has `marks_as_test=true` (see Server changes)
4. Runner proceeds to normal startup
### Warm start (subsequent runs)
1. Runner reads `.env.stresstest` → 16 entries
2. `SessionPool` creates 16 `BrowserContext`s
3. For each context: inject token into localStorage using the key the client app reads on load (resolved during implementation by inspecting `client/app.js`; see Open Questions)
4. Each session navigates to `/` and lands post-auth
5. If any token is rejected (401), pool silently re-logs in via cached password and refreshes the token in `.env.stresstest`
### Seeding: explicit script vs automatic fallback
Two paths to the same result, for flexibility:
- **Preferred: explicit `npm run seed`** — runs `scripts/seed-accounts.ts` once during bring-up. Gives clear feedback, fails loudly on rate limits or network issues, lets you verify the accounts exist before a real run.
- **Fallback: auto-seed on cold start** — if `runner.ts` starts and `.env.stresstest` is missing, `SessionPool` invokes the same seeding logic transparently. Useful for CI or fresh clones where nobody ran the explicit step.
Both paths share the same code in `core/session-pool.ts`; the script is a thin CLI wrapper around `SessionPool.seedAccounts()`. Documented in `tests/soak/README.md` with "run `npm run seed` first" as the happy path.
### Room code handoff
Host session calls `createGame` → receives room code → `coordinator.announce(roomId, code)`. Joiner sessions `await coordinator.await(roomId)` → receive code → call `joinGame`. All in-process, no polling.
## Watch modes
| Mode | Flag | Rendering | When to use |
|---|---|---|---|
| `none` | `WATCH=none` | Pure headless, JSONL stdout | CI, overnight unattended |
| `dashboard` | `WATCH=dashboard` *(default)* | HTML status grid + click-to-watch live video | Interactive runs, demos, debugging |
| `tiled` | `WATCH=tiled` | 4 native Chromium windows positioned 2×2 | Hands-on power-user debugging |
### `tiled` implementation detail
Two browsers launched: one headed (`headless: false, slowMo: 50`) for the 4 host contexts, one headless for the 12 joiner contexts. Host windows positioned via `page.evaluate(() => window.moveTo(x, y))` after load. Grid computed from screen size with a default of 1920×1080.
## Server-side changes
All changes are additive and fit the existing inline migration pattern in `server/stores/user_store.py`.
### 1. Schema
Two new columns + one partial index:
```sql
ALTER TABLE users_v2 ADD COLUMN IF NOT EXISTS is_test_account BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_users_test_account ON users_v2(is_test_account)
WHERE is_test_account = TRUE;
ALTER TABLE invite_codes ADD COLUMN IF NOT EXISTS marks_as_test BOOLEAN DEFAULT FALSE;
```
Partial index because ~99% of rows will be `FALSE`; we only want to accelerate the "show test accounts" admin queries, not pay index-maintenance cost on every normal write.
### 2. Register flow propagates the flag
In `services/auth_service.py`, after resolving the invite code, read `marks_as_test` and pass through to `user_store.create_user`:
```python
invite = await admin_service.get_invite_code(invite_code)
is_test = bool(invite and invite.marks_as_test)
user = await user_store.create_user(
username=..., password_hash=..., email=...,
is_test_account=is_test,
)
```
Users signing up without an invite or with a non-test invite are unaffected.
### 3. One-time: flag `5VC2MCCN` as test-seed
Executed once against staging (and any other environment the harness runs against):
```sql
UPDATE invite_codes SET marks_as_test = TRUE WHERE code = '5VC2MCCN';
```
Documented in the seeder script as a comment, and in `tests/soak/README.md` as a bring-up step. No admin UI for flagging invites as test-seed in MVP — add later if needed.
### 4. Stats filtering
Add `include_test: bool = False` parameter to stats queries in `services/stats_service.py`:
```python
async def get_leaderboard(self, limit=50, include_test=False):
query = """
SELECT ... FROM player_stats ps
JOIN users_v2 u ON u.id = ps.user_id
WHERE ($1 OR NOT u.is_test_account)
ORDER BY ps.total_points DESC
LIMIT $2
"""
return await conn.fetch(query, include_test, limit)
```
Router in `routers/stats.py` exposes `include_test` as an optional query parameter. Default `False` — real users visiting the site never see soak traffic. Admin panel and debugging views pass `?include_test=true`.
Same treatment for:
- `get_player_stats(user_id, include_test)` — gates individual profile lookups
- `get_recent_games(include_test)` — hides games where any participant is a test account by default
### 5. Admin panel surfacing
Small additions to `client/admin.html` + `client/admin.js`:
- User list: "Test" badge column for `is_test_account=true` rows
- Invite codes: "Test-seed" indicator next to `marks_as_test=true` codes
- Leaderboard + user list: "Include test accounts" toggle → passes `?include_test=true`
### Out of scope (server-side)
- New admin endpoint for marking existing accounts as test
- Admin UI for flagging invites as test-seed at creation time
- Separate "test stats only" aggregation (admins invert their mental filter)
- `test_only=true` query mode
## Error handling
### Failure taxonomy
| Category | Example | Strategy |
|---|---|---|
| Recoverable game error | Animation flag stuck, click missed target | Log, continue, bot retries via existing `GolfBot` fallbacks |
| Recoverable session error | WS disconnect for one player, token expires | Reconnect session, rejoin game if possible, abort that room only if unrecoverable |
| Unrecoverable room error | Room stuck >60s, impossible state | Kill the room, capture artifacts, let other rooms continue |
| Fatal runner error | Staging unreachable, invite code exhausted, OOM | Stop everything cleanly, dump summary, exit non-zero |
**Core principle: per-room isolation.** A failure in room 3 never unwinds rooms 1, 2, 4. Each room runs in its own `Promise.allSettled` branch.
### Per-room watchdog
Each room gets a watchdog that resets on every `ctx.heartbeat(roomId)` call. If a room hasn't heartbeat'd in 60s, the watchdog captures artifacts, aborts that room only, and the runner continues with the remaining rooms.
Scenarios call `heartbeat` at each significant progress point (turn played, game started, game finished). The helper `DashboardReporter.update()` internally calls `heartbeat` as a convenience, so scenarios that use the dashboard reporter get watchdog resets for free. Scenarios that run with `WATCH=none` still need to call `heartbeat` explicitly at least once per 60s — a single call at the top of the per-turn loop is sufficient.
### Artifact capture on failure
Captured per-room into `tests/soak/artifacts/<run-id>/<room-id>/`:
- Screenshot of every context in the affected room
- `page.content()` HTML snapshot per context
- Last 200 console log messages per context (already captured by `GolfBot`)
- Game state JSON from the state parser
- Error stack trace
- Scenario config snapshot
Directory structure:
```
tests/soak/artifacts/
2026-04-10-populate-14.23.05/
run.log # structured JSONL, full run
summary.json # final stats
room-0/
screenshot-host.png
screenshot-joiner-1.png
page-host.html
console.txt
state.json
error.txt
```
Artifacts directory is gitignored. Runs older than 7 days auto-pruned on startup.
### Structured logging
Single logger, JSON Lines to stdout, pretty mirror to the dashboard. Every log line carries `run_id`, `scenario`, `room` (when applicable), and `timestamp`. Grep-friendly and `jq`-friendly.
### Graceful shutdown
`SIGINT` / `SIGTERM` trigger shutdown via `AbortController`:
1. Global `AbortSignal` flips to aborted
2. Scenarios check `ctx.signal.aborted` in loops, finish current turn, exit cleanly
3. Runner waits up to 10s for scenarios to unwind
4. After 10s, force-closes all contexts + browser
5. Writes final `summary.json` and prints results
6. Exit codes: `0` = all rooms completed target games, `1` = any room failed, `2` = interrupted before completion
Double Ctrl-C = immediate force exit.
### Periodic health probes
Every 30s during a run:
- `GET /api/health` against the target server
- Count of open browser contexts vs expected
- Runner memory usage
If `/api/health` fails 3 consecutive times, declare fatal error, capture artifacts, stop. This prevents staging outages from being misattributed to bot bugs.
### Retry policy
Retry only at the session level, never at the scenario level.
- WS drop → reconnect session, rejoin game if possible, 3 attempts max
- Token rejected → re-login via cached password, 1 attempt
- Click missed → existing `GolfBot` retry (already built in)
Never retry: whole games, whole scenarios, fatal errors.
### Cleanup guarantees
Three cleanup points, all going through the same `cleanup()` function wrapped in top-level `try/finally`:
1. **Success** — close contexts, close browsers, flush logs, write summary
2. **Exception** — capture artifacts first, then close contexts, flush logs, write partial summary
3. **Signal interrupt** — graceful shutdown as above, best-effort artifact capture
## File layout
```
tests/soak/
├── package.json # standalone (separate from tests/e2e/)
├── tsconfig.json
├── README.md # quickstart + flag reference + bring-up steps
├── .env.stresstest.example # template (real file gitignored)
├── runner.ts # CLI entry — `npm run soak`
├── config.ts # CLI parsing + defaults merging
├── core/
│ ├── session-pool.ts
│ ├── room-coordinator.ts
│ ├── screencaster.ts # CDP attach/detach on demand
│ ├── watchdog.ts
│ ├── artifacts.ts
│ ├── logger.ts
│ └── types.ts # Scenario, Session, ScenarioContext interfaces
├── scenarios/
│ ├── populate.ts
│ ├── stress.ts
│ └── index.ts # name → module registry
├── dashboard/
│ ├── server.ts # http + ws
│ ├── index.html
│ ├── dashboard.css
│ └── dashboard.js
├── scripts/
│ ├── seed-accounts.ts # one-shot seeding
│ ├── reset-accounts.ts # future: wipe test account stats
│ └── smoke.sh # bring-up validation
└── artifacts/ # gitignored, auto-pruned 7d
└── <run-id>/...
```
## Dependencies
New `tests/soak/package.json`:
```json
{
"name": "golf-soak",
"private": true,
"scripts": {
"soak": "tsx runner.ts",
"soak:populate": "tsx runner.ts --scenario=populate",
"soak:stress": "tsx runner.ts --scenario=stress",
"seed": "tsx scripts/seed-accounts.ts",
"smoke": "scripts/smoke.sh"
},
"dependencies": {
"playwright-core": "^1.40.0",
"ws": "^8.16.0"
},
"devDependencies": {
"tsx": "^4.7.0",
"@types/ws": "^8.5.0",
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
}
}
```
Three runtime deps: `playwright-core` (already in `tests/e2e/`), `ws` (WebSocket for dashboard), `tsx` (dev-only, runs TypeScript directly). No HTTP framework, no bundler, no build step.
## CLI flags
```
--scenario=populate|stress required
--accounts=<n> total sessions (default: scenario.needs.accounts)
--rooms=<n> default from scenario.needs
--cpus-per-room=<n> default from scenario.needs
--games-per-room=<n> default from scenario.defaultConfig
--holes=<n> default from scenario.defaultConfig
--watch=none|dashboard|tiled default: dashboard
--dashboard-port=<n> default: 7777
--target=<url> default: TEST_URL env or http://localhost:8000
--run-id=<string> default: ISO timestamp
--list print available scenarios and exit
--dry-run validate config without running
```
Derived: `accounts-per-room = accounts / rooms`. Must divide evenly; runner errors out with a clear message if not.
Config precedence: CLI flags → environment variables → scenario `defaultConfig` → runner defaults.
## Meta-testing
### Unit tests (Vitest, minimal)
- `room-coordinator.ts` — announce/await correctness, timeout behavior
- `watchdog.ts` — fires on timeout, resets on heartbeat, cancels cleanly
- `config.ts` — CLI precedence, required field validation
### Bring-up smoke test (`tests/soak/scripts/smoke.sh`)
Runs against local dev server with minimum viable config:
```bash
TEST_URL=http://localhost:8000 \
npm run soak -- \
--scenario=populate \
--accounts=2 \
--rooms=1 \
--cpus-per-room=0 \
--games-per-room=1 \
--holes=1 \
--watch=none
```
Exit 0 = full harness works end-to-end. ~30 seconds. Run after any change.
### Manual validation checklist
Documented in `tests/soak/CHECKLIST.md`:
- [ ] Seed 16 accounts against staging using the invite code
- [ ] `--scenario=populate --rooms=1 --games-per-room=1` completes cleanly
- [ ] `--scenario=populate --rooms=4 --games-per-room=1` — 4 rooms in parallel, no cross-contamination
- [ ] `--watch=dashboard` opens browser, grid renders, progress updates
- [ ] Click a player tile → live video appears, Esc → stops
- [ ] `--watch=tiled` opens 4 browser windows in 2×2 grid
- [ ] Ctrl-C during a run → graceful shutdown, summary printed, exit 2
- [ ] Kill the target server mid-run → runner detects, captures artifacts, exits 1
- [ ] Stats query `?include_test=false` hides soak accounts, `?include_test=true` shows them
- [ ] Full stress run (`--scenario=stress --games-per-room=10`) — no console errors, all rooms complete
## Implementation order
Sequenced so each step produces something demonstrable before moving on. The writing-plans skill will break this into concrete tasks.
1. **Server-side changes** — schema alters, register flow, stats filter, admin badge. Independent, ships first, unblocks local testing.
2. **Scaffold `tests/soak/`** — package.json, tsconfig, core/types, logger. No behavior yet.
3. **`SessionPool` + `scripts/seed-accounts.ts`** — end-to-end auth: seed, cache, load, validate login.
4. **`RoomCoordinator` + minimal `populate` scenario body** — proves multi-room orchestration.
5. **`runner.ts`** — CLI, config merging, scenario loading, top-level error handling.
6. **`--watch=none` works** — runs against local dev, produces clean logs, exits 0. First end-to-end milestone.
7. **`--watch=dashboard` status grid** — HTML + WS + tile updates (no video yet).
8. **CDP screencast / click-to-watch** — the live video feature.
9. **`--watch=tiled` mode** — native windows via `page.evaluate(window.moveTo)`.
10. **`stress` scenario** — chaos injection, rapid games.
11. **Failure handling** — watchdog, artifact capture, graceful shutdown.
12. **Smoke test script + CHECKLIST.md** — validation.
13. **Run against staging for real** — populate scoreboard, hunt bugs, report findings.
If step 6 takes longer than planned, steps 15 are still useful standalone.
## Out of scope for MVP
- Mobile viewport scenarios (future `mobile-populate`)
- Reconnect-storm scenarios
- Admin workflow scenarios
- Concurrent scenario execution
- Distributed runner
- Grafana / OTEL / custom metrics push
- Test account stat reset tooling
- Auto-promoting stress findings into Playwright regression tests
- New admin endpoints for account marking
- Admin UI for flagging invites as test-seed
All of these are cheap to add later because the scenario interface and session pool don't presuppose them.
## Open questions (to resolve during implementation)
1. **localStorage auth key** — exact keys used by `client/app.js` to persist the JWT and user blob; verified by reading the file during step 3.
2. **Chaos event set for `stress` scenario** — finalize which chaos events are in scope for MVP vs added incrementally (start with rapid clicks + tab nav + `setOffline`, add more as the server proves robust).
3. **CDP screencast frame rate tuning** — start at `everyNthFrame: 2` (~15fps), adjust down if bandwidth/CPU is excessive on long runs.
4. **Screen bounds detection for `tiled` mode** — default to 1920×1080, expose override via `--tiled-bounds=WxH`; auto-detect later if useful.

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
[project] [project]
name = "golfgame" name = "golfgame"
version = "3.1.1" version = "3.3.4"
description = "6-Card Golf card game with AI opponents" description = "6-Card Golf card game with AI opponents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = {text = "MIT"} license = {text = "GPL-3.0-or-later"}
authors = [ authors = [
{name = "alee"} {name = "alee"}
] ]
@@ -13,7 +13,7 @@ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Framework :: FastAPI", "Framework :: FastAPI",
"Intended Audience :: End Users/Desktop", "Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",

42
scripts/deploy-prod.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
set -e
TAG="${1:?Usage: deploy-prod.sh <tag> (e.g. v3.3.2)}"
DROPLET="root@165.245.152.51"
IMAGE="git.adlee.work/alee/golfgame"
echo "Deploying $TAG to production ($DROPLET)..."
ssh $DROPLET bash -s "$TAG" "$IMAGE" <<'REMOTE'
set -e
TAG="$1"
IMAGE="$2"
cd /opt/golfgame
# Pull the image that passed staging
docker pull "$IMAGE:$TAG"
docker tag "$IMAGE:$TAG" golfgame-app:latest
# Update code for compose/env changes
git fetch origin
git checkout "$TAG"
# Restart app
docker compose -f docker-compose.prod.yml up -d app
# Wait for healthy
echo "Waiting for health check..."
for i in $(seq 1 30); do
if docker compose -f docker-compose.prod.yml ps app | grep -q "healthy"; then
echo "Production deploy successful — $TAG"
exit 0
fi
sleep 2
done
echo "CRITICAL: app not healthy after 60s"
docker compose -f docker-compose.prod.yml logs --tail=30 app
exit 1
REMOTE
echo "Done."

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

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

View File

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

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""AI personalities for CPU players in Golf.""" """AI personalities for CPU players in Golf."""
import logging import logging
@@ -43,8 +44,9 @@ CPU_TIMING = {
# Delay before CPU "looks at" the discard pile # Delay before CPU "looks at" the discard pile
"initial_look": (0.3, 0.5), "initial_look": (0.3, 0.5),
# Brief pause after draw broadcast - let draw animation complete # Brief pause after draw broadcast - let draw animation complete
# Must be >= client draw animation duration (~1s for deck, ~0.4s for discard) # Must be >= client draw animation duration (~1.09s for deck, ~0.4s for discard)
"post_draw_settle": 1.1, # Extra margin prevents swap message from arriving before draw flip completes
"post_draw_settle": 1.3,
# Consideration time after drawing (before swap/discard decision) # Consideration time after drawing (before swap/discard decision)
"post_draw_consider": (0.2, 0.4), "post_draw_consider": (0.2, 0.4),
# Variance multiplier range for chaotic personality players # Variance multiplier range for chaotic personality players
@@ -54,17 +56,15 @@ CPU_TIMING = {
"post_action_pause": (0.5, 0.7), "post_action_pause": (0.5, 0.7),
} }
# Thinking time ranges by card difficulty (seconds) # Thinking time ranges by card difficulty (seconds).
# Yes, these are all identical. That's intentional — the categories exist so we
# CAN tune them independently later, but right now a uniform 0.15-0.3s feels
# natural enough. The structure is the point, not the current values.
THINKING_TIME = { THINKING_TIME = {
# Obviously good cards (Jokers, Kings, 2s, Aces) - easy take
"easy_good": (0.15, 0.3), "easy_good": (0.15, 0.3),
# Obviously bad cards (10s, Jacks, Queens) - easy pass
"easy_bad": (0.15, 0.3), "easy_bad": (0.15, 0.3),
# Medium difficulty (3, 4, 8, 9)
"medium": (0.15, 0.3), "medium": (0.15, 0.3),
# Hardest decisions (5, 6, 7 - middle of range)
"hard": (0.15, 0.3), "hard": (0.15, 0.3),
# No discard available - quick decision
"no_card": (0.15, 0.3), "no_card": (0.15, 0.3),
} }
@@ -799,7 +799,9 @@ class GolfAI:
ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)") ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)")
return True return True
# Take card if it could make a column pair (but NOT for negative value cards) # Take card if it could make a column pair (but NOT for negative value cards).
# Why exclude negatives: a Joker (-2) paired in a column scores 0, which is
# worse than keeping it unpaired at -2. Same logic for 2s with default values.
if discard_value > 0: if discard_value > 0:
for i, card in enumerate(player.cards): for i, card in enumerate(player.cards):
pair_pos = (i + 3) % 6 if i < 3 else i - 3 pair_pos = (i + 3) % 6 if i < 3 else i - 3
@@ -1030,7 +1032,11 @@ class GolfAI:
if not creates_negative_pair: if not creates_negative_pair:
expected_hidden = EXPECTED_HIDDEN_VALUE expected_hidden = EXPECTED_HIDDEN_VALUE
point_gain = expected_hidden - drawn_value point_gain = expected_hidden - drawn_value
discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0 # Personality discount: swap_threshold ranges 0-8, so this maps to 0.5-1.0.
# Conservative players (low threshold) discount heavily — they need a bigger
# point gain to justify swapping into the unknown. Aggressive players take
# the swap at closer to face value.
discount = 0.5 + (profile.swap_threshold / 16)
return point_gain * discount return point_gain * discount
return 0.0 return 0.0
@@ -1251,8 +1257,6 @@ class GolfAI:
"""If player has exactly 1 face-down card, decide the best go-out swap. """If player has exactly 1 face-down card, decide the best go-out swap.
Returns position to swap into, or None to fall through to normal scoring. Returns position to swap into, or None to fall through to normal scoring.
Uses a sentinel value of -1 (converted to None by caller) is not needed -
we return None to indicate "no early decision, continue normal flow".
""" """
options = game.options options = game.options
face_down_positions = hidden_positions(player) face_down_positions = hidden_positions(player)
@@ -1301,12 +1305,28 @@ class GolfAI:
max_acceptable_go_out = 14 + int(profile.aggression * 4) max_acceptable_go_out = 14 + int(profile.aggression * 4)
# Check opponent scores - don't go out if we'd lose badly
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
# Aggressive players tolerate a bigger gap; conservative ones less
opponent_margin = 4 + int(profile.aggression * 4) # 4-8 points
opponent_cap = opponent_min + opponent_margin
# Use the more restrictive of the two thresholds
effective_max = min(max_acceptable_go_out, opponent_cap)
ai_log(f" Go-out safety check: visible_base={visible_score}, " ai_log(f" Go-out safety check: visible_base={visible_score}, "
f"score_if_swap={score_if_swap}, score_if_flip={score_if_flip}, " f"score_if_swap={score_if_swap}, score_if_flip={score_if_flip}, "
f"max_acceptable={max_acceptable_go_out}") f"max_acceptable={max_acceptable_go_out}, opponent_min={opponent_min}, "
f"opponent_cap={opponent_cap}, effective_max={effective_max}")
# High-card safety: don't swap 8+ into hidden position unless it makes a pair
creates_pair = (last_partner.face_up and last_partner.rank == drawn_card.rank)
if drawn_value >= HIGH_CARD_THRESHOLD and not creates_pair:
ai_log(f" >> GO-OUT: high card ({drawn_value}) into hidden, preferring flip")
return None # Fall through to normal scoring (will flip)
# If BOTH options are bad, choose the better one # If BOTH options are bad, choose the better one
if score_if_swap > max_acceptable_go_out and score_if_flip > max_acceptable_go_out: if score_if_swap > effective_max and score_if_flip > effective_max:
if score_if_swap <= score_if_flip: if score_if_swap <= score_if_flip:
ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) " ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) "
f"<= flip ({score_if_flip}), forcing swap") f"<= flip ({score_if_flip}), forcing swap")
@@ -1322,7 +1342,7 @@ class GolfAI:
return None return None
# If swap is good, prefer it (known outcome vs unknown flip) # If swap is good, prefer it (known outcome vs unknown flip)
elif score_if_swap <= max_acceptable_go_out and score_if_swap <= score_if_flip: elif score_if_swap <= effective_max and score_if_swap <= score_if_flip:
ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}") ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}")
return last_pos return last_pos
@@ -1344,7 +1364,11 @@ class GolfAI:
if not face_down or random.random() >= 0.5: if not face_down or random.random() >= 0.5:
return None return None
# SAFETY: Don't randomly go out with a bad score # SAFETY: Don't randomly go out with a bad score.
# This duplicates some logic from project_score() on purpose — project_score()
# is designed for strategic decisions with weighted estimates, but here we need
# a hard pass/fail check with exact pair math. Close enough isn't good enough
# when the downside is accidentally ending the round at 30 points.
if len(face_down) == 1: if len(face_down) == 1:
last_pos = face_down[0] last_pos = face_down[0]
projected = drawn_value projected = drawn_value
@@ -1739,9 +1763,23 @@ class GolfAI:
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
projected_score = visible_score + expected_hidden_total projected_score = visible_score + expected_hidden_total
# Hard cap: never knock with projected score > 10
if projected_score > 10:
ai_log(f" Knock rejected: projected score {projected_score:.1f} > 10 hard cap")
return False
# Tighter threshold: range 5 to 9 based on aggression # Tighter threshold: range 5 to 9 based on aggression
max_acceptable = 5 + int(profile.aggression * 4) max_acceptable = 5 + int(profile.aggression * 4)
# Check opponent threat - don't knock if an opponent likely beats us
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
if opponent_min < projected_score:
# Opponent is likely beating us - penalize threshold
threat_margin = projected_score - opponent_min
max_acceptable -= int(threat_margin * 0.75)
ai_log(f" Knock threat penalty: opponent est {opponent_min}, "
f"margin {threat_margin:.1f}, threshold now {max_acceptable}")
# Exception: if all opponents are showing terrible scores, relax threshold # Exception: if all opponents are showing terrible scores, relax threshold
all_opponents_bad = all( all_opponents_bad = all(
sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25 sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25
@@ -1752,12 +1790,14 @@ class GolfAI:
if projected_score <= max_acceptable: if projected_score <= max_acceptable:
# Scale knock chance by how good the projected score is # Scale knock chance by how good the projected score is
if projected_score <= 5: if projected_score <= 4:
knock_chance = profile.aggression * 0.3 # Max 30% knock_chance = profile.aggression * 0.35 # Max 35%
elif projected_score <= 7: elif projected_score <= 6:
knock_chance = profile.aggression * 0.15 # Max 15% knock_chance = profile.aggression * 0.15 # Max 15%
else: elif projected_score <= 8:
knock_chance = profile.aggression * 0.05 # Max 5% (very rare) knock_chance = profile.aggression * 0.06 # Max 6%
else: # 9-10
knock_chance = profile.aggression * 0.02 # Max 2% (very rare)
if random.random() < knock_chance: if random.random() < knock_chance:
ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})") ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})")
@@ -1932,9 +1972,14 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga
async def process_cpu_turn( async def process_cpu_turn(
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None,
reveal_callback=None,
) -> None: ) -> None:
"""Process a complete turn for a CPU player.""" """Process a complete turn for a CPU player.
May raise asyncio.CancelledError if the game is ended mid-turn.
The caller (check_and_run_cpu_turn) handles cancellation.
"""
import asyncio import asyncio
from services.game_logger import get_logger from services.game_logger import get_logger
@@ -1962,10 +2007,8 @@ async def process_cpu_turn(
await asyncio.sleep(thinking_time) await asyncio.sleep(thinking_time)
ai_log(f"{cpu_player.name} done thinking, making decision") ai_log(f"{cpu_player.name} done thinking, making decision")
# Check if we should try to go out early
GolfAI.should_go_out_early(cpu_player, game, profile)
# Check if we should knock early (flip all remaining cards at once) # Check if we should knock early (flip all remaining cards at once)
# (Opponent threat logic consolidated into should_knock_early)
if GolfAI.should_knock_early(game, cpu_player, profile): if GolfAI.should_knock_early(game, cpu_player, profile):
if game.knock_early(cpu_player.id): if game.knock_early(cpu_player.id):
_log_cpu_action(logger, game_id, cpu_player, game, _log_cpu_action(logger, game_id, cpu_player, game,
@@ -2048,6 +2091,13 @@ async def process_cpu_turn(
if swap_pos is not None: if swap_pos is not None:
old_card = cpu_player.cards[swap_pos] old_card = cpu_player.cards[swap_pos]
# Reveal the face-down card before swapping
if not old_card.face_up and reveal_callback:
await reveal_callback(
cpu_player.id, swap_pos,
{"rank": old_card.rank.value, "suit": old_card.suit.value},
)
await asyncio.sleep(1.0)
game.swap_card(cpu_player.id, swap_pos) game.swap_card(cpu_player.id, swap_pos)
_log_cpu_action(logger, game_id, cpu_player, game, _log_cpu_action(logger, game_id, cpu_player, game,
action="swap", card=drawn, position=swap_pos, action="swap", card=drawn, position=swap_pos,

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Authentication and user management for Golf game. Authentication and user management for Golf game.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Centralized configuration for Golf game server. Centralized configuration for Golf game server.
@@ -142,11 +143,21 @@ class ServerConfig:
MAX_PLAYERS_PER_ROOM: int = 6 MAX_PLAYERS_PER_ROOM: int = 6
ROOM_TIMEOUT_MINUTES: int = 60 ROOM_TIMEOUT_MINUTES: int = 60
ROOM_CODE_LENGTH: int = 4 ROOM_CODE_LENGTH: int = 4
ROOM_IDLE_TIMEOUT_SECONDS: int = 300 # 5 minutes of inactivity
# Security (for future auth system) # Security (for future auth system)
SECRET_KEY: str = "" SECRET_KEY: str = ""
INVITE_ONLY: bool = True INVITE_ONLY: bool = True
# Allow visitors to request an invite (shown on login page when invite-only)
INVITE_REQUEST_ENABLED: bool = False
# Metered open signups (public beta)
# 0 = disabled (invite-only), -1 = unlimited, N = max per day
DAILY_OPEN_SIGNUPS: int = 0
# Max signups per IP per day (0 = unlimited)
DAILY_SIGNUPS_PER_IP: int = 3
# Bootstrap admin (for first-time setup when INVITE_ONLY=true) # Bootstrap admin (for first-time setup when INVITE_ONLY=true)
BOOTSTRAP_ADMIN_USERNAME: str = "" BOOTSTRAP_ADMIN_USERNAME: str = ""
BOOTSTRAP_ADMIN_PASSWORD: str = "" BOOTSTRAP_ADMIN_PASSWORD: str = ""
@@ -192,8 +203,12 @@ class ServerConfig:
MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6), MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6),
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60), ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4), ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
ROOM_IDLE_TIMEOUT_SECONDS=get_env_int("ROOM_IDLE_TIMEOUT_SECONDS", 300),
SECRET_KEY=get_env("SECRET_KEY", ""), SECRET_KEY=get_env("SECRET_KEY", ""),
INVITE_ONLY=get_env_bool("INVITE_ONLY", True), INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
INVITE_REQUEST_ENABLED=get_env_bool("INVITE_REQUEST_ENABLED", False),
DAILY_OPEN_SIGNUPS=get_env_int("DAILY_OPEN_SIGNUPS", 0),
DAILY_SIGNUPS_PER_IP=get_env_int("DAILY_SIGNUPS_PER_IP", 3),
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""), BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""), BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True), MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Card value constants for 6-Card Golf. Card value constants for 6-Card Golf.

View File

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

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Game Analyzer for 6-Card Golf AI decisions. Game Analyzer for 6-Card Golf AI decisions.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""WebSocket message handlers for the Golf card game. """WebSocket message handlers for the Golf card game.
Each handler corresponds to a single message type from the client. Each handler corresponds to a single message type from the client.
@@ -69,6 +70,7 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player") player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
room = room_manager.create_room() room = room_manager.create_room()
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id) room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
room.touch()
ctx.current_room = room ctx.current_room = room
await ctx.websocket.send_json({ await ctx.websocket.send_json({
@@ -114,6 +116,7 @@ async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager,
return return
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id) room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
room.touch()
ctx.current_room = room ctx.current_room = room
await ctx.websocket.send_json({ await ctx.websocket.send_json({
@@ -189,6 +192,7 @@ async def handle_remove_cpu(data: dict, ctx: ConnectionContext, **kw) -> None:
async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id) room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host: if not room_player or not room_player.is_host:
@@ -229,18 +233,19 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
"game_state": game_state, "game_state": game_state,
}) })
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
positions = data.get("positions", []) positions = data.get("positions", [])
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions): if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -250,6 +255,7 @@ async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_g
async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None: async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
source = data.get("source", "deck") source = data.get("source", "deck")
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@@ -277,6 +283,7 @@ async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
position = data.get("position", 0) position = data.get("position", 0)
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@@ -284,6 +291,18 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
player = ctx.current_room.game.get_player(ctx.player_id) player = ctx.current_room.game.get_player(ctx.player_id)
old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None
# Capture old card info BEFORE the swap mutates the player's hand.
# game.swap_card() overwrites player.cards[position] in place, so if we
# read it after, we'd get the new card. The client needs the old card data
# to animate the outgoing card correctly.
old_was_face_down = old_card and not old_card.face_up if old_card else False
old_card_data = None
if old_card and old_was_face_down:
old_card_data = {
"rank": old_card.rank.value if old_card.rank else None,
"suit": old_card.suit.value if old_card.suit else None,
}
discarded = ctx.current_room.game.swap_card(ctx.player_id, position) discarded = ctx.current_room.game.swap_card(ctx.player_id, position)
if discarded: if discarded:
@@ -297,12 +316,13 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
drawn_card = ctx.current_room.game.drawn_card drawn_card = ctx.current_room.game.drawn_card
@@ -329,12 +349,12 @@ async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_s
}) })
else: else:
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
else: else:
logger.debug("Player discarded, waiting 0.5s before CPU turn") logger.debug("Player discarded, waiting 0.5s before CPU turn")
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
logger.debug("Post-discard delay complete, checking for CPU turn") logger.debug("Post-discard delay complete, checking for CPU turn")
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None: async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
@@ -349,6 +369,7 @@ async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_ga
async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
position = data.get("position", 0) position = data.get("position", 0)
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@@ -364,12 +385,13 @@ async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game
) )
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id) player = ctx.current_room.game.get_player(ctx.player_id)
@@ -380,12 +402,13 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game
) )
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
position = data.get("position", 0) position = data.get("position", 0)
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@@ -400,12 +423,13 @@ async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast
) )
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id) player = ctx.current_room.game.get_player(ctx.player_id)
@@ -418,12 +442,13 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga
) )
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id) room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host: if not room_player or not room_player.is_host:
@@ -443,7 +468,7 @@ async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_gam
"game_state": game_state, "game_state": game_state,
}) })
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
else: else:
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
@@ -467,12 +492,22 @@ async def handle_leave_game(data: dict, ctx: ConnectionContext, *, handle_player
async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None: async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id) room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host: if not room_player or not room_player.is_host:
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"}) await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
return return
# Cancel any running CPU turn task so the game ends immediately
if ctx.current_room.cpu_turn_task:
ctx.current_room.cpu_turn_task.cancel()
try:
await ctx.current_room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
ctx.current_room.cpu_turn_task = None
await ctx.current_room.broadcast({ await ctx.current_room.broadcast({
"type": "game_ended", "type": "game_ended",
"reason": "Host ended the game", "reason": "Host ended the game",

View File

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

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""FastAPI WebSocket server for Golf card game.""" """FastAPI WebSocket server for Golf card game."""
import asyncio import asyncio
@@ -64,6 +65,7 @@ _matchmaking_service = None
_replay_service = None _replay_service = None
_spectator_manager = None _spectator_manager = None
_leaderboard_refresh_task = None _leaderboard_refresh_task = None
_room_cleanup_task = None
_redis_client = None _redis_client = None
_rate_limiter = None _rate_limiter = None
_shutdown_event = asyncio.Event() _shutdown_event = asyncio.Event()
@@ -83,8 +85,74 @@ async def _periodic_leaderboard_refresh():
logger.error(f"Leaderboard refresh failed: {e}") logger.error(f"Leaderboard refresh failed: {e}")
async def _periodic_room_cleanup():
"""Periodic task to clean up rooms idle for longer than ROOM_IDLE_TIMEOUT_SECONDS."""
import time
while True:
try:
await asyncio.sleep(60)
now = time.time()
timeout = config.ROOM_IDLE_TIMEOUT_SECONDS
stale_rooms = [
room for room in room_manager.rooms.values()
if now - room.last_activity > timeout
]
for room in stale_rooms:
logger.info(
f"Cleaning up stale room {room.code} "
f"(idle {int(now - room.last_activity)}s, "
f"{len(room.players)} players)"
)
# Cancel CPU turn task
if room.cpu_turn_task:
room.cpu_turn_task.cancel()
try:
await room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
room.cpu_turn_task = None
# Notify and close human WebSocket connections
for player in list(room.players.values()):
if player.websocket and not player.is_cpu:
try:
await player.websocket.send_json({
"type": "room_expired",
"message": "Room closed due to inactivity",
})
await player.websocket.close(code=4002, reason="Room expired")
except Exception:
pass
# Mark game as abandoned in DB
if room.game_log_id:
try:
async with _user_store.pool.acquire() as conn:
await conn.execute(
"UPDATE games_v2 SET status = 'abandoned', completed_at = NOW() WHERE id = $1 AND status = 'active'",
room.game_log_id,
)
logger.info(f"Marked game {room.game_log_id} as abandoned in DB")
except Exception as e:
logger.error(f"Failed to mark game {room.game_log_id} as abandoned: {e}")
# Clean up players and profiles
room_code = room.code
for cpu in list(room.get_cpu_players()):
room.remove_player(cpu.id)
cleanup_room_profiles(room_code)
room_manager.remove_room(room_code)
if stale_rooms:
logger.info(f"Cleaned up {len(stale_rooms)} stale room(s)")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Room cleanup failed: {e}")
async def _init_redis(): async def _init_redis():
"""Initialize Redis client and rate limiter.""" """Initialize Redis client, rate limiter, and signup limiter."""
global _redis_client, _rate_limiter global _redis_client, _rate_limiter
try: try:
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False) _redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
@@ -95,6 +163,17 @@ async def _init_redis():
from services.ratelimit import get_rate_limiter from services.ratelimit import get_rate_limiter
_rate_limiter = await get_rate_limiter(_redis_client) _rate_limiter = await get_rate_limiter(_redis_client)
logger.info("Rate limiter initialized") logger.info("Rate limiter initialized")
# Initialize signup limiter for metered open signups
if config.DAILY_OPEN_SIGNUPS != 0 or config.DAILY_SIGNUPS_PER_IP > 0:
from services.ratelimit import get_signup_limiter
signup_limiter = await get_signup_limiter(_redis_client)
from routers.auth import set_signup_limiter
set_signup_limiter(signup_limiter)
logger.info(
f"Signup limiter initialized "
f"(daily={config.DAILY_OPEN_SIGNUPS}, per_ip={config.DAILY_SIGNUPS_PER_IP})"
)
except Exception as e: except Exception as e:
logger.warning(f"Redis connection failed: {e} - rate limiting disabled") logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
_redis_client = None _redis_client = None
@@ -243,6 +322,14 @@ async def _shutdown_services():
reset_all_profiles() reset_all_profiles()
logger.info("All rooms and CPU profiles cleaned up") logger.info("All rooms and CPU profiles cleaned up")
if _room_cleanup_task:
_room_cleanup_task.cancel()
try:
await _room_cleanup_task
except asyncio.CancelledError:
pass
logger.info("Room cleanup task stopped")
if _leaderboard_refresh_task: if _leaderboard_refresh_task:
_leaderboard_refresh_task.cancel() _leaderboard_refresh_task.cancel()
try: try:
@@ -301,6 +388,26 @@ async def lifespan(app: FastAPI):
room_manager=room_manager, room_manager=room_manager,
) )
# Mark any orphaned active games as abandoned (in-memory state lost on restart)
if _user_store:
try:
async with _user_store.pool.acquire() as conn:
result = await conn.execute(
"UPDATE games_v2 SET status = 'abandoned', completed_at = NOW() WHERE status = 'active'"
)
# PostgreSQL returns command tags like "UPDATE 3" — the last word is
# the affected row count. This is a documented protocol behavior.
count = int(result.split()[-1]) if result else 0
if count > 0:
logger.info(f"Marked {count} orphaned active game(s) as abandoned on startup")
except Exception as e:
logger.error(f"Failed to clean up orphaned games on startup: {e}")
# Start periodic room cleanup
global _room_cleanup_task
_room_cleanup_task = asyncio.create_task(_periodic_room_cleanup())
logger.info(f"Room cleanup task started (timeout={config.ROOM_IDLE_TIMEOUT_SECONDS}s)")
logger.info(f"Golf server started (environment={config.ENVIRONMENT})") logger.info(f"Golf server started (environment={config.ENVIRONMENT})")
yield yield
@@ -325,7 +432,7 @@ async def _close_all_websockets():
app = FastAPI( app = FastAPI(
title="Golf Card Game", title="Golf Card Game",
debug=config.DEBUG, debug=config.DEBUG,
version="3.1.1", version="3.2.0",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -509,6 +616,8 @@ async def reset_cpu_profiles():
return {"status": "ok", "message": "All CPU profiles reset"} return {"status": "ok", "message": "All CPU profiles reset"}
# Per-user game limit. Prevents a single account from creating dozens of rooms
# and exhausting server memory. 4 is generous — most people play 1 at a time.
MAX_CONCURRENT_GAMES = 4 MAX_CONCURRENT_GAMES = 4
@@ -549,6 +658,10 @@ async def websocket_endpoint(websocket: WebSocket):
else: else:
logger.debug(f"WebSocket connected anonymously as {connection_id}") logger.debug(f"WebSocket connected anonymously as {connection_id}")
# player_id = connection_id by design. Originally these were separate concepts
# (connection vs game identity), but in practice a player IS their connection.
# Reconnection creates a new connection_id, and the room layer handles the
# identity mapping. Keeping both fields lets handlers be explicit about intent.
ctx = ConnectionContext( ctx = ConnectionContext(
websocket=websocket, websocket=websocket,
connection_id=connection_id, connection_id=connection_id,
@@ -652,7 +765,7 @@ async def broadcast_game_state(room: Room):
# Check for round over # Check for round over
if room.game.phase == GamePhase.ROUND_OVER: if room.game.phase == GamePhase.ROUND_OVER:
scores = [ scores = [
{"name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won} {"id": p.id, "name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
for p in room.game.players for p in room.game.players
] ]
# Build rankings # Build rankings
@@ -661,6 +774,7 @@ async def broadcast_game_state(room: Room):
await player.websocket.send_json({ await player.websocket.send_json({
"type": "round_over", "type": "round_over",
"scores": scores, "scores": scores,
"finisher_id": room.game.finisher_id,
"round": room.game.current_round, "round": room.game.current_round,
"total_rounds": room.game.num_rounds, "total_rounds": room.game.num_rounds,
"rankings": { "rankings": {
@@ -705,8 +819,13 @@ async def broadcast_game_state(room: Room):
}) })
async def check_and_run_cpu_turn(room: Room): def check_and_run_cpu_turn(room: Room):
"""Check if current player is CPU and run their turn.""" """Check if current player is CPU and start their turn as a background task.
The CPU turn chain runs as a fire-and-forget asyncio.Task stored on
room.cpu_turn_task. This allows the WebSocket message loop to remain
responsive so that end_game/leave messages can cancel the task immediately.
"""
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN): if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return return
@@ -718,25 +837,77 @@ async def check_and_run_cpu_turn(room: Room):
if not room_player or not room_player.is_cpu: if not room_player or not room_player.is_cpu:
return return
# Brief pause before CPU starts - animations are faster now task = asyncio.create_task(_run_cpu_chain(room))
room.cpu_turn_task = task
def _on_done(t: asyncio.Task):
# Clear the reference when the task finishes (success, cancel, or error)
if room.cpu_turn_task is t:
room.cpu_turn_task = None
if not t.cancelled() and t.exception():
logger.error(f"CPU turn task error in room {room.code}: {t.exception()}")
task.add_done_callback(_on_done)
async def _run_cpu_chain(room: Room):
"""Run consecutive CPU turns until a human player's turn or game ends."""
while True:
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return
current = room.game.current_player()
if not current:
return
room_player = room.get_player(current.id)
if not room_player or not room_player.is_cpu:
return
room.touch()
# Brief pause before CPU starts. Without this, the CPU's draw message arrives
# before the client has finished processing the previous turn's state update,
# and animations overlap. 0.25s is enough for the client to settle.
await asyncio.sleep(0.25) await asyncio.sleep(0.25)
# Run CPU turn # Run CPU turn
async def broadcast_cb(): async def broadcast_cb():
await broadcast_game_state(room) await broadcast_game_state(room)
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id) async def reveal_cb(player_id, position, card_data):
reveal_msg = {
"type": "card_revealed",
"player_id": player_id,
"position": position,
"card": card_data,
}
for pid, p in room.players.items():
if not p.is_cpu and p.websocket:
try:
await p.websocket.send_json(reveal_msg)
except Exception:
pass
# Check if next player is also CPU (chain CPU turns) await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id, reveal_callback=reveal_cb)
await check_and_run_cpu_turn(room)
async def handle_player_leave(room: Room, player_id: str): async def handle_player_leave(room: Room, player_id: str):
"""Handle a player leaving a room.""" """Handle a player leaving a room."""
# Cancel any running CPU turn task before cleanup
if room.cpu_turn_task:
room.cpu_turn_task.cancel()
try:
await room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
room.cpu_turn_task = None
room_code = room.code room_code = room.code
room_player = room.remove_player(player_id) room_player = room.remove_player(player_id)
# If no human players left, clean up the room entirely # Check both is_empty() AND human_player_count() — CPU players keep rooms
# technically non-empty, but a room with only CPUs is an abandoned room.
if room.is_empty() or room.human_player_count() == 0: if room.is_empty() or room.human_player_count() == 0:
# Remove all remaining CPU players to release their profiles # Remove all remaining CPU players to release their profiles
for cpu in list(room.get_cpu_players()): for cpu in list(room.get_cpu_players()):
@@ -773,7 +944,18 @@ if os.path.exists(client_path):
return FileResponse(os.path.join(client_path, "index.html")) return FileResponse(os.path.join(client_path, "index.html"))
# Mount static files for everything else (JS, CSS, SVG, etc.) # Mount static files for everything else (JS, CSS, SVG, etc.)
app.mount("/", StaticFiles(directory=client_path), name="static") # Wrap StaticFiles to reject WebSocket requests gracefully instead of
# crashing with AssertionError (starlette asserts scope["type"] == "http").
static_files = StaticFiles(directory=client_path)
async def safe_static_files(scope, receive, send):
if scope["type"] != "http":
if scope["type"] == "websocket":
await send({"type": "websocket.close", "code": 1000})
return
await static_files(scope, receive, send)
app.mount("/", safe_static_files, name="static")
def run(): def run():

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Middleware components for Golf game server. Middleware components for Golf game server.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Rate limiting middleware for FastAPI. Rate limiting middleware for FastAPI.
@@ -81,10 +82,14 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
# Generate client key # Generate client key
client_key = self.limiter.get_client_key(request, user_id) client_key = self.limiter.get_client_key(request, user_id)
# Check rate limit # Check rate limit (fail closed for auth endpoints)
endpoint_key = self._get_endpoint_key(path) endpoint_key = self._get_endpoint_key(path)
full_key = f"{endpoint_key}:{client_key}" full_key = f"{endpoint_key}:{client_key}"
is_auth_endpoint = path.startswith("/api/auth")
if is_auth_endpoint:
allowed, info = await self.limiter.is_allowed_strict(full_key, limit, window)
else:
allowed, info = await self.limiter.is_allowed(full_key, limit, window) allowed, info = await self.limiter.is_allowed(full_key, limit, window)
# Build response # Build response

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Request ID middleware for request tracing. Request ID middleware for request tracing.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Security headers middleware for FastAPI. Security headers middleware for FastAPI.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Models package for Golf game V2.""" """Models package for Golf game V2."""
from .events import EventType, GameEvent from .events import EventType, GameEvent

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Event definitions for Golf game event sourcing. Event definitions for Golf game event sourcing.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Game state rebuilder for event sourcing. Game state rebuilder for event sourcing.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
User-related models for Golf game authentication. User-related models for Golf game authentication.
@@ -44,6 +45,7 @@ class User:
is_banned: Whether user is banned. is_banned: Whether user is banned.
ban_reason: Reason for ban (if banned). ban_reason: Reason for ban (if banned).
force_password_reset: Whether user must reset password on next login. force_password_reset: Whether user must reset password on next login.
is_test_account: True for accounts created by the soak test harness.
""" """
id: str id: str
username: str username: str
@@ -65,6 +67,7 @@ class User:
is_banned: bool = False is_banned: bool = False
ban_reason: Optional[str] = None ban_reason: Optional[str] = None
force_password_reset: bool = False force_password_reset: bool = False
is_test_account: bool = False
def is_admin(self) -> bool: def is_admin(self) -> bool:
"""Check if user has admin role.""" """Check if user has admin role."""
@@ -99,6 +102,7 @@ class User:
"is_banned": self.is_banned, "is_banned": self.is_banned,
"ban_reason": self.ban_reason, "ban_reason": self.ban_reason,
"force_password_reset": self.force_password_reset, "force_password_reset": self.force_password_reset,
"is_test_account": self.is_test_account,
} }
if include_sensitive: if include_sensitive:
d["password_hash"] = self.password_hash d["password_hash"] = self.password_hash
@@ -145,6 +149,7 @@ class User:
is_banned=d.get("is_banned", False), is_banned=d.get("is_banned", False),
ban_reason=d.get("ban_reason"), ban_reason=d.get("ban_reason"),
force_password_reset=d.get("force_password_reset", False), force_password_reset=d.get("force_password_reset", False),
is_test_account=d.get("is_test_account", False),
) )

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Room management for multiplayer Golf games. Room management for multiplayer Golf games.
@@ -14,6 +15,7 @@ A Room contains:
import asyncio import asyncio
import random import random
import string import string
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
@@ -69,6 +71,12 @@ class Room:
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1}) settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
game_log_id: Optional[str] = None game_log_id: Optional[str] = None
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock) game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
cpu_turn_task: Optional[asyncio.Task] = None
last_activity: float = field(default_factory=time.time)
def touch(self) -> None:
"""Update last_activity timestamp to mark room as active."""
self.last_activity = time.time()
def add_player( def add_player(
self, self,
@@ -91,6 +99,9 @@ class Room:
Returns: Returns:
The created RoomPlayer object. The created RoomPlayer object.
""" """
# First player in becomes host. On reconnection, the player gets a new
# connection_id, so they rejoin as a "new" player — host status may shift
# if the original host disconnected and someone else was promoted.
is_host = len(self.players) == 0 is_host = len(self.players) == 0
room_player = RoomPlayer( room_player = RoomPlayer(
id=player_id, id=player_id,
@@ -166,7 +177,9 @@ class Room:
if room_player.is_cpu: if room_player.is_cpu:
release_profile(room_player.name, self.code) release_profile(room_player.name, self.code)
# Assign new host if needed # Assign new host if needed. next(iter(...)) gives us the first value in
# insertion order (Python 3.7+ dict guarantee). This means the longest-tenured
# player becomes host, which is the least surprising behavior.
if room_player.is_host and self.players: if room_player.is_host and self.players:
next_host = next(iter(self.players.values())) next_host = next(iter(self.players.values()))
next_host.is_host = True next_host.is_host = True

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Routers package for Golf game API.""" """Routers package for Golf game API."""
from .auth import router as auth_router from .auth import router as auth_router

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Admin API router for Golf game V2. Admin API router for Golf game V2.
@@ -83,6 +84,7 @@ async def list_users(
offset: int = 0, offset: int = 0,
include_banned: bool = True, include_banned: bool = True,
include_deleted: bool = False, include_deleted: bool = False,
include_test: bool = True,
admin: User = Depends(require_admin_v2), admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep), service: AdminService = Depends(get_admin_service_dep),
): ):
@@ -95,6 +97,10 @@ async def list_users(
offset: Results to skip. offset: Results to skip.
include_banned: Include banned users. include_banned: Include banned users.
include_deleted: Include soft-deleted users. include_deleted: Include soft-deleted users.
include_test: Include soak-harness test accounts (default true).
Admins see all accounts by default; pass ?include_test=false
to hide test accounts. Public stats endpoints default to
hiding them.
""" """
users = await service.search_users( users = await service.search_users(
query=query, query=query,
@@ -102,6 +108,7 @@ async def list_users(
offset=offset, offset=offset,
include_banned=include_banned, include_banned=include_banned,
include_deleted=include_deleted, include_deleted=include_deleted,
include_test=include_test,
) )
return {"users": [u.to_dict() for u in users]} return {"users": [u.to_dict() for u in users]}
@@ -417,3 +424,76 @@ async def revoke_invite_code(
if not success: if not success:
raise HTTPException(status_code=404, detail="Invite code not found") raise HTTPException(status_code=404, detail="Invite code not found")
return {"message": "Invite code revoked successfully"} return {"message": "Invite code revoked successfully"}
# =============================================================================
# Invite Request Endpoints
# =============================================================================
@router.get("/invite-requests")
async def list_invite_requests(
status: Optional[str] = None,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""List invite requests, optionally filtered by status (pending, approved, denied)."""
requests = await service.get_invite_requests(status=status)
return {"requests": [r.to_dict() for r in requests]}
@router.post("/invite-requests/{request_id}/approve")
async def approve_invite_request(
request_id: int,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Approve an invite request — creates a code and emails the requester."""
code = await service.approve_invite_request(
request_id=request_id,
admin_id=admin.id,
ip_address=get_client_ip(request),
)
if not code:
raise HTTPException(status_code=404, detail="Request not found or already handled")
# Get the request details to send the approval email
requests = await service.get_invite_requests()
req = next((r for r in requests if r.id == request_id), None)
if req:
from services.email_service import get_email_service
email_service = get_email_service()
await email_service.send_invite_approved_email(
to=req.email,
name=req.name,
invite_code=code,
)
return {"code": code, "message": "Request approved and invite sent"}
@router.post("/invite-requests/{request_id}/deny")
async def deny_invite_request(
request_id: int,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Deny an invite request — optionally emails the requester."""
result = await service.deny_invite_request(
request_id=request_id,
admin_id=admin.id,
ip_address=get_client_ip(request),
)
if not result:
raise HTTPException(status_code=404, detail="Request not found or already handled")
from services.email_service import get_email_service
email_service = get_email_service()
await email_service.send_invite_denied_email(
to=result["email"],
name=result["name"],
)
return {"message": "Request denied"}

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Authentication API router for Golf game V2. Authentication API router for Golf game V2.
@@ -5,6 +6,7 @@ Provides endpoints for user registration, login, password management,
and session handling. and session handling.
""" """
import hashlib
import logging import logging
from typing import Optional from typing import Optional
@@ -15,6 +17,7 @@ from config import config
from models.user import User from models.user import User
from services.auth_service import AuthService from services.auth_service import AuthService
from services.admin_service import AdminService from services.admin_service import AdminService
from services.ratelimit import SignupLimiter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -72,6 +75,13 @@ class UpdatePreferencesRequest(BaseModel):
preferences: dict preferences: dict
class InviteRequestBody(BaseModel):
"""Invite request body."""
name: str
email: str
message: Optional[str] = None
class ConvertGuestRequest(BaseModel): class ConvertGuestRequest(BaseModel):
"""Convert guest to user request.""" """Convert guest to user request."""
guest_id: str guest_id: str
@@ -115,6 +125,7 @@ class SessionResponse(BaseModel):
# These will be set by main.py during startup # These will be set by main.py during startup
_auth_service: Optional[AuthService] = None _auth_service: Optional[AuthService] = None
_admin_service: Optional[AdminService] = None _admin_service: Optional[AdminService] = None
_signup_limiter: Optional[SignupLimiter] = None
def set_auth_service(service: AuthService) -> None: def set_auth_service(service: AuthService) -> None:
@@ -129,6 +140,12 @@ def set_admin_service_for_auth(service: AdminService) -> None:
_admin_service = service _admin_service = service
def set_signup_limiter(limiter: SignupLimiter) -> None:
"""Set the signup limiter instance (called from main.py)."""
global _signup_limiter
_signup_limiter = limiter
def get_auth_service_dep() -> AuthService: def get_auth_service_dep() -> AuthService:
"""Dependency to get auth service.""" """Dependency to get auth service."""
if _auth_service is None: if _auth_service is None:
@@ -211,30 +228,86 @@ async def register(
auth_service: AuthService = Depends(get_auth_service_dep), auth_service: AuthService = Depends(get_auth_service_dep),
): ):
"""Register a new user account.""" """Register a new user account."""
# Validate invite code when invite-only mode is enabled has_invite = bool(request_body.invite_code)
if config.INVITE_ONLY: is_open_signup = not has_invite
if not request_body.invite_code: client_ip = get_client_ip(request)
raise HTTPException(status_code=400, detail="Invite code required") ip_hash = hashlib.sha256(client_ip.encode()).hexdigest()[:16] if client_ip else "unknown"
# --- Per-IP daily signup limit (applies to ALL signups) ---
if config.DAILY_SIGNUPS_PER_IP > 0 and _signup_limiter:
ip_allowed, ip_remaining = await _signup_limiter.check_ip_limit(
ip_hash, config.DAILY_SIGNUPS_PER_IP
)
if not ip_allowed:
raise HTTPException(
status_code=429,
detail="Too many signups from this address today. Please try again tomorrow.",
)
# --- Invite code validation ---
is_test_account = False
if has_invite:
if not _admin_service: if not _admin_service:
raise HTTPException(status_code=503, detail="Admin service not initialized") raise HTTPException(status_code=503, detail="Admin service not initialized")
if not await _admin_service.validate_invite_code(request_body.invite_code): if not await _admin_service.validate_invite_code(request_body.invite_code):
raise HTTPException(status_code=400, detail="Invalid or expired invite code") raise HTTPException(status_code=400, detail="Invalid or expired invite code")
# Check if this invite flags new accounts as test accounts
invite_details = await _admin_service.get_invite_code_details(request_body.invite_code)
if invite_details and invite_details.get("marks_as_test"):
is_test_account = True
logger.info(
"test_seed_account_registering",
extra={
"username": request_body.username,
"invite_code": request_body.invite_code,
},
)
else:
# No invite code — check if open signups are allowed
if config.INVITE_ONLY and config.DAILY_OPEN_SIGNUPS == 0:
raise HTTPException(status_code=400, detail="Invite code required")
# Check daily open signup limit
if config.DAILY_OPEN_SIGNUPS != 0 and _signup_limiter:
daily_allowed, daily_remaining = await _signup_limiter.check_daily_limit(
config.DAILY_OPEN_SIGNUPS
)
if not daily_allowed:
raise HTTPException(
status_code=429,
detail="Daily signup limit reached. Please try again tomorrow or use an invite code.",
)
elif config.DAILY_OPEN_SIGNUPS != 0 and not _signup_limiter:
# Signup limiter requires Redis — fail closed
raise HTTPException(
status_code=503,
detail="Registration temporarily unavailable. Please try again later.",
)
# --- Create the account ---
result = await auth_service.register( result = await auth_service.register(
username=request_body.username, username=request_body.username,
password=request_body.password, password=request_body.password,
email=request_body.email, email=request_body.email,
is_test_account=is_test_account,
) )
if not result.success: if not result.success:
raise HTTPException(status_code=400, detail=result.error) raise HTTPException(status_code=400, detail=result.error)
# Consume the invite code after successful registration # --- Post-registration bookkeeping ---
if config.INVITE_ONLY and request_body.invite_code: # Consume invite code if used
if has_invite and _admin_service:
await _admin_service.use_invite_code(request_body.invite_code) await _admin_service.use_invite_code(request_body.invite_code)
# Increment signup counters
if _signup_limiter:
if is_open_signup and config.DAILY_OPEN_SIGNUPS != 0:
await _signup_limiter.increment_daily()
if config.DAILY_SIGNUPS_PER_IP > 0:
await _signup_limiter.increment_ip(ip_hash)
if result.requires_verification: if result.requires_verification:
# Return user info but note they need to verify
return { return {
"user": _user_to_response(result.user), "user": _user_to_response(result.user),
"token": "", "token": "",
@@ -247,7 +320,7 @@ async def register(
username=request_body.username, username=request_body.username,
password=request_body.password, password=request_body.password,
device_info=get_device_info(request), device_info=get_device_info(request),
ip_address=get_client_ip(request), ip_address=client_ip,
) )
if not login_result.success: if not login_result.success:
@@ -260,6 +333,82 @@ async def register(
} }
@router.get("/signup-info")
async def signup_info():
"""
Public endpoint: returns signup availability info.
Tells the client whether invite codes are required,
and how many open signup slots remain today.
"""
open_signups_enabled = config.DAILY_OPEN_SIGNUPS != 0
invite_required = config.INVITE_ONLY and not open_signups_enabled
unlimited = config.DAILY_OPEN_SIGNUPS < 0
remaining = None
if open_signups_enabled and not unlimited and _signup_limiter:
daily_count = await _signup_limiter.get_daily_count()
remaining = max(0, config.DAILY_OPEN_SIGNUPS - daily_count)
return {
"invite_required": invite_required,
"invite_request_enabled": config.INVITE_REQUEST_ENABLED,
"open_signups_enabled": open_signups_enabled,
"daily_limit": config.DAILY_OPEN_SIGNUPS if not unlimited else None,
"remaining_today": remaining,
"unlimited": unlimited,
}
@router.post("/request-invite")
async def request_invite(
request_body: InviteRequestBody,
request: Request,
):
"""
Public endpoint: submit a request for an invite code.
Stores the request in the database and notifies admins via email.
"""
if not config.INVITE_REQUEST_ENABLED:
raise HTTPException(status_code=404, detail="Invite requests are not enabled")
if not _admin_service:
raise HTTPException(status_code=503, detail="Service not initialized")
name = request_body.name.strip()
email = request_body.email.strip().lower()
message = request_body.message.strip() if request_body.message else None
if not name or len(name) > 100:
raise HTTPException(status_code=400, detail="Name is required (max 100 characters)")
if not email or "@" not in email:
raise HTTPException(status_code=400, detail="Valid email is required")
client_ip = get_client_ip(request)
request_id = await _admin_service.create_invite_request(
name=name,
email=email,
message=message,
ip_address=client_ip,
)
# Notify admin emails
if config.ADMIN_EMAILS:
from services.email_service import get_email_service
email_service = get_email_service()
for admin_email in config.ADMIN_EMAILS:
await email_service.send_invite_request_admin_notification(
to=admin_email,
requester_name=name,
requester_email=email,
message=message or "",
)
return {"status": "ok", "message": "Your request has been submitted. We'll be in touch!"}
@router.post("/verify-email") @router.post("/verify-email")
async def verify_email( async def verify_email(
request_body: VerifyEmailRequest, request_body: VerifyEmailRequest,

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Health check endpoints for production deployment. Health check endpoints for production deployment.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Replay API router for Golf game. Replay API router for Golf game.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Stats and Leaderboards API router for Golf game. Stats and Leaderboards API router for Golf game.
@@ -158,6 +159,7 @@ async def get_leaderboard(
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"), metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
include_test: bool = Query(False, description="Include soak-harness test accounts"),
service: StatsService = Depends(get_stats_service_dep), service: StatsService = Depends(get_stats_service_dep),
): ):
""" """
@@ -171,8 +173,9 @@ async def get_leaderboard(
- streak: Best win streak - streak: Best win streak
Players must have 5+ games to appear on leaderboards. Players must have 5+ games to appear on leaderboards.
By default, soak-harness test accounts are hidden.
""" """
entries = await service.get_leaderboard(metric, limit, offset) entries = await service.get_leaderboard(metric, limit, offset, include_test)
return { return {
"metric": metric, "metric": metric,
@@ -227,10 +230,11 @@ async def get_player_stats(
async def get_player_rank( async def get_player_rank(
user_id: str, user_id: str,
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"), metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
include_test: bool = Query(False, description="Include soak-harness test accounts"),
service: StatsService = Depends(get_stats_service_dep), service: StatsService = Depends(get_stats_service_dep),
): ):
"""Get player's rank on a leaderboard.""" """Get player's rank on a leaderboard."""
rank = await service.get_player_rank(user_id, metric) rank = await service.get_player_rank(user_id, metric, include_test)
return { return {
"user_id": user_id, "user_id": user_id,
@@ -347,11 +351,12 @@ async def get_my_stats(
@router.get("/me/rank", response_model=PlayerRankResponse) @router.get("/me/rank", response_model=PlayerRankResponse)
async def get_my_rank( async def get_my_rank(
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"), metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
include_test: bool = Query(False, description="Include soak-harness test accounts"),
user: User = Depends(require_user), user: User = Depends(require_user),
service: StatsService = Depends(get_stats_service_dep), service: StatsService = Depends(get_stats_service_dep),
): ):
"""Get current user's rank on a leaderboard.""" """Get current user's rank on a leaderboard."""
rank = await service.get_player_rank(user.id, metric) rank = await service.get_player_rank(user.id, metric, include_test)
return { return {
"user_id": user.id, "user_id": user.id,

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Score distribution analysis for Golf AI. Score distribution analysis for Golf AI.

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Create an admin user for the Golf game. Create an admin user for the Golf game.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Services package for Golf game V2 business logic.""" """Services package for Golf game V2 business logic."""
from .recovery_service import RecoveryService, RecoveryResult from .recovery_service import RecoveryService, RecoveryResult

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Admin service for Golf game. Admin service for Golf game.
@@ -37,6 +38,7 @@ class UserDetails:
is_active: bool is_active: bool
games_played: int games_played: int
games_won: int games_won: int
is_test_account: bool = False
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -54,6 +56,7 @@ class UserDetails:
"is_active": self.is_active, "is_active": self.is_active,
"games_played": self.games_played, "games_played": self.games_played,
"games_won": self.games_won, "games_won": self.games_won,
"is_test_account": self.is_test_account,
} }
@@ -122,6 +125,7 @@ class InviteCode:
max_uses: int max_uses: int
use_count: int use_count: int
is_active: bool is_active: bool
marks_as_test: bool = False
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -134,6 +138,36 @@ class InviteCode:
"use_count": self.use_count, "use_count": self.use_count,
"is_active": self.is_active, "is_active": self.is_active,
"remaining_uses": max(0, self.max_uses - self.use_count), "remaining_uses": max(0, self.max_uses - self.use_count),
"marks_as_test": self.marks_as_test,
}
@dataclass
class InviteRequest:
"""Invite request details."""
id: int
name: str
email: str
message: Optional[str]
status: str
ip_address: Optional[str]
created_at: datetime
reviewed_at: Optional[datetime]
reviewed_by: Optional[str]
reviewed_by_username: Optional[str] = None
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"email": self.email,
"message": self.message,
"status": self.status,
"ip_address": self.ip_address,
"created_at": self.created_at.isoformat() if self.created_at else None,
"reviewed_at": self.reviewed_at.isoformat() if self.reviewed_at else None,
"reviewed_by": self.reviewed_by,
"reviewed_by_username": self.reviewed_by_username,
} }
@@ -286,6 +320,7 @@ class AdminService:
offset: int = 0, offset: int = 0,
include_banned: bool = True, include_banned: bool = True,
include_deleted: bool = False, include_deleted: bool = False,
include_test: bool = True,
) -> List[UserDetails]: ) -> List[UserDetails]:
""" """
Search users by username or email. Search users by username or email.
@@ -296,6 +331,10 @@ class AdminService:
offset: Number of results to skip. offset: Number of results to skip.
include_banned: Include banned users. include_banned: Include banned users.
include_deleted: Include soft-deleted users. include_deleted: Include soft-deleted users.
include_test: Include soak-harness test accounts (default True).
Admins see all accounts by default; the admin UI provides a
toggle to hide test accounts. Public stats endpoints use the
opposite default (False) so real users never see soak traffic.
Returns: Returns:
List of user details. List of user details.
@@ -306,6 +345,7 @@ class AdminService:
u.email_verified, u.is_banned, u.ban_reason, u.email_verified, u.is_banned, u.ban_reason,
u.force_password_reset, u.created_at, u.last_login, u.force_password_reset, u.created_at, u.last_login,
u.last_seen_at, u.is_active, u.last_seen_at, u.is_active,
COALESCE(u.is_test_account, FALSE) as is_test_account,
COALESCE(s.games_played, 0) as games_played, COALESCE(s.games_played, 0) as games_played,
COALESCE(s.games_won, 0) as games_won COALESCE(s.games_won, 0) as games_won
FROM users_v2 u FROM users_v2 u
@@ -326,6 +366,9 @@ class AdminService:
if not include_deleted: if not include_deleted:
sql += " AND u.deleted_at IS NULL" sql += " AND u.deleted_at IS NULL"
if not include_test:
sql += " AND (u.is_test_account = FALSE OR u.is_test_account IS NULL)"
sql += f" ORDER BY u.created_at DESC LIMIT ${param_num} OFFSET ${param_num + 1}" sql += f" ORDER BY u.created_at DESC LIMIT ${param_num} OFFSET ${param_num + 1}"
params.extend([limit, offset]) params.extend([limit, offset])
@@ -347,6 +390,7 @@ class AdminService:
is_active=row["is_active"], is_active=row["is_active"],
games_played=row["games_played"] or 0, games_played=row["games_played"] or 0,
games_won=row["games_won"] or 0, games_won=row["games_won"] or 0,
is_test_account=row["is_test_account"],
) )
for row in rows for row in rows
] ]
@@ -355,6 +399,10 @@ class AdminService:
""" """
Get detailed user info by ID. Get detailed user info by ID.
Note: Returns the user regardless of is_test_account status.
Filtering by test-account only applies to list views
(search_users). If you know the ID, you get the row.
Args: Args:
user_id: User UUID. user_id: User UUID.
@@ -368,6 +416,7 @@ class AdminService:
u.email_verified, u.is_banned, u.ban_reason, u.email_verified, u.is_banned, u.ban_reason,
u.force_password_reset, u.created_at, u.last_login, u.force_password_reset, u.created_at, u.last_login,
u.last_seen_at, u.is_active, u.last_seen_at, u.is_active,
COALESCE(u.is_test_account, FALSE) as is_test_account,
COALESCE(s.games_played, 0) as games_played, COALESCE(s.games_played, 0) as games_played,
COALESCE(s.games_won, 0) as games_won COALESCE(s.games_won, 0) as games_won
FROM users_v2 u FROM users_v2 u
@@ -395,6 +444,7 @@ class AdminService:
is_active=row["is_active"], is_active=row["is_active"],
games_played=row["games_played"] or 0, games_played=row["games_played"] or 0,
games_won=row["games_won"] or 0, games_won=row["games_won"] or 0,
is_test_account=row["is_test_account"],
) )
async def ban_user( async def ban_user(
@@ -1087,6 +1137,7 @@ class AdminService:
query = """ query = """
SELECT c.code, c.created_by, c.created_at, c.expires_at, SELECT c.code, c.created_by, c.created_at, c.expires_at,
c.max_uses, c.use_count, c.is_active, c.max_uses, c.use_count, c.is_active,
COALESCE(c.marks_as_test, FALSE) as marks_as_test,
u.username as created_by_username u.username as created_by_username
FROM invite_codes c FROM invite_codes c
JOIN users_v2 u ON c.created_by = u.id JOIN users_v2 u ON c.created_by = u.id
@@ -1108,6 +1159,7 @@ class AdminService:
max_uses=row["max_uses"], max_uses=row["max_uses"],
use_count=row["use_count"], use_count=row["use_count"],
is_active=row["is_active"], is_active=row["is_active"],
marks_as_test=row["marks_as_test"],
) )
for row in rows for row in rows
] ]
@@ -1183,6 +1235,34 @@ class AdminService:
return True return True
async def get_invite_code_details(self, code: str) -> Optional[dict]:
"""
Look up an invite code's row including marks_as_test.
Returns None if the code does not exist. Does NOT validate expiry
or usage — use validate_invite_code for that. This is purely a
helper for the register flow to discover the test-seed flag.
"""
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT code, max_uses, use_count, is_active,
COALESCE(marks_as_test, FALSE) as marks_as_test
FROM invite_codes
WHERE code = $1
""",
code,
)
if not row:
return None
return {
"code": row["code"],
"max_uses": row["max_uses"],
"use_count": row["use_count"],
"is_active": row["is_active"],
"marks_as_test": row["marks_as_test"],
}
async def use_invite_code(self, code: str) -> bool: async def use_invite_code(self, code: str) -> bool:
""" """
Use an invite code (increment use count). Use an invite code (increment use count).
@@ -1210,6 +1290,183 @@ class AdminService:
return result != "UPDATE 0" return result != "UPDATE 0"
# -------------------------------------------------------------------------
# Invite Requests
# -------------------------------------------------------------------------
async def create_invite_request(
self,
name: str,
email: str,
message: Optional[str] = None,
ip_address: Optional[str] = None,
) -> int:
"""
Create a new invite request.
Returns:
The request ID.
"""
async with self.pool.acquire() as conn:
# Check for existing pending request from same email
existing = await conn.fetchval(
"SELECT id FROM invite_requests WHERE email = $1 AND status = 'pending'",
email,
)
if existing:
return existing
row_id = await conn.fetchval(
"""
INSERT INTO invite_requests (name, email, message, ip_address)
VALUES ($1, $2, $3, $4::inet)
RETURNING id
""",
name,
email,
message,
ip_address,
)
logger.info(f"New invite request #{row_id} from {email}")
return row_id
async def get_invite_requests(self, status: Optional[str] = None) -> List[InviteRequest]:
"""Get invite requests, optionally filtered by status."""
async with self.pool.acquire() as conn:
query = """
SELECT r.id, r.name, r.email, r.message, r.status, r.ip_address,
r.created_at, r.reviewed_at, r.reviewed_by,
u.username as reviewed_by_username
FROM invite_requests r
LEFT JOIN users_v2 u ON r.reviewed_by = u.id
"""
params = []
if status:
query += " WHERE r.status = $1"
params.append(status)
query += " ORDER BY r.created_at DESC"
rows = await conn.fetch(query, *params)
return [
InviteRequest(
id=row["id"],
name=row["name"],
email=row["email"],
message=row["message"],
status=row["status"],
ip_address=str(row["ip_address"]) if row["ip_address"] else None,
created_at=row["created_at"],
reviewed_at=row["reviewed_at"],
reviewed_by=str(row["reviewed_by"]) if row["reviewed_by"] else None,
reviewed_by_username=row["reviewed_by_username"],
)
for row in rows
]
async def approve_invite_request(
self,
request_id: int,
admin_id: str,
ip_address: Optional[str] = None,
) -> Optional[str]:
"""
Approve an invite request: create an invite code and update the request.
Returns:
The generated invite code, or None if request not found/already handled.
"""
async with self.pool.acquire() as conn:
# Verify request exists and is pending
row = await conn.fetchrow(
"SELECT id, email, name FROM invite_requests WHERE id = $1 AND status = 'pending'",
request_id,
)
if not row:
return None
# Create an invite code for this request
code = secrets.token_urlsafe(6).upper()[:8]
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
invite_id = await conn.fetchval(
"""
INSERT INTO invite_codes (code, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, 1)
RETURNING id
""",
code,
admin_id,
expires_at,
)
# Update the request
await conn.execute(
"""
UPDATE invite_requests
SET status = 'approved', reviewed_at = NOW(), reviewed_by = $1, invite_code_id = $2
WHERE id = $3
""",
admin_id,
invite_id,
request_id,
)
await self.audit(
admin_id,
"approve_invite_request",
"invite_request",
str(request_id),
{"email": row["email"], "invite_code": code},
ip_address,
)
logger.info(f"Admin {admin_id} approved invite request #{request_id}, code={code}")
return code
async def deny_invite_request(
self,
request_id: int,
admin_id: str,
ip_address: Optional[str] = None,
) -> Optional[dict]:
"""
Deny an invite request.
Returns:
The request info (name, email) or None if not found/already handled.
"""
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, email, name FROM invite_requests WHERE id = $1 AND status = 'pending'",
request_id,
)
if not row:
return None
await conn.execute(
"""
UPDATE invite_requests
SET status = 'denied', reviewed_at = NOW(), reviewed_by = $1
WHERE id = $2
""",
admin_id,
request_id,
)
await self.audit(
admin_id,
"deny_invite_request",
"invite_request",
str(request_id),
{"email": row["email"]},
ip_address,
)
logger.info(f"Admin {admin_id} denied invite request #{request_id}")
return {"name": row["name"], "email": row["email"]}
# Global admin service instance # Global admin service instance
_admin_service: Optional[AdminService] = None _admin_service: Optional[AdminService] = None

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Authentication service for Golf game. Authentication service for Golf game.
@@ -100,6 +101,7 @@ class AuthService:
password: str, password: str,
email: Optional[str] = None, email: Optional[str] = None,
guest_id: Optional[str] = None, guest_id: Optional[str] = None,
is_test_account: bool = False,
) -> RegistrationResult: ) -> RegistrationResult:
""" """
Register a new user account. Register a new user account.
@@ -109,6 +111,7 @@ class AuthService:
password: Plain text password. password: Plain text password.
email: Optional email address. email: Optional email address.
guest_id: Guest session ID if converting. guest_id: Guest session ID if converting.
is_test_account: Mark this user as a soak-harness test account.
Returns: Returns:
RegistrationResult with user or error. RegistrationResult with user or error.
@@ -150,6 +153,7 @@ class AuthService:
guest_id=guest_id, guest_id=guest_id,
verification_token=verification_token, verification_token=verification_token,
verification_expires=verification_expires, verification_expires=verification_expires,
is_test_account=is_test_account,
) )
if not user: if not user:

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Email service for Golf game authentication. Email service for Golf game authentication.
@@ -164,6 +165,76 @@ class EmailService:
return await self._send_email(to, subject, html) return await self._send_email(to, subject, html)
async def send_invite_request_admin_notification(
self,
to: str,
requester_name: str,
requester_email: str,
message: str,
) -> Optional[str]:
"""Notify admin of a new invite request."""
if not self.is_configured():
logger.info(f"Email not configured. Would send invite request notification to {to}")
return None
admin_url = f"{self.base_url}/admin.html"
message_html = f"<p><strong>Message:</strong> {message}</p>" if message else ""
subject = f"Golf Game invite request from {requester_name}"
html = f"""
<h2>New Invite Request</h2>
<p><strong>Name:</strong> {requester_name}</p>
<p><strong>Email:</strong> {requester_email}</p>
{message_html}
<p><a href="{admin_url}">Review in Admin Panel</a></p>
"""
return await self._send_email(to, subject, html)
async def send_invite_approved_email(
self,
to: str,
name: str,
invite_code: str,
) -> Optional[str]:
"""Notify requester that their invite was approved."""
if not self.is_configured():
logger.info(f"Email not configured. Would send invite approval to {to}")
return None
signup_url = f"{self.base_url}/?invite={invite_code}"
subject = "Your Golf Game invite is ready!"
html = f"""
<h2>You're In, {name}!</h2>
<p>Your request to join Golf Game has been approved.</p>
<p>Use this link to create your account:</p>
<p><a href="{signup_url}">{signup_url}</a></p>
<p>Or sign up manually with invite code: <strong>{invite_code}</strong></p>
<p>This invite is single-use and expires in 7 days.</p>
"""
return await self._send_email(to, subject, html)
async def send_invite_denied_email(
self,
to: str,
name: str,
) -> Optional[str]:
"""Notify requester that their invite was denied."""
if not self.is_configured():
logger.info(f"Email not configured. Would send invite denial to {to}")
return None
subject = "Golf Game invite request update"
html = f"""
<h2>Hi {name},</h2>
<p>Thanks for your interest in Golf Game. Unfortunately, we're not able to approve your invite request at this time.</p>
<p>We may open up registrations in the future — stay tuned!</p>
"""
return await self._send_email(to, subject, html)
async def _send_email( async def _send_email(
self, self,
to: str, to: str,

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
PostgreSQL-backed game logging for AI decision analysis. PostgreSQL-backed game logging for AI decision analysis.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Matchmaking service for public skill-based games. Matchmaking service for public skill-based games.

View File

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

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Glicko-2 rating service for Golf game matchmaking. Glicko-2 rating service for Golf game matchmaking.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Game recovery service for rebuilding active games from event store. Game recovery service for rebuilding active games from event store.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Replay service for Golf game. Replay service for Golf game.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Spectator manager for Golf game. Spectator manager for Golf game.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Stats service for Golf game leaderboards and achievements. Stats service for Golf game leaderboards and achievements.
@@ -170,6 +171,7 @@ class StatsService:
metric: str = "wins", metric: str = "wins",
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
include_test: bool = False,
) -> List[LeaderboardEntry]: ) -> List[LeaderboardEntry]:
""" """
Get leaderboard by metric. Get leaderboard by metric.
@@ -178,6 +180,8 @@ class StatsService:
metric: Ranking metric - wins, win_rate, avg_score, knockouts, streak. metric: Ranking metric - wins, win_rate, avg_score, knockouts, streak.
limit: Maximum entries to return. limit: Maximum entries to return.
offset: Pagination offset. offset: Pagination offset.
include_test: If True, include soak-harness test accounts. Default
False so real users never see synthetic load-test traffic.
Returns: Returns:
List of LeaderboardEntry sorted by metric. List of LeaderboardEntry sorted by metric.
@@ -211,9 +215,10 @@ class StatsService:
COALESCE(rating, 1500) as rating, COALESCE(rating, 1500) as rating,
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM leaderboard_overall FROM leaderboard_overall
WHERE ($3 OR NOT is_test_account)
ORDER BY {column} {direction} ORDER BY {column} {direction}
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
""", limit, offset) """, limit, offset, include_test)
else: else:
# Fall back to direct query # Fall back to direct query
rows = await conn.fetch(f""" rows = await conn.fetch(f"""
@@ -229,9 +234,10 @@ class StatsService:
WHERE s.games_played >= 5 WHERE s.games_played >= 5
AND u.deleted_at IS NULL AND u.deleted_at IS NULL
AND (u.is_banned = false OR u.is_banned IS NULL) AND (u.is_banned = false OR u.is_banned IS NULL)
AND ($3 OR NOT COALESCE(u.is_test_account, FALSE))
ORDER BY {column} {direction} ORDER BY {column} {direction}
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
""", limit, offset) """, limit, offset, include_test)
return [ return [
LeaderboardEntry( LeaderboardEntry(
@@ -245,16 +251,26 @@ class StatsService:
for row in rows for row in rows
] ]
async def get_player_rank(self, user_id: str, metric: str = "wins") -> Optional[int]: async def get_player_rank(
self,
user_id: str,
metric: str = "wins",
include_test: bool = False,
) -> Optional[int]:
""" """
Get a player's rank on a leaderboard. Get a player's rank on a leaderboard.
Args: Args:
user_id: User UUID. user_id: User UUID.
metric: Ranking metric. metric: Ranking metric.
include_test: If True, rank within a leaderboard that includes
soak-harness test accounts. Default False matches the public
leaderboard view.
Returns: Returns:
Rank number or None if not ranked (< 5 games or not found). Rank number or None if not ranked (< 5 games or not found, or
filtered out because they are a test account and include_test is
False).
""" """
order_map = { order_map = {
"wins": ("games_won", "DESC"), "wins": ("games_won", "DESC"),
@@ -287,9 +303,10 @@ class StatsService:
SELECT rank FROM ( SELECT rank FROM (
SELECT user_id, ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank SELECT user_id, ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM leaderboard_overall FROM leaderboard_overall
WHERE ($2 OR NOT is_test_account)
) ranked ) ranked
WHERE user_id = $1 WHERE user_id = $1
""", user_id) """, user_id, include_test)
else: else:
row = await conn.fetchrow(f""" row = await conn.fetchrow(f"""
SELECT rank FROM ( SELECT rank FROM (
@@ -299,9 +316,10 @@ class StatsService:
WHERE s.games_played >= 5 WHERE s.games_played >= 5
AND u.deleted_at IS NULL AND u.deleted_at IS NULL
AND (u.is_banned = false OR u.is_banned IS NULL) AND (u.is_banned = false OR u.is_banned IS NULL)
AND ($2 OR NOT COALESCE(u.is_test_account, FALSE))
) ranked ) ranked
WHERE user_id = $1 WHERE user_id = $1
""", user_id) """, user_id, include_test)
return row["rank"] if row else None return row["rank"] if row else None

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Golf AI Simulation Runner Golf AI Simulation Runner

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Stores package for Golf game V2 persistence.""" """Stores package for Golf game V2 persistence."""
from .event_store import EventStore, ConcurrencyError from .event_store import EventStore, ConcurrencyError

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
PostgreSQL-backed event store for Golf game. PostgreSQL-backed event store for Golf game.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Redis pub/sub for cross-server game events. Redis pub/sub for cross-server game events.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Redis-backed live game state cache. Redis-backed live game state cache.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
PostgreSQL-backed user store for Golf game authentication. PostgreSQL-backed user store for Golf game authentication.
@@ -94,6 +95,19 @@ BEGIN
WHERE table_name = 'users_v2' AND column_name = 'last_seen_at') THEN WHERE table_name = 'users_v2' AND column_name = 'last_seen_at') THEN
ALTER TABLE users_v2 ADD COLUMN last_seen_at TIMESTAMPTZ; ALTER TABLE users_v2 ADD COLUMN last_seen_at TIMESTAMPTZ;
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users_v2' AND column_name = 'is_test_account') THEN
ALTER TABLE users_v2 ADD COLUMN is_test_account BOOLEAN DEFAULT FALSE;
END IF;
END $$;
-- Add marks_as_test to invite_codes if not exists
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'invite_codes' AND column_name = 'marks_as_test') THEN
ALTER TABLE invite_codes ADD COLUMN marks_as_test BOOLEAN DEFAULT FALSE;
END IF;
END $$; END $$;
-- Admin audit log table -- Admin audit log table
@@ -132,6 +146,20 @@ CREATE TABLE IF NOT EXISTS invite_codes (
is_active BOOLEAN DEFAULT TRUE is_active BOOLEAN DEFAULT TRUE
); );
-- Invite requests table
CREATE TABLE IF NOT EXISTS invite_requests (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
message TEXT,
status VARCHAR(20) DEFAULT 'pending',
ip_address INET,
created_at TIMESTAMPTZ DEFAULT NOW(),
reviewed_at TIMESTAMPTZ,
reviewed_by UUID REFERENCES users_v2(id),
invite_code_id BIGINT REFERENCES invite_codes(id)
);
-- Player stats table (extended for V2 leaderboards) -- Player stats table (extended for V2 leaderboards)
CREATE TABLE IF NOT EXISTS player_stats ( CREATE TABLE IF NOT EXISTS player_stats (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@@ -281,14 +309,14 @@ CREATE TABLE IF NOT EXISTS system_metrics (
); );
-- Leaderboard materialized view (refreshed periodically) -- Leaderboard materialized view (refreshed periodically)
-- Drop and recreate if missing rating column (v3.1.0 migration) -- Drop and recreate if missing is_test_account column (soak harness migration)
DO $$ DO $$
BEGIN BEGIN
IF EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN IF EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
-- Check if rating column exists in the view -- Check if is_test_account column exists in the view
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_name = 'leaderboard_overall' AND column_name = 'rating' WHERE table_name = 'leaderboard_overall' AND column_name = 'is_test_account'
) THEN ) THEN
DROP MATERIALIZED VIEW leaderboard_overall; DROP MATERIALIZED VIEW leaderboard_overall;
END IF; END IF;
@@ -300,6 +328,7 @@ BEGIN
SELECT SELECT
u.id as user_id, u.id as user_id,
u.username, u.username,
COALESCE(u.is_test_account, FALSE) as is_test_account,
s.games_played, s.games_played,
s.games_won, s.games_won,
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate, ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
@@ -327,6 +356,8 @@ CREATE INDEX IF NOT EXISTS idx_users_reset ON users_v2(reset_token) WHERE reset_
CREATE INDEX IF NOT EXISTS idx_users_guest ON users_v2(guest_id) WHERE guest_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_users_guest ON users_v2(guest_id) WHERE guest_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_active ON users_v2(is_active) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_users_active ON users_v2(is_active) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_banned ON users_v2(is_banned) WHERE is_banned = TRUE; CREATE INDEX IF NOT EXISTS idx_users_banned ON users_v2(is_banned) WHERE is_banned = TRUE;
CREATE INDEX IF NOT EXISTS idx_users_test_account ON users_v2(is_test_account)
WHERE is_test_account = TRUE;
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON user_sessions(token_hash); CREATE INDEX IF NOT EXISTS idx_sessions_token ON user_sessions(token_hash);
@@ -445,6 +476,7 @@ class UserStore:
guest_id: Optional[str] = None, guest_id: Optional[str] = None,
verification_token: Optional[str] = None, verification_token: Optional[str] = None,
verification_expires: Optional[datetime] = None, verification_expires: Optional[datetime] = None,
is_test_account: bool = False,
) -> Optional[User]: ) -> Optional[User]:
""" """
Create a new user account. Create a new user account.
@@ -457,6 +489,7 @@ class UserStore:
guest_id: Guest session ID if converting. guest_id: Guest session ID if converting.
verification_token: Email verification token. verification_token: Email verification token.
verification_expires: Token expiration time. verification_expires: Token expiration time.
is_test_account: True for accounts created by the soak test harness.
Returns: Returns:
Created User, or None if username/email already exists. Created User, or None if username/email already exists.
@@ -466,12 +499,13 @@ class UserStore:
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
INSERT INTO users_v2 (username, password_hash, email, role, guest_id, INSERT INTO users_v2 (username, password_hash, email, role, guest_id,
verification_token, verification_expires) verification_token, verification_expires,
VALUES ($1, $2, $3, $4, $5, $6, $7) is_test_account)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, username, email, password_hash, role, email_verified, RETURNING id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires, verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset is_active, is_banned, ban_reason, force_password_reset, is_test_account
""", """,
username, username,
password_hash, password_hash,
@@ -480,6 +514,7 @@ class UserStore:
guest_id, guest_id,
verification_token, verification_token,
verification_expires, verification_expires,
is_test_account,
) )
return self._row_to_user(row) return self._row_to_user(row)
except asyncpg.UniqueViolationError: except asyncpg.UniqueViolationError:
@@ -493,7 +528,7 @@ class UserStore:
SELECT id, username, email, password_hash, role, email_verified, SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires, verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset is_active, is_banned, ban_reason, force_password_reset, is_test_account
FROM users_v2 FROM users_v2
WHERE id = $1 WHERE id = $1
""", """,
@@ -509,7 +544,7 @@ class UserStore:
SELECT id, username, email, password_hash, role, email_verified, SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires, verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset is_active, is_banned, ban_reason, force_password_reset, is_test_account
FROM users_v2 FROM users_v2
WHERE LOWER(username) = LOWER($1) WHERE LOWER(username) = LOWER($1)
""", """,
@@ -525,7 +560,7 @@ class UserStore:
SELECT id, username, email, password_hash, role, email_verified, SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires, verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset is_active, is_banned, ban_reason, force_password_reset, is_test_account
FROM users_v2 FROM users_v2
WHERE LOWER(email) = LOWER($1) WHERE LOWER(email) = LOWER($1)
""", """,
@@ -541,7 +576,7 @@ class UserStore:
SELECT id, username, email, password_hash, role, email_verified, SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires, verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset is_active, is_banned, ban_reason, force_password_reset, is_test_account
FROM users_v2 FROM users_v2
WHERE verification_token = $1 WHERE verification_token = $1
""", """,
@@ -557,7 +592,7 @@ class UserStore:
SELECT id, username, email, password_hash, role, email_verified, SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires, verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset is_active, is_banned, ban_reason, force_password_reset, is_test_account
FROM users_v2 FROM users_v2
WHERE reset_token = $1 WHERE reset_token = $1
""", """,
@@ -655,7 +690,7 @@ class UserStore:
RETURNING id, username, email, password_hash, role, email_verified, RETURNING id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires, verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset is_active, is_banned, ban_reason, force_password_reset, is_test_account
""" """
async with self.pool.acquire() as conn: async with self.pool.acquire() as conn:
@@ -699,7 +734,8 @@ class UserStore:
""" """
SELECT id, username, email, password_hash, role, email_verified, SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires, verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, is_active guest_id, deleted_at, preferences, created_at, last_login, is_active,
is_test_account
FROM users_v2 FROM users_v2
ORDER BY created_at DESC ORDER BY created_at DESC
""" """
@@ -709,7 +745,8 @@ class UserStore:
""" """
SELECT id, username, email, password_hash, role, email_verified, SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires, verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, is_active guest_id, deleted_at, preferences, created_at, last_login, is_active,
is_test_account
FROM users_v2 FROM users_v2
WHERE is_active = TRUE AND deleted_at IS NULL WHERE is_active = TRUE AND deleted_at IS NULL
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -1002,6 +1039,7 @@ class UserStore:
is_banned=row.get("is_banned", False) or False, is_banned=row.get("is_banned", False) or False,
ban_reason=row.get("ban_reason"), ban_reason=row.get("ban_reason"),
force_password_reset=row.get("force_password_reset", False) or False, force_password_reset=row.get("force_password_reset", False) or False,
is_test_account=row.get("is_test_account", False) or False,
) )
def _row_to_session(self, row: asyncpg.Record) -> UserSession: def _row_to_session(self, row: asyncpg.Record) -> UserSession:

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Test suite for AI decision sub-functions extracted from ai.py. Test suite for AI decision sub-functions extracted from ai.py.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Tests for the GameAnalyzer decision evaluation logic. Tests for the GameAnalyzer decision evaluation logic.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Tests for the authentication system. Tests for the authentication system.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Test suite for 6-Card Golf game rules. Test suite for 6-Card Golf game rules.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Test suite for WebSocket message handlers. Test suite for WebSocket message handlers.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
House Rules Testing Suite House Rules Testing Suite

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Test for the original Maya bug: Test for the original Maya bug:

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Test suite for Room and RoomManager CRUD operations. Test suite for Room and RoomManager CRUD operations.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Test suite for V3 features in 6-Card Golf. Test suite for V3 features in 6-Card Golf.

View File

@@ -1 +1,2 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests package for Golf game.""" """Tests package for Golf game."""

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Tests for event sourcing and state replay. Tests for event sourcing and state replay.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Tests for V2 Persistence & Recovery components. Tests for V2 Persistence & Recovery components.

View File

@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Tests for the replay service. Tests for the replay service.

View File

@@ -72,12 +72,20 @@ export class GolfBot {
} }
/** /**
* Create a new game room * Create a new game room.
*
* Works for both guest sessions (fills the name input) and
* authenticated sessions (the name input is hidden; the server
* uses the logged-in username). `playerName` is ignored when the
* session is authenticated.
*/ */
async createGame(playerName: string): Promise<string> { async createGame(playerName: string): Promise<string> {
// Enter name // Guest sessions have a visible player-name input; authenticated
// sessions don't. Only fill it if it's actually there.
const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput); const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput);
if (await nameInput.isVisible().catch(() => false)) {
await nameInput.fill(playerName); await nameInput.fill(playerName);
}
// Click create room // Click create room
const createBtn = this.page.locator(SELECTORS.lobby.createRoomBtn); const createBtn = this.page.locator(SELECTORS.lobby.createRoomBtn);
@@ -97,12 +105,16 @@ export class GolfBot {
} }
/** /**
* Join an existing game room * Join an existing game room.
*
* Same auth handling as `createGame` — `playerName` is ignored for
* authenticated sessions.
*/ */
async joinGame(roomCode: string, playerName: string): Promise<void> { async joinGame(roomCode: string, playerName: string): Promise<void> {
// Enter name
const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput); const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput);
if (await nameInput.isVisible().catch(() => false)) {
await nameInput.fill(playerName); await nameInput.fill(playerName);
}
// Enter room code // Enter room code
const codeInput = this.page.locator(SELECTORS.lobby.roomCodeInput); const codeInput = this.page.locator(SELECTORS.lobby.roomCodeInput);
@@ -172,7 +184,19 @@ export class GolfBot {
await this.page.selectOption(SELECTORS.waiting.numRounds, String(options.holes)); await this.page.selectOption(SELECTORS.waiting.numRounds, String(options.holes));
} }
if (options.decks) { if (options.decks) {
await this.page.selectOption(SELECTORS.waiting.numDecks, String(options.decks)); // #num-decks is a stepper (hidden input + +/- buttons), not a select.
// Click the stepper until the hidden input matches the requested value.
const target = options.decks;
for (let i = 0; i < 10; i++) {
const current = parseInt(
(await this.page.locator(SELECTORS.waiting.numDecks).inputValue().catch(() => '1')) || '1',
10,
);
if (current === target) break;
const btnId = current < target ? '#decks-plus' : '#decks-minus';
await this.page.locator(btnId).click({ timeout: 1000 }).catch(() => {});
await this.page.waitForTimeout(50);
}
} }
if (options.initialFlips !== undefined) { if (options.initialFlips !== undefined) {
await this.page.selectOption(SELECTORS.waiting.initialFlips, String(options.initialFlips)); await this.page.selectOption(SELECTORS.waiting.initialFlips, String(options.initialFlips));

View File

@@ -0,0 +1,6 @@
# Soak harness account cache.
# This file is AUTO-GENERATED on first run; do not edit by hand.
# Format: SOAK_ACCOUNT_NN=username:password:token
#
# Example (delete before first real run):
# SOAK_ACCOUNT_00=soak_00_a7bx:<generated-password>:<jwt-token>

5
tests/soak/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
artifacts/
.env.stresstest
*.log

64
tests/soak/CHECKLIST.md Normal file
View File

@@ -0,0 +1,64 @@
# Soak Harness Validation Checklist
Run after significant changes or before calling the harness implementation complete.
## Post-deploy schema verification
Run after the server-side changes deploy to each environment.
- [ ] Server restarted (docker compose up -d or CI/CD deploy)
- [ ] Server logs show `User store schema initialized` after restart
- [ ] `\d users_v2` shows `is_test_account` column with default `false`
- [ ] `\d invite_codes` shows `marks_as_test` column with default `false`
- [ ] `\d leaderboard_overall` shows `is_test_account` column
- [ ] `\di idx_users_test_account` shows the partial index
- [ ] Leaderboard query still works: `curl .../api/stats/leaderboard` returns entries
- [ ] `?include_test=true` parameter is accepted (no 422/500)
## Bring-up
- [ ] Invite code flagged with `marks_as_test=TRUE` on target environment
- [ ] `bun run seed` creates/updates accounts in `.env.stresstest`
- [ ] All seeded users show `is_test_account=TRUE` in the DB
## Smoke test
- [ ] `bash scripts/smoke.sh` exits 0 within 60s
## Scenarios
- [ ] `--scenario=populate --rooms=1 --games-per-room=1` completes cleanly
- [ ] `--scenario=populate --rooms=2 --games-per-room=2` runs multiple rooms and multiple games
- [ ] `--scenario=stress --games-per-room=3` logs `chaos_injected` events and completes
## Watch modes
- [ ] `--watch=none` produces JSONL on stdout, nothing else
- [ ] `--watch=dashboard` opens http://localhost:7777, grid renders, WS shows `healthy`
- [ ] Clicking a player tile opens the video modal with live JPEG frames
- [ ] Closing the modal (Esc or Close) stops the screencast (check logs for `screencast_stopped`)
- [ ] `--watch=tiled` opens native Chromium windows sized to show the full game table
## Failure handling
- [ ] Ctrl-C during a run → graceful shutdown, summary printed, exit code 2
- [ ] Double Ctrl-C → immediate hard exit (130)
- [ ] Health probes detect server down (3 consecutive failures → fatal abort)
- [ ] Artifacts directory contains screenshots + state JSON on failure
- [ ] Artifacts older than 7 days are pruned on next startup
## Server-side filtering
- [ ] `GET /api/stats/leaderboard` (default) hides soak accounts
- [ ] `GET /api/stats/leaderboard?include_test=true` shows soak accounts
- [ ] Admin panel user list shows `[Test]` badge on soak accounts
- [ ] Admin panel invite codes tab shows `[Test-seed]` badge
- [ ] "Include test accounts" checkbox toggles visibility in admin
## Staging bring-up
- [ ] `5VC2MCCN` flagged with `marks_as_test=TRUE` on staging DB
- [ ] 16 accounts seeded via `SOAK_INVITE_CODE=5VC2MCCN bun run seed`
- [ ] Populate run against staging completes with `--watch=dashboard`
- [ ] Staging leaderboard default does NOT show soak accounts
- [ ] Staging leaderboard with `?include_test=true` does show them

296
tests/soak/README.md Normal file
View File

@@ -0,0 +1,296 @@
# Golf Soak & UX Test Harness
Standalone Playwright-based runner that drives multiple authenticated
browser sessions playing real multiplayer games. Used for:
- **Scoreboard population** — fill staging leaderboards with realistic data
- **Stability stress testing** — hunt race conditions, WebSocket leaks, cleanup bugs
- **Live monitoring** — watch bot sessions play in real time via CDP screencast
## Prerequisites
- [Bun](https://bun.sh/) (or Node.js + npm)
- Chromium browser binary (installed via `bunx playwright install chromium`)
- A running Golf Card Game server (local dev or staging)
- An invite code flagged as `marks_as_test=TRUE` (see [Bring-up](#first-time-setup))
## First-time setup
### 1. Install dependencies
```bash
cd tests/soak
bun install
bunx playwright install chromium
```
### 2. Flag the invite code as test-seed
Any account registered with a test-seed invite gets `is_test_account=TRUE`,
which keeps it out of real-user stats and leaderboards.
**Local dev:**
```bash
PGPASSWORD=devpassword psql -h localhost -U golf -d golf <<'SQL'
INSERT INTO invite_codes (code, created_by, expires_at, max_uses, is_active, marks_as_test)
SELECT 'SOAKTEST', id, NOW() + INTERVAL '10 years', 100, TRUE, TRUE
FROM users_v2 LIMIT 1
ON CONFLICT (code) DO UPDATE SET marks_as_test = TRUE;
SQL
```
**Staging:**
```bash
ssh root@129.212.150.189 \
'docker compose -f /opt/golfgame/docker-compose.staging.yml exec -T postgres psql -U postgres -d golfgame' <<'SQL'
UPDATE invite_codes SET marks_as_test = TRUE WHERE code = '5VC2MCCN';
SQL
```
### 3. Seed test accounts
```bash
# Local dev
TEST_URL=http://localhost:8000 SOAK_INVITE_CODE=SOAKTEST bun run seed
# Staging
TEST_URL=https://staging.adlee.work SOAK_INVITE_CODE=5VC2MCCN bun run seed
```
This registers 16 accounts via the invite code and caches their credentials
in `.env.stresstest`. Only needs to run once — subsequent runs reuse the
cached credentials (re-logging in if tokens expire).
### 4. Verify with a smoke test
```bash
# Local dev
TEST_URL=http://localhost:8000 SOAK_INVITE_CODE=SOAKTEST bash scripts/smoke.sh
```
Expected: one game plays to completion in ~60 seconds, exits 0.
## Usage
### Populate scoreboards (recommended first run)
```bash
TEST_URL=https://staging.adlee.work SOAK_INVITE_CODE=5VC2MCCN bun run soak -- \
--scenario=populate \
--watch=dashboard
```
This runs 4 rooms x 10 games x 9 holes with varied CPU personalities.
The dashboard opens automatically at `http://localhost:7777`.
### Quick smoke against staging
```bash
TEST_URL=https://staging.adlee.work SOAK_INVITE_CODE=5VC2MCCN bun run soak -- \
--scenario=populate \
--accounts=2 --rooms=1 --cpus-per-room=0 \
--games-per-room=1 --holes=1 \
--watch=dashboard
```
### Stress test with chaos injection
```bash
TEST_URL=https://staging.adlee.work SOAK_INVITE_CODE=5VC2MCCN bun run soak -- \
--scenario=stress \
--accounts=4 --rooms=1 --games-per-room=5 \
--watch=dashboard
```
Rapid 1-hole games with random chaos events (rapid clicks, tab blur,
brief network outage) injected during gameplay.
### Headless mode (CI / overnight)
```bash
TEST_URL=https://staging.adlee.work SOAK_INVITE_CODE=5VC2MCCN bun run soak -- \
--scenario=populate --watch=none
```
Outputs structured JSONL to stdout. Pipe to `jq` for filtering:
```bash
bun run soak -- --scenario=populate --watch=none 2>&1 | jq 'select(.msg == "game_complete")'
```
### Tiled mode (native browser windows)
```bash
bun run soak -- --scenario=populate --rooms=2 --watch=tiled
```
Opens visible Chromium windows for each room's host session. Useful for
hands-on debugging with DevTools.
## CLI flags
```
--scenario=populate|stress required — which scenario to run
--accounts=<n> total sessions (default: from scenario)
--rooms=<n> parallel rooms (default: from scenario)
--cpus-per-room=<n> CPU opponents per room (default: from scenario)
--games-per-room=<n> games per room (default: from scenario)
--holes=<n> holes per game (default: from scenario)
--watch=none|dashboard|tiled visualization mode (default: dashboard)
--dashboard-port=<n> dashboard server port (default: 7777)
--target=<url> override TEST_URL env var
--run-id=<string> custom run identifier (default: timestamp)
--list print available scenarios and exit
--dry-run validate config without running
```
`accounts / rooms` must divide evenly.
## Environment variables
| Variable | Description | Default |
|---|---|---|
| `TEST_URL` | Target server base URL | `http://localhost:8000` |
| `SOAK_INVITE_CODE` | Invite code for account seeding | `SOAKTEST` |
| `SOAK_HOLES` | Override `--holes` | — |
| `SOAK_ROOMS` | Override `--rooms` | — |
| `SOAK_ACCOUNTS` | Override `--accounts` | — |
| `SOAK_CPUS_PER_ROOM` | Override `--cpus-per-room` | — |
| `SOAK_GAMES_PER_ROOM` | Override `--games-per-room` | — |
| `SOAK_WATCH` | Override `--watch` | — |
| `SOAK_DASHBOARD_PORT` | Override `--dashboard-port` | — |
Config precedence: CLI flags > env vars > scenario defaults.
## Watch modes
### `dashboard` (default)
Opens `http://localhost:7777` with a live status grid:
- 2x2 room tiles showing phase, current player, move count, progress bar
- Activity log at the bottom
- **Click any player tile** to watch their live session via CDP screencast
- Press Esc or click Close to stop the video feed
- WS connection status indicator
The dashboard runs **locally on your machine** — the runner's headless
browsers connect to the target server remotely while the dashboard UI
is served from your workstation.
### `tiled`
Opens native Chromium windows for each room's host session, positioned
in a grid. Joiners stay headless. Useful for interactive debugging with
DevTools. The viewport is sized at 960x900 to show the full game table.
### `none`
Pure headless, structured JSONL to stdout. Use for CI, overnight runs,
or piping to `jq`.
## Scenarios
### `populate`
Long multi-round games to populate scoreboards with realistic data.
| Setting | Default |
|---|---|
| Accounts | 16 |
| Rooms | 4 |
| CPUs per room | 1 |
| Games per room | 10 |
| Holes | 9 |
| Decks | 2 |
| Think time | 800-2200ms |
### `stress`
Rapid short games with chaos injection for stability testing.
| Setting | Default |
|---|---|
| Accounts | 16 |
| Rooms | 4 |
| CPUs per room | 2 |
| Games per room | 50 |
| Holes | 1 |
| Decks | 1 |
| Think time | 50-150ms |
| Chaos chance | 5% per turn |
Chaos events: `rapid_clicks`, `tab_blur`, `brief_offline`
### Adding new scenarios
Create `scenarios/<name>.ts` exporting a `Scenario` object, then register
it in `scenarios/index.ts`. See existing scenarios for the pattern.
## Error handling
- **Per-room isolation**: a failure in one room never unwinds other rooms
(`Promise.allSettled`)
- **Watchdog**: 60s per-room timeout — fires if no heartbeat arrives
- **Health probes**: `GET /health` every 30s, 3 consecutive failures = fatal abort
- **Graceful shutdown**: Ctrl-C finishes current turn, then cleans up (10s timeout).
Double Ctrl-C = immediate force exit
- **Artifacts**: on failure, screenshots + HTML + game state JSON saved to
`artifacts/<run-id>/`. Old artifacts auto-pruned after 7 days
- **Exit codes**: `0` = success, `1` = errors, `2` = interrupted
## Test account filtering
Soak accounts are flagged `is_test_account=TRUE` in the database. They are:
- **Hidden by default** from public leaderboards and stats (`?include_test=false`)
- **Visible to admins** by default in the admin panel
- **Togglable** via the "Include test accounts" checkbox in the admin panel
- **Badged** with `[Test]` in the admin user list and `[Test-seed]` on the invite code
## Unit tests
```bash
bun run test
```
27 tests covering Deferred, RoomCoordinator, Watchdog, Logger, and Config.
Integration-level modules (SessionPool, scenarios, dashboard) are verified
by the smoke test and live runs.
## Architecture
```
runner.ts CLI entry — parses flags, wires everything, runs scenario
core/
session-pool.ts Owns browser contexts, seeds/logs in accounts
room-coordinator Deferred-based host→joiners room code handoff
watchdog.ts Per-room timeout detector
screencaster.ts CDP Page.startScreencast for live video
logger.ts Structured JSONL logger with child contexts
artifacts.ts Screenshot/HTML/state capture on failure
types.ts Scenario/Session/Logger contracts
scenarios/
populate.ts Long multi-round games
stress.ts Rapid games with chaos injection
shared/
multiplayer-game.ts Shared "play one game" loop
chaos.ts Chaos event injector
dashboard/
server.ts HTTP + WS server
index.html Status grid UI
dashboard.js WS client + click-to-watch
scripts/
seed-accounts.ts Account seeding CLI
smoke.sh End-to-end canary (~60s)
```
Reuses `tests/e2e/bot/golf-bot.ts` unchanged for all game interactions.
## Related docs
- [Design spec](../../docs/superpowers/specs/2026-04-10-multiplayer-soak-test-design.md)
- [Bring-up steps](../../docs/soak-harness-bringup.md)
- [Implementation plan](../../docs/superpowers/plans/2026-04-10-multiplayer-soak-test.md)

344
tests/soak/bun.lock Normal file
View File

@@ -0,0 +1,344 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "golf-soak",
"dependencies": {
"playwright-core": "^1.40.0",
"ws": "^8.16.0",
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.10.0",
"@types/ws": "^8.5.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0",
"vitest": "^1.2.0",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
"@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vitest/expect": ["@vitest/expect@1.6.1", "", { "dependencies": { "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "chai": "^4.3.10" } }, "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog=="],
"@vitest/runner": ["@vitest/runner@1.6.1", "", { "dependencies": { "@vitest/utils": "1.6.1", "p-limit": "^5.0.0", "pathe": "^1.1.1" } }, "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA=="],
"@vitest/snapshot": ["@vitest/snapshot@1.6.1", "", { "dependencies": { "magic-string": "^0.30.5", "pathe": "^1.1.1", "pretty-format": "^29.7.0" } }, "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ=="],
"@vitest/spy": ["@vitest/spy@1.6.1", "", { "dependencies": { "tinyspy": "^2.2.0" } }, "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw=="],
"@vitest/utils": ["@vitest/utils@1.6.1", "", { "dependencies": { "diff-sequences": "^29.6.3", "estree-walker": "^3.0.3", "loupe": "^2.3.7", "pretty-format": "^29.7.0" } }, "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"chai": ["chai@4.5.0", "", { "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", "deep-eql": "^4.1.3", "get-func-name": "^2.0.2", "loupe": "^2.3.6", "pathval": "^1.1.1", "type-detect": "^4.1.0" } }, "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw=="],
"check-error": ["check-error@1.0.3", "", { "dependencies": { "get-func-name": "^2.0.2" } }, "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg=="],
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-eql": ["deep-eql@4.1.4", "", { "dependencies": { "type-detect": "^4.0.0" } }, "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-func-name": ["get-func-name@2.0.2", "", {}, "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ=="],
"get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
"get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
"is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"local-pkg": ["local-pkg@0.5.1", "", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="],
"loupe": ["loupe@2.3.7", "", { "dependencies": { "get-func-name": "^2.0.1" } }, "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"pathval": ["pathval@1.1.1", "", {}, "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"strip-literal": ["strip-literal@2.1.1", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinypool": ["tinypool@0.8.4", "", {}, "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ=="],
"tinyspy": ["tinyspy@2.2.1", "", {}, "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"type-detect": ["type-detect@4.1.0", "", {}, "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vite-node": ["vite-node@1.6.1", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.4", "pathe": "^1.1.1", "picocolors": "^1.0.0", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA=="],
"vitest": ["vitest@1.6.1", "", { "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", "@vitest/snapshot": "1.6.1", "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", "execa": "^8.0.1", "local-pkg": "^0.5.0", "magic-string": "^0.30.5", "pathe": "^1.1.1", "picocolors": "^1.0.0", "std-env": "^3.5.0", "strip-literal": "^2.0.0", "tinybench": "^2.5.1", "tinypool": "^0.8.3", "vite": "^5.0.0", "vite-node": "1.6.1", "why-is-node-running": "^2.2.2" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "1.6.1", "@vitest/ui": "1.6.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
"mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
}
}

125
tests/soak/config.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* CLI flag parsing and config precedence for the soak runner.
*
* Precedence (later wins):
* runner defaults → scenario.defaultConfig → env vars → CLI flags
*/
export type WatchMode = 'none' | 'dashboard' | 'tiled';
export interface CliArgs {
scenario?: string;
accounts?: number;
rooms?: number;
cpusPerRoom?: number;
gamesPerRoom?: number;
holes?: number;
watch?: WatchMode;
dashboardPort?: number;
target?: string;
runId?: string;
dryRun?: boolean;
listOnly?: boolean;
}
const VALID_WATCH: WatchMode[] = ['none', 'dashboard', 'tiled'];
function parseInt10(s: string, name: string): number {
const n = parseInt(s, 10);
if (Number.isNaN(n)) throw new Error(`Invalid integer for ${name}: ${s}`);
return n;
}
export function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = {};
for (const arg of argv) {
if (arg === '--list') {
out.listOnly = true;
continue;
}
if (arg === '--dry-run') {
out.dryRun = true;
continue;
}
const m = arg.match(/^--([a-z][a-z0-9-]*)=(.*)$/);
if (!m) continue;
const [, key, value] = m;
switch (key) {
case 'scenario':
out.scenario = value;
break;
case 'accounts':
out.accounts = parseInt10(value, '--accounts');
break;
case 'rooms':
out.rooms = parseInt10(value, '--rooms');
break;
case 'cpus-per-room':
out.cpusPerRoom = parseInt10(value, '--cpus-per-room');
break;
case 'games-per-room':
out.gamesPerRoom = parseInt10(value, '--games-per-room');
break;
case 'holes':
out.holes = parseInt10(value, '--holes');
break;
case 'watch':
if (!VALID_WATCH.includes(value as WatchMode)) {
throw new Error(
`Invalid --watch value: ${value} (expected ${VALID_WATCH.join('|')})`,
);
}
out.watch = value as WatchMode;
break;
case 'dashboard-port':
out.dashboardPort = parseInt10(value, '--dashboard-port');
break;
case 'target':
out.target = value;
break;
case 'run-id':
out.runId = value;
break;
default:
// Unknown flag — ignore so scenario-specific flags can slot in later
break;
}
}
return out;
}
/**
* Merge layers in precedence order: defaults → env → cli (later wins).
*/
export function mergeConfig(
cli: Record<string, unknown>,
env: Record<string, string | undefined>,
defaults: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...defaults };
// Env overlay — SOAK_UPPER_SNAKE → lowerCamel in cli space.
const envMap: Record<string, string> = {
SOAK_HOLES: 'holes',
SOAK_ROOMS: 'rooms',
SOAK_ACCOUNTS: 'accounts',
SOAK_CPUS_PER_ROOM: 'cpusPerRoom',
SOAK_GAMES_PER_ROOM: 'gamesPerRoom',
SOAK_WATCH: 'watch',
SOAK_DASHBOARD_PORT: 'dashboardPort',
};
const numericKeys = /^(holes|rooms|accounts|cpusPerRoom|gamesPerRoom|dashboardPort)$/;
for (const [envKey, cfgKey] of Object.entries(envMap)) {
const v = env[envKey];
if (v !== undefined) {
merged[cfgKey] = numericKeys.test(cfgKey) ? parseInt(v, 10) : v;
}
}
// CLI overlay — wins over env and defaults.
for (const [k, v] of Object.entries(cli)) {
if (v !== undefined) merged[k] = v;
}
return merged;
}

View File

@@ -0,0 +1,121 @@
/**
* Artifacts — capture session debugging info on scenario failure.
*
* When runner.ts hits an unrecoverable error during a scenario, it
* calls `artifacts.captureAll(liveSessions)` which dumps one
* screenshot + HTML snapshot + game state JSON + console tail per
* session into `tests/soak/artifacts/<run-id>/`.
*
* Successful runs get a lightweight `summary.json` written at the
* same path so post-run inspection has something to grep.
*
* `pruneOldRuns` sweeps run dirs older than maxAgeMs on startup so
* the artifacts directory doesn't grow unbounded.
*/
import * as fs from 'fs';
import * as path from 'path';
import type { Session, Logger } from './types';
export interface ArtifactsOptions {
runId: string;
/** Absolute path to the artifacts root, e.g. /path/to/tests/soak/artifacts */
rootDir: string;
logger: Logger;
}
export class Artifacts {
readonly runDir: string;
constructor(private opts: ArtifactsOptions) {
this.runDir = path.join(opts.rootDir, opts.runId);
fs.mkdirSync(this.runDir, { recursive: true });
}
/** Capture screenshot + HTML + state + console tail for one session. */
async captureSession(session: Session, roomId: string): Promise<void> {
const dir = path.join(this.runDir, roomId);
fs.mkdirSync(dir, { recursive: true });
const prefix = session.key;
try {
const png = await session.page.screenshot({ fullPage: true });
fs.writeFileSync(path.join(dir, `${prefix}.png`), png);
} catch (err) {
this.opts.logger.warn('artifact_screenshot_failed', {
session: session.key,
error: err instanceof Error ? err.message : String(err),
});
}
try {
const html = await session.page.content();
fs.writeFileSync(path.join(dir, `${prefix}.html`), html);
} catch (err) {
this.opts.logger.warn('artifact_html_failed', {
session: session.key,
error: err instanceof Error ? err.message : String(err),
});
}
try {
const state = await session.bot.getGameState();
fs.writeFileSync(
path.join(dir, `${prefix}.state.json`),
JSON.stringify(state, null, 2),
);
} catch (err) {
this.opts.logger.warn('artifact_state_failed', {
session: session.key,
error: err instanceof Error ? err.message : String(err),
});
}
try {
const errors = session.bot.getConsoleErrors?.() ?? [];
fs.writeFileSync(path.join(dir, `${prefix}.console.txt`), errors.join('\n'));
} catch {
// ignore — not all bot flavors expose console errors
}
}
/**
* Best-effort capture for every live session. We don't know which
* room each session belongs to at this level, so everything lands
* under `room-unknown/` unless callers partition sessions first.
*/
async captureAll(sessions: Session[]): Promise<void> {
await Promise.all(
sessions.map((s) => this.captureSession(s, 'room-unknown')),
);
}
writeSummary(summary: object): void {
fs.writeFileSync(
path.join(this.runDir, 'summary.json'),
JSON.stringify(summary, null, 2),
);
}
}
/** Prune run directories older than `maxAgeMs`. Called on runner startup. */
export function pruneOldRuns(
rootDir: string,
maxAgeMs: number,
logger: Logger,
): void {
if (!fs.existsSync(rootDir)) return;
const now = Date.now();
for (const entry of fs.readdirSync(rootDir)) {
const full = path.join(rootDir, entry);
try {
const stat = fs.statSync(full);
if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) {
fs.rmSync(full, { recursive: true, force: true });
logger.info('artifact_pruned', { runId: entry });
}
} catch {
// ignore — best effort
}
}
}

View File

@@ -0,0 +1,20 @@
/**
* Promise deferred primitive — lets external code resolve or reject
* a promise. Used by RoomCoordinator for host→joiners handoff.
*/
export interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: unknown): void;
}
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

59
tests/soak/core/logger.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* Structured JSONL logger for the soak harness.
*
* One JSON line per call, written to stdout by default. Child loggers
* inherit parent meta so scenarios can bind room/game context once and
* every subsequent call carries it automatically.
*/
import type { Logger, LogLevel } from './types';
const LEVEL_ORDER: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
export interface LoggerOptions {
runId: string;
minLevel?: LogLevel;
/** Defaults to process.stdout.write bound to stdout. Override for tests. */
write?: (line: string) => boolean;
baseMeta?: Record<string, unknown>;
}
export function createLogger(opts: LoggerOptions): Logger {
const minLevel = opts.minLevel ?? 'info';
const write = opts.write ?? ((s: string) => process.stdout.write(s));
const baseMeta = opts.baseMeta ?? {};
function emit(level: LogLevel, msg: string, meta?: object): void {
if (LEVEL_ORDER[level] < LEVEL_ORDER[minLevel]) return;
const line = JSON.stringify({
timestamp: new Date().toISOString(),
level,
msg,
runId: opts.runId,
...baseMeta,
...(meta ?? {}),
}) + '\n';
write(line);
}
const logger: Logger = {
debug: (msg, meta) => emit('debug', msg, meta),
info: (msg, meta) => emit('info', msg, meta),
warn: (msg, meta) => emit('warn', msg, meta),
error: (msg, meta) => emit('error', msg, meta),
child: (meta) =>
createLogger({
runId: opts.runId,
minLevel,
write,
baseMeta: { ...baseMeta, ...meta },
}),
};
return logger;
}

View File

@@ -0,0 +1,42 @@
/**
* RoomCoordinator — tiny host→joiners handoff primitive.
*
* Lazy Deferred per roomId. `announce` resolves the promise; `await`
* blocks until the promise resolves or a per-call timeout fires.
* Rooms are keyed by string; each key has at most one Deferred.
*/
import { deferred, Deferred } from './deferred';
import type { RoomCoordinatorApi } from './types';
export class RoomCoordinator implements RoomCoordinatorApi {
private rooms = new Map<string, Deferred<string>>();
announce(roomId: string, code: string): void {
this.getOrCreate(roomId).resolve(code);
}
async await(roomId: string, timeoutMs: number = 30_000): Promise<string> {
const d = this.getOrCreate(roomId);
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`RoomCoordinator: room "${roomId}" timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
try {
return await Promise.race([d.promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
private getOrCreate(roomId: string): Deferred<string> {
let d = this.rooms.get(roomId);
if (!d) {
d = deferred<string>();
this.rooms.set(roomId, d);
}
return d;
}
}

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