fix(server): game completion pipeline — stats recording + dict iteration safety

Three bugs prevented game stats from recording:

1. broadcast_game_state had game_over processing (log_game_end + stats)
   inside the per-player loop — if all players disconnected before the
   loop ran, stats never processed. Moved to run once before the loop.

2. room.broadcast and broadcast_game_state iterated players.items()
   without snapshotting, causing RuntimeError when concurrent player
   disconnects mutated the dict. Fixed with list().

3. stats_service.process_game_from_state passed avg_round_score to a
   CASE expression without a type hint, causing asyncpg to fail with
   "could not determine data type of parameter $6". Added ::integer
   casts.

Also wrapped per-player send_json calls in try/except so a single
disconnected player doesn't abort the broadcast to remaining players.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-17 20:37:08 -04:00
parent d5194f43ba
commit ccc2f3b559
3 changed files with 49 additions and 42 deletions

View File

@@ -751,16 +751,29 @@ async def broadcast_game_state(room: Room):
spectator_state = room.game.get_state(None) # No player perspective
await _spectator_manager.send_game_state(room.code, spectator_state)
for pid, player in room.players.items():
# 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:
game_logger = get_logger()
if game_logger and room.game_log_id:
game_logger.log_game_end(room.game_log_id)
room.game_log_id = None
if _stats_service and room.game.players:
asyncio.create_task(_process_stats_safe(room))
for pid, player in list(room.players.items()):
# Skip CPU players
if player.is_cpu or not player.websocket:
continue
game_state = room.game.get_state(pid)
await player.websocket.send_json({
"type": "game_state",
"game_state": game_state,
})
try:
await player.websocket.send_json({
"type": "game_state",
"game_state": game_state,
})
except Exception:
continue
# Check for round over
if room.game.phase == GamePhase.ROUND_OVER:
@@ -768,47 +781,41 @@ async def broadcast_game_state(room: Room):
{"id": p.id, "name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
for p in room.game.players
]
# Build rankings
by_points = sorted(scores, key=lambda x: x["total"])
by_holes_won = sorted(scores, key=lambda x: -x["rounds_won"])
await player.websocket.send_json({
"type": "round_over",
"scores": scores,
"finisher_id": room.game.finisher_id,
"round": room.game.current_round,
"total_rounds": room.game.num_rounds,
"rankings": {
"by_points": by_points,
"by_holes_won": by_holes_won,
},
})
try:
await player.websocket.send_json({
"type": "round_over",
"scores": scores,
"finisher_id": room.game.finisher_id,
"round": room.game.current_round,
"total_rounds": room.game.num_rounds,
"rankings": {
"by_points": by_points,
"by_holes_won": by_holes_won,
},
})
except Exception:
pass
# Check for game over
elif room.game.phase == GamePhase.GAME_OVER:
# Log game end
game_logger = get_logger()
if game_logger and room.game_log_id:
game_logger.log_game_end(room.game_log_id)
room.game_log_id = None # Clear to avoid duplicate logging
# Process stats asynchronously (fire-and-forget) to avoid delaying game over notifications
if _stats_service and room.game.players:
asyncio.create_task(_process_stats_safe(room))
scores = [
{"name": p.name, "total": p.total_score, "rounds_won": p.rounds_won}
for p in room.game.players
]
by_points = sorted(scores, key=lambda x: x["total"])
by_holes_won = sorted(scores, key=lambda x: -x["rounds_won"])
await player.websocket.send_json({
"type": "game_over",
"final_scores": by_points,
"rankings": {
"by_points": by_points,
"by_holes_won": by_holes_won,
},
})
try:
await player.websocket.send_json({
"type": "game_over",
"final_scores": by_points,
"rankings": {
"by_points": by_points,
"by_holes_won": by_holes_won,
},
})
except Exception:
pass
# Notify current player it's their turn (only if human)
elif room.game.phase in (GamePhase.PLAYING, GamePhase.FINAL_TURN):