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>