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
|
spectator_state = room.game.get_state(None) # No player perspective
|
||||||
await _spectator_manager.send_game_state(room.code, spectator_state)
|
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
|
# Skip CPU players
|
||||||
if player.is_cpu or not player.websocket:
|
if player.is_cpu or not player.websocket:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
game_state = room.game.get_state(pid)
|
game_state = room.game.get_state(pid)
|
||||||
await player.websocket.send_json({
|
try:
|
||||||
"type": "game_state",
|
await player.websocket.send_json({
|
||||||
"game_state": game_state,
|
"type": "game_state",
|
||||||
})
|
"game_state": game_state,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
# Check for round over
|
# Check for round over
|
||||||
if room.game.phase == GamePhase.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}
|
{"id": p.id, "name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
|
||||||
for p in room.game.players
|
for p in room.game.players
|
||||||
]
|
]
|
||||||
# Build rankings
|
|
||||||
by_points = sorted(scores, key=lambda x: x["total"])
|
by_points = sorted(scores, key=lambda x: x["total"])
|
||||||
by_holes_won = sorted(scores, key=lambda x: -x["rounds_won"])
|
by_holes_won = sorted(scores, key=lambda x: -x["rounds_won"])
|
||||||
await player.websocket.send_json({
|
try:
|
||||||
"type": "round_over",
|
await player.websocket.send_json({
|
||||||
"scores": scores,
|
"type": "round_over",
|
||||||
"finisher_id": room.game.finisher_id,
|
"scores": scores,
|
||||||
"round": room.game.current_round,
|
"finisher_id": room.game.finisher_id,
|
||||||
"total_rounds": room.game.num_rounds,
|
"round": room.game.current_round,
|
||||||
"rankings": {
|
"total_rounds": room.game.num_rounds,
|
||||||
"by_points": by_points,
|
"rankings": {
|
||||||
"by_holes_won": by_holes_won,
|
"by_points": by_points,
|
||||||
},
|
"by_holes_won": by_holes_won,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Check for game over
|
|
||||||
elif room.game.phase == GamePhase.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 = [
|
scores = [
|
||||||
{"name": p.name, "total": p.total_score, "rounds_won": p.rounds_won}
|
{"name": p.name, "total": p.total_score, "rounds_won": p.rounds_won}
|
||||||
for p in room.game.players
|
for p in room.game.players
|
||||||
]
|
]
|
||||||
by_points = sorted(scores, key=lambda x: x["total"])
|
by_points = sorted(scores, key=lambda x: x["total"])
|
||||||
by_holes_won = sorted(scores, key=lambda x: -x["rounds_won"])
|
by_holes_won = sorted(scores, key=lambda x: -x["rounds_won"])
|
||||||
await player.websocket.send_json({
|
try:
|
||||||
"type": "game_over",
|
await player.websocket.send_json({
|
||||||
"final_scores": by_points,
|
"type": "game_over",
|
||||||
"rankings": {
|
"final_scores": by_points,
|
||||||
"by_points": by_points,
|
"rankings": {
|
||||||
"by_holes_won": by_holes_won,
|
"by_points": by_points,
|
||||||
},
|
"by_holes_won": by_holes_won,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Notify current player it's their turn (only if human)
|
# Notify current player it's their turn (only if human)
|
||||||
elif room.game.phase in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
elif room.game.phase in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ class Room:
|
|||||||
message: JSON-serializable message dict.
|
message: JSON-serializable message dict.
|
||||||
exclude: Optional player ID to skip.
|
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:
|
if player_id != exclude and player.websocket and not player.is_cpu:
|
||||||
try:
|
try:
|
||||||
await player.websocket.send_json(message)
|
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
|
# 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
|
# 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
|
# Update stats
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
@@ -792,13 +792,13 @@ class StatsService:
|
|||||||
rounds_won = rounds_won + $4,
|
rounds_won = rounds_won + $4,
|
||||||
total_points = total_points + $5,
|
total_points = total_points + $5,
|
||||||
best_score = CASE
|
best_score = CASE
|
||||||
WHEN best_score IS NULL THEN $6
|
WHEN best_score IS NULL THEN $6::integer
|
||||||
WHEN $6 IS NOT NULL AND $6 < best_score THEN $6
|
WHEN $6::integer IS NOT NULL AND $6::integer < best_score THEN $6::integer
|
||||||
ELSE best_score
|
ELSE best_score
|
||||||
END,
|
END,
|
||||||
worst_score = CASE
|
worst_score = CASE
|
||||||
WHEN worst_score IS NULL THEN $7
|
WHEN worst_score IS NULL THEN $7::integer
|
||||||
WHEN $7 IS NOT NULL AND $7 > worst_score THEN $7
|
WHEN $7::integer IS NOT NULL AND $7::integer > worst_score THEN $7::integer
|
||||||
ELSE worst_score
|
ELSE worst_score
|
||||||
END,
|
END,
|
||||||
current_win_streak = CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE 0 END,
|
current_win_streak = CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE 0 END,
|
||||||
|
|||||||
Reference in New Issue
Block a user