Two issues in the GAME_OVER broadcast path:
1. log_game_end called update_game_completed with winner_id=None default,
so games_v2.winner_id was NULL on all 17 completed staging rows. The
denormalized column existed but carried no information. Compute winner
(lowest total; None on tie) in broadcast_game_state and thread through.
2. _process_stats_safe had no idempotency guard. log_game_end was already
self-guarding via game_log_id=None after first fire, but nothing
stopped repeated GAME_OVER broadcasts from re-firing stats and
double-counting games_played/games_won. Add Room.stats_processed latch;
reset it in handle_start_game so a re-used room still records.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
update_game_started (started_at, num_players, num_rounds, player_ids)
was defined in event_store but had zero callers. 289/289 staging games
had those fields NULL — queries that joined on them returned garbage,
and the denormalized player_ids GIN index was dead weight.
log_game_start now calls create_game THEN update_game_started in one
async task. If create fails, update is skipped (row doesn't exist).
handlers.py passes num_rounds and player_ids through at call time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When handle_player_leave emptied a room or handle_end_game was invoked,
the room was removed from memory without touching games_v2. Periodic
cleanup only scans in-memory rooms, so those rows were stranded as
status='active' forever — staging had 42 orphans accumulated over 5h.
- event_store.update_game_abandoned: guarded UPDATE (status='active' only)
- GameLogger.log_game_abandoned{,_async}: fire-and-forget wrapper
- handle_end_game + handle_player_leave: flip status before remove_room
- LEADERBOARD_INCLUDE_TEST_DEFAULT: env override so staging can show
soak-harness accounts by default; prod keeps them hidden
Verified on staging: 42 orphans swept on restart, soak accounts now
visible on /api/stats/leaderboard (rank 1-4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three bugs prevented game stats from recording:
1. broadcast_game_state had game_over processing (log_game_end + stats)
inside the per-player loop — if all players disconnected before the
loop ran, stats never processed. Moved to run once before the loop.
2. room.broadcast and broadcast_game_state iterated players.items()
without snapshotting, causing RuntimeError when concurrent player
disconnects mutated the dict. Fixed with list().
3. stats_service.process_game_from_state passed avg_round_score to a
CASE expression without a type hint, causing asyncpg to fail with
"could not determine data type of parameter $6". Added ::integer
casts.
Also wrapped per-player send_json calls in try/except so a single
disconnected player doesn't abort the broadcast to remaining players.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
- Enforce invite codes on registration (INVITE_ONLY=true by default)
- Bootstrap admin account for first-time setup
- Require authentication for WebSocket connections and room creation
- Add Glicko-2 rating system with multiplayer pairwise comparisons
- Add Redis-backed matchmaking queue with expanding rating window
- Auto-start matched games with standard rules after countdown
- Add "Find Game" button and matchmaking UI to client
- Add rating column to leaderboard
- Scale down docker-compose.prod.yml for 512MB droplet
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Only standard-rules games now count toward leaderboard stats. Games
with any house rule variant are marked "Unranked" in the active rules
bar, and a notice appears in the lobby when house rules are selected.
Also fixes game_logger duplicate options dicts (now uses dataclasses.asdict,
capturing all options including previously missing ones) and refactors
duplicated achievement-checking logic into shared helpers.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add GameLogger service for move logging to PostgreSQL
- Add moves table to event_store.py for AI decision analysis
- Update main.py to initialize GameLogger in lifespan
- Update game_analyzer.py to query PostgreSQL instead of SQLite
- Add VDD documentation V2_08_GAME_LOGGING.md
Replaces SQLite game_log.py with unified PostgreSQL backend.
See docs/v2/V2_08_GAME_LOGGING.md for architecture and API.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Phase 1 - Critical Fixes:
- Add game_lock (asyncio.Lock) to Room class for serializing mutations
- Wrap all game action handlers in lock to prevent race conditions
- Split Card.to_dict into to_dict (full data) and to_client_dict (hidden)
- Fix CardState.from_dict to handle missing rank/suit gracefully
- Fix GameOptions reconstruction in recovery_service (dict -> object)
- Extend state cache TTL from 4h to 24h, add touch_game method
Phase 2 - Security:
- Add optional WebSocket authentication via token query param
- Use authenticated user ID/name when available
- Add auth support to spectator WebSocket endpoint
Phase 3 - Performance:
- Make stats processing async (fire-and-forget) to avoid blocking
game completion notifications
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>