fix(server): mark games abandoned on room teardown + staging leaderboard

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>
This commit is contained in:
adlee-was-taken
2026-04-18 00:37:49 -04:00
parent 70498b1c33
commit d5f8eef6b3
7 changed files with 230 additions and 3 deletions

View File

@@ -164,6 +164,24 @@ class GameLogger:
# Not in async context - skip (simulations don't need this)
pass
async def log_game_abandoned_async(self, game_id: str) -> None:
"""Mark game as abandoned (room emptied before GAME_OVER)."""
try:
await self.event_store.update_game_abandoned(game_id)
log.debug(f"Logged game abandoned: {game_id}")
except Exception as e:
log.error(f"Failed to log game abandoned: {e}")
def log_game_abandoned(self, game_id: str) -> None:
"""Sync wrapper: fires async task in async context, no-op otherwise."""
if not game_id:
return
try:
asyncio.get_running_loop()
asyncio.create_task(self.log_game_abandoned_async(game_id))
except RuntimeError:
pass
# -------------------------------------------------------------------------
# Move Logging
# -------------------------------------------------------------------------