fix(server): winner_id on completed games + stats idempotency latch

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>
This commit is contained in:
adlee-was-taken
2026-04-18 00:47:53 -04:00
parent 8030a3c171
commit c02b0054c2
5 changed files with 117 additions and 28 deletions

View File

@@ -143,20 +143,22 @@ class GameLogger:
)
)
async def log_game_end_async(self, game_id: str) -> None:
async def log_game_end_async(
self,
game_id: str,
winner_id: Optional[str] = None,
) -> None:
"""
Mark game as ended.
Args:
game_id: Game UUID.
Mark game as ended. winner_id is the player who finished with the
lowest total — None when tied or when the caller doesn't have it.
"""
try:
await self.event_store.update_game_completed(game_id)
log.debug(f"Logged game end: {game_id}")
await self.event_store.update_game_completed(game_id, winner_id)
log.debug(f"Logged game end: {game_id} winner={winner_id}")
except Exception as e:
log.error(f"Failed to log game end: {e}")
def log_game_end(self, game_id: str) -> None:
def log_game_end(self, game_id: str, winner_id: Optional[str] = None) -> None:
"""
Sync wrapper for log_game_end_async.
@@ -166,8 +168,8 @@ class GameLogger:
return
try:
loop = asyncio.get_running_loop()
asyncio.create_task(self.log_game_end_async(game_id))
asyncio.get_running_loop()
asyncio.create_task(self.log_game_end_async(game_id, winner_id))
except RuntimeError:
# Not in async context - skip (simulations don't need this)
pass