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

@@ -73,6 +73,10 @@ class Room:
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
cpu_turn_task: Optional[asyncio.Task] = None
last_activity: float = field(default_factory=time.time)
# Latched True after _process_stats_safe fires for this game; prevents
# double-counting if broadcast_game_state is invoked multiple times
# with phase=GAME_OVER (double-click on next-round, reconnect flush).
stats_processed: bool = False
def touch(self) -> None:
"""Update last_activity timestamp to mark room as active."""