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

@@ -754,11 +754,24 @@ async def broadcast_game_state(room: Room):
# Process game completion BEFORE the per-player loop so it runs exactly
# once and isn't gated on any player still being connected.
if room.game.phase == GamePhase.GAME_OVER:
# Determine winner (lowest total; None on tie) so games_v2.winner_id
# is actually populated and stats/rating agree with each other.
winner_id: Optional[str] = None
if room.game.players:
lowest = min(p.total_score for p in room.game.players)
leaders = [p for p in room.game.players if p.total_score == lowest]
if len(leaders) == 1:
winner_id = leaders[0].id
game_logger = get_logger()
if game_logger and room.game_log_id:
game_logger.log_game_end(room.game_log_id)
game_logger.log_game_end(room.game_log_id, winner_id=winner_id)
room.game_log_id = None
if _stats_service and room.game.players:
# Idempotency: latch on room so repeat GAME_OVER broadcasts don't
# double-count. Set before scheduling the task — the task itself is
# fire-and-forget and might outlive this function.
if _stats_service and room.game.players and not room.stats_processed:
room.stats_processed = True
asyncio.create_task(_process_stats_safe(room))
for pid, player in list(room.players.items()):