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:
@@ -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)
|
||||
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,9 +781,9 @@ 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"])
|
||||
try:
|
||||
await player.websocket.send_json({
|
||||
"type": "round_over",
|
||||
"scores": scores,
|
||||
@@ -782,25 +795,17 @@ async def broadcast_game_state(room: Room):
|
||||
"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"])
|
||||
try:
|
||||
await player.websocket.send_json({
|
||||
"type": "game_over",
|
||||
"final_scores": by_points,
|
||||
@@ -809,6 +814,8 @@ async def broadcast_game_state(room: Room):
|
||||
"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):
|
||||
|
||||
@@ -232,7 +232,7 @@ class Room:
|
||||
message: JSON-serializable message dict.
|
||||
exclude: Optional player ID to skip.
|
||||
"""
|
||||
for player_id, player in self.players.items():
|
||||
for player_id, player in list(self.players.items()):
|
||||
if player_id != exclude and player.websocket and not player.is_cpu:
|
||||
try:
|
||||
await player.websocket.send_json(message)
|
||||
|
||||
@@ -781,7 +781,7 @@ class StatsService:
|
||||
|
||||
# We don't have per-round data in legacy mode, so some stats are limited
|
||||
# Use total_score / num_rounds as an approximation for avg round score
|
||||
avg_round_score = total_score / num_rounds if num_rounds > 0 else None
|
||||
avg_round_score = total_score // num_rounds if num_rounds > 0 else total_score
|
||||
|
||||
# Update stats
|
||||
await conn.execute("""
|
||||
@@ -792,13 +792,13 @@ class StatsService:
|
||||
rounds_won = rounds_won + $4,
|
||||
total_points = total_points + $5,
|
||||
best_score = CASE
|
||||
WHEN best_score IS NULL THEN $6
|
||||
WHEN $6 IS NOT NULL AND $6 < best_score THEN $6
|
||||
WHEN best_score IS NULL THEN $6::integer
|
||||
WHEN $6::integer IS NOT NULL AND $6::integer < best_score THEN $6::integer
|
||||
ELSE best_score
|
||||
END,
|
||||
worst_score = CASE
|
||||
WHEN worst_score IS NULL THEN $7
|
||||
WHEN $7 IS NOT NULL AND $7 > worst_score THEN $7
|
||||
WHEN worst_score IS NULL THEN $7::integer
|
||||
WHEN $7::integer IS NOT NULL AND $7::integer > worst_score THEN $7::integer
|
||||
ELSE worst_score
|
||||
END,
|
||||
current_win_streak = CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE 0 END,
|
||||
|
||||
Reference in New Issue
Block a user