Files
SkinsPins/app/routers/games.py
T
Rolf a4ce8a9d50 Fix stroke index display when SI is per-tee only
has_stroke_index was derived solely from course_holes.stroke_index,
but stroke indices can also be stored as JSON on course_tees
(stroke_indices column), which is what's used at game creation to
compute strokes_allocation per player. Courses imported from the API
often have per-tee SI but no course-level hole SI, causing
has_stroke_index = False even though every player had valid stroke
data — hiding the +N badges and scorecard slash markers entirely.

Fix: fall back to checking whether any player actually has stroke
data in strokes_per_hole, applied to both game_page and game_summary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:18:32 +01:00

559 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import secrets
from datetime import datetime, timezone
import aiosqlite
from fastapi import APIRouter, Depends, Form, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse
from app.database import get_db
from app.dependencies import get_current_user
from app.templates_config import templates
router = APIRouter()
# game_id -> set of connected websockets
_connections: dict[str, set[WebSocket]] = {}
# ── Helpers ──────────────────────────────────────────────────────────────────
async def _get_game(game_id: str, db: aiosqlite.Connection):
async with db.execute(
"""SELECT g.*, c.name as course_name
FROM games g LEFT JOIN courses c ON c.id = g.course_id
WHERE g.id = ?""",
(game_id,),
) as c:
return await c.fetchone()
async def _get_players(game_id: str, db: aiosqlite.Connection):
async with db.execute(
"""SELECT u.id, u.name, u.avatar, gp.handicap_snapshot,
gp.playing_handicap, gp.strokes_allocation,
ct.slope, ct.rating as tee_rating, ct.name as tee_name
FROM game_players gp JOIN users u ON gp.user_id = u.id
LEFT JOIN course_tees ct ON ct.id = gp.tee_id
WHERE gp.game_id = ? ORDER BY gp.joined_at""",
(game_id,),
) as c:
rows = [dict(r) for r in await c.fetchall()]
for p in rows:
raw = p.get("strokes_allocation")
p["strokes_allocation"] = {int(k): v for k, v in json.loads(raw).items()} if raw else {}
return rows
async def _get_scores(game_id: str, db: aiosqlite.Connection):
"""Returns {hole_number: {user_id: score_row}}"""
async with db.execute(
"SELECT * FROM hole_scores WHERE game_id = ?", (game_id,)
) as c:
rows = await c.fetchall()
scores: dict[int, dict] = {}
for r in rows:
h = r["hole_number"]
if h not in scores:
scores[h] = {}
scores[h][r["user_id"]] = dict(r)
return scores
async def _broadcast(game_id: str, message: dict):
dead = set()
for ws in _connections.get(game_id, set()):
try:
await ws.send_text(json.dumps(message))
except Exception:
dead.add(ws)
_connections.get(game_id, set()).difference_update(dead)
def _strokes_per_hole(players: list, holes: list, hole_stroke_indices: dict) -> dict:
"""Fallback: compute strokes-per-hole from playing_handicap + stroke indices."""
played_with_si = {h: hole_stroke_indices[h] for h in holes if h in hole_stroke_indices}
if not played_with_si:
return {}
n_si = len(played_with_si)
sorted_holes = sorted(played_with_si, key=lambda h: played_with_si[h])
hole_rank = {h: rank + 1 for rank, h in enumerate(sorted_holes)}
result = {}
for p in players:
ph = p.get("playing_handicap")
if ph is not None and ph > 0:
base, extra = divmod(ph, n_si)
result[p["id"]] = {h: base + (1 if hole_rank[h] <= extra else 0)
for h in holes if h in played_with_si}
return result
def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True,
skin_value: float | None = None, skin_double_back9: bool = False,
strokes_per_hole: dict | None = None):
"""Returns (skins_per_player, hole_results, unclaimed_pot, money_per_player, unclaimed_money)
money_per_player is None when skin_value is not set.
strokes_per_hole is {uid: {hole: strokes}} for net scoring."""
skins = {p["id"]: 0 for p in players}
money = {p["id"]: 0.0 for p in players}
hole_results = {}
pot = 0
money_pot = 0.0
for hole in holes:
raw = {uid: s["strokes"] for uid, s in scores.get(hole, {}).items() if s.get("strokes")}
if strokes_per_hole:
scored = {uid: g - (strokes_per_hole.get(uid) or {}).get(hole, 0) for uid, g in raw.items()}
else:
scored = raw
if not scored:
continue
pot += 1
if skin_value:
money_pot += skin_value * (2 if skin_double_back9 and hole >= 10 else 1)
min_score = min(scored.values())
winners = [uid for uid, s in scored.items() if s == min_score]
if len(winners) == 1:
skins[winners[0]] += pot
money[winners[0]] += money_pot
hole_results[hole] = {"winner": winners[0], "pot": pot, "min_score": min_score, "money_pot": money_pot}
pot = 0
money_pot = 0.0
else:
hole_results[hole] = {"winner": None, "pot": pot, "min_score": min_score, "money_pot": money_pot}
if not carryover:
pot = 0
money_pot = 0.0
return skins, hole_results, pot, (money if skin_value else None), money_pot
def _totals(players: list, scores: dict) -> dict:
"""Returns {user_id: {bingo, bango, bongo, total}}"""
totals = {p["id"]: {"bingo": 0, "bango": 0.0, "bongo": 0, "total": 0.0} for p in players}
for hole_scores in scores.values():
for uid, s in hole_scores.items():
if uid in totals:
totals[uid]["bingo"] += s["bingo"]
totals[uid]["bango"] += s["bango"]
totals[uid]["bongo"] += s["bongo"]
totals[uid]["total"] += s["bingo"] + s["bango"] + s["bongo"]
return totals
async def _new_game_context(db: aiosqlite.Connection) -> dict:
async with db.execute(
"SELECT id, name, avatar, handicap FROM users WHERE is_onboarded = 1 ORDER BY name"
) as c:
all_users = [dict(r) for r in await c.fetchall()]
async with db.execute(
"SELECT c.id, c.name, c.holes, c.location, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id),0) AS par, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id AND hole_number<=9),0) AS par_9 FROM courses c ORDER BY c.name"
) as c:
courses = [dict(r) for r in await c.fetchall()]
async with db.execute(
"SELECT id, course_id, name, slope, rating, gender, front_slope, front_rating, back_slope, back_rating FROM course_tees ORDER BY course_id, gender, name"
) as c:
tee_rows = [dict(r) for r in await c.fetchall()]
tees_by_course: dict[str, list] = {}
for t in tee_rows:
tees_by_course.setdefault(t["course_id"], []).append(
{"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"],
"gender": t["gender"], "front_slope": t["front_slope"], "front_rating": t["front_rating"],
"back_slope": t["back_slope"], "back_rating": t["back_rating"]}
)
return {"all_users": all_users, "courses": courses, "tees_by_course": tees_by_course}
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("/games", response_class=HTMLResponse)
async def games_list(request: Request, user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
async with db.execute(
"""SELECT g.id, g.holes_count, g.status, g.created_at, g.format, g.carryover,
u.name as creator_name, c.name as course_name
FROM games g JOIN users u ON g.created_by = u.id
LEFT JOIN courses c ON c.id = g.course_id
WHERE g.id IN (SELECT game_id FROM game_players WHERE user_id = ?)
ORDER BY g.created_at DESC LIMIT 20""",
(user["id"],),
) as c:
games = [dict(r) for r in await c.fetchall()]
# For completed BBB games, find the winner (highest total points)
async with db.execute(
"""SELECT hs.game_id, u.name, u.avatar,
SUM(hs.bingo + hs.bango + hs.bongo) AS total
FROM hole_scores hs JOIN users u ON hs.user_id = u.id
WHERE hs.game_id IN (SELECT id FROM games WHERE status = 'completed' AND format = 'bingo_bango_bongo')
GROUP BY hs.game_id, hs.user_id
ORDER BY hs.game_id, total DESC""",
) as c:
rows = await c.fetchall()
# Keep only the top scorer per game
winners: dict[str, dict] = {}
for r in rows:
if r["game_id"] not in winners:
winners[r["game_id"]] = {"name": r["name"], "avatar": r["avatar"], "total": r["total"]}
# For completed skins games, calculate winner via skins algorithm
for g in games:
if g["status"] == "completed" and g["format"] == "skins":
gid = g["id"]
players_g = await _get_players(gid, db)
scores_g = await _get_scores(gid, db)
holes_g = list(range(1, g["holes_count"] + 1))
skins_g, _, _, _, _ = _calc_skins(players_g, scores_g, holes_g, bool(g["carryover"]))
if skins_g:
winner_id = max(skins_g, key=skins_g.get)
if skins_g[winner_id] > 0:
wp = next((p for p in players_g if p["id"] == winner_id), None)
if wp:
winners[gid] = {"name": wp["name"], "avatar": wp["avatar"], "total": skins_g[winner_id]}
return templates.TemplateResponse(
"games/games.html", {"request": request, "user": user, "games": games, "winners": winners}
)
@router.get("/games/new", response_class=HTMLResponse)
async def new_game_page(request: Request, user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
ctx = await _new_game_context(db)
return templates.TemplateResponse(
"games/new_game.html", {"request": request, "user": user, **ctx},
)
VALID_FORMATS = {"bingo_bango_bongo", "skins"}
@router.post("/games/new")
async def create_game(request: Request, holes_count: int = Form(...),
course_id: str = Form(...),
format: str = Form("bingo_bango_bongo"),
user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
form = await request.form()
player_ids = form.getlist("players")
if format not in VALID_FORMATS:
format = "bingo_bango_bongo"
if user["id"] not in player_ids:
player_ids.insert(0, user["id"])
async with db.execute(
"SELECT c.id, c.name, c.holes, c.location, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id),0) AS par, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id AND hole_number<=9),0) AS par_9 FROM courses c ORDER BY c.name"
) as c:
courses = [dict(r) for r in await c.fetchall()]
error = None
if len(player_ids) < 2:
error = "Select at least 2 players."
if not course_id or not any(c["id"] == course_id for c in courses):
error = error or "Select a course."
if not error:
async with db.execute(
"SELECT id FROM course_tees WHERE course_id = ? LIMIT 1", (course_id,)
) as c:
has_tees = await c.fetchone()
if has_tees and any(not form.get(f"tee_{uid}") for uid in player_ids):
error = "All players must select a tee."
if error:
ctx = await _new_game_context(db)
return templates.TemplateResponse(
"games/new_game.html",
{"request": request, "user": user, **ctx, "error": error},
)
game_id = secrets.token_urlsafe(10)
now = datetime.now(timezone.utc).isoformat()
start_hole_raw = form.get("start_hole", "1")
start_hole = int(start_hole_raw) if start_hole_raw in ("1", "10") else 1
carryover = 1 if form.get("carryover") == "1" else 0
skin_value_str = (form.get("skin_value") or "").strip()
skin_value = float(skin_value_str) if skin_value_str else None
skin_double_back_nine = 1 if form.get("skin_double_back_nine") == "1" else 0
skins_net = 1 if form.get("skins_net") == "1" else 0
side_skins_enabled = 1 if form.get("side_skins_enabled") == "1" else 0
await db.execute(
"INSERT INTO games (id, format, holes_count, start_hole, status, created_by, created_at, course_id, carryover, skin_value, skin_double_back_nine, skins_net, side_skins_enabled) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(game_id, format, holes_count, start_hole, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine, skins_net, side_skins_enabled),
)
# Pre-fetch course holes for handicap calculation
course_par_played = 0 # par for the actual holes being played
course_total_holes = holes_count # assume matches unless we find otherwise
hole_stroke_indices: dict[int, int] = {}
if course_id:
async with db.execute("SELECT holes FROM courses WHERE id = ?", (course_id,)) as c:
cr = await c.fetchone()
if cr:
course_total_holes = cr["holes"]
async with db.execute(
"SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number",
(course_id,),
) as c:
for row in await c.fetchall():
h = row["hole_number"]
if start_hole <= h < start_hole + holes_count:
course_par_played += row["par"] or 0
if row["stroke_index"] is not None:
hole_stroke_indices[h] = row["stroke_index"]
nine_of_eighteen = (holes_count == 9 and course_total_holes == 18)
nine_hole_dedicated = (holes_count == 9 and not nine_of_eighteen)
is_back_nine = nine_of_eighteen and start_hole == 10
played_holes = list(range(start_hole, start_hole + holes_count))
# Fallback formula for 9-of-18 without split ratings: compute 18-hole CH then halve.
# Use played-par×2 as estimated 18-hole par.
par_for_formula = course_par_played * 2 if nine_of_eighteen else course_par_played
# Batch-fetch all player handicaps
ph_placeholders = ",".join("?" * len(player_ids))
async with db.execute(
f"SELECT id, handicap FROM users WHERE id IN ({ph_placeholders})", player_ids
) as c:
handicaps_map = {r["id"]: r["handicap"] for r in await c.fetchall()}
# Batch-fetch all selected tees
tee_id_map = {uid: (form.get(f"tee_{uid}") or None) for uid in player_ids}
tee_ids_needed = [t for t in tee_id_map.values() if t]
tees_map: dict[str, aiosqlite.Row] = {}
if tee_ids_needed:
t_placeholders = ",".join("?" * len(tee_ids_needed))
async with db.execute(
f"SELECT id, slope, rating, stroke_indices, front_slope, front_rating, back_slope, back_rating FROM course_tees WHERE id IN ({t_placeholders})",
tee_ids_needed,
) as c:
tees_map = {r["id"]: r for r in await c.fetchall()}
for uid in player_ids:
hc = handicaps_map.get(uid)
tee_id = tee_id_map[uid]
tee_row = tees_map.get(tee_id) if tee_id else None
playing_handicap = None
strokes_allocation_json = None
if hc is not None and tee_row and par_for_formula:
if is_back_nine and tee_row["back_slope"] and tee_row["back_rating"]:
# Back 9 specific ratings available — use directly
ph = round(hc * (tee_row["back_slope"] / 113) + (tee_row["back_rating"] - course_par_played))
playing_handicap = ph
elif nine_of_eighteen and not is_back_nine and tee_row["front_slope"] and tee_row["front_rating"]:
# Front 9 specific ratings available — use directly
ph = round(hc * (tee_row["front_slope"] / 113) + (tee_row["front_rating"] - course_par_played))
playing_handicap = ph
else:
hc_for_formula = hc / 2 if nine_hole_dedicated else hc
ph = round(hc_for_formula * (tee_row["slope"] / 113) + (tee_row["rating"] - par_for_formula))
playing_handicap = round(ph / 2) if nine_of_eighteen else ph
# Per-tee stroke indices take priority over course-level fallback
if tee_row["stroke_indices"]:
raw = json.loads(tee_row["stroke_indices"])
player_si = {int(k): v for k, v in raw.items()}
else:
player_si = hole_stroke_indices
played_with_si_p = {h: player_si[h] for h in played_holes if h in player_si}
if played_with_si_p and playing_handicap > 0:
n_si_p = len(played_with_si_p)
sorted_holes_p = sorted(played_with_si_p, key=lambda h: played_with_si_p[h])
hole_rank_p = {h: rank + 1 for rank, h in enumerate(sorted_holes_p)}
base, extra = divmod(playing_handicap, n_si_p)
allocation = {str(h): base + (1 if hole_rank_p[h] <= extra else 0)
for h in played_holes if h in played_with_si_p}
strokes_allocation_json = json.dumps(allocation)
await db.execute(
"INSERT OR IGNORE INTO game_players (id, game_id, user_id, handicap_snapshot, tee_id, joined_at, playing_handicap, strokes_allocation) VALUES (?,?,?,?,?,?,?,?)",
(secrets.token_urlsafe(10), game_id, uid, hc, tee_id, now, playing_handicap, strokes_allocation_json),
)
await db.commit()
return RedirectResponse(f"/games/{game_id}", status_code=302)
@router.get("/games/{game_id}/summary", response_class=HTMLResponse)
async def game_summary(game_id: str, request: Request, user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
game = await _get_game(game_id, db)
if not game:
return RedirectResponse("/games", status_code=302)
players = await _get_players(game_id, db)
scores = await _get_scores(game_id, db)
totals = _totals(players, scores)
start_hole = game["start_hole"] if game["start_hole"] else 1
holes = list(range(start_hole, start_hole + game["holes_count"]))
hole_pars: dict[int, int] = {}
hole_stroke_indices: dict[int, int] = {}
if game["course_id"]:
async with db.execute(
"SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number",
(game["course_id"],),
) as c:
for row in await c.fetchall():
hole_pars[row["hole_number"]] = row["par"]
if row["stroke_index"] is not None:
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
playing_handicaps = {p["id"]: p.get("playing_handicap") for p in players}
computed = _strokes_per_hole(players, holes, hole_stroke_indices)
strokes_per_hole = {p["id"]: p["strokes_allocation"] or computed.get(p["id"], {}) for p in players}
has_stroke_index = bool(hole_stroke_indices) or any(strokes_per_hole.values())
skins_won, skins_hole_results, unclaimed, money_won, unclaimed_money = None, None, 0, None, 0.0
skin_value = game["skin_value"]
skin_double_back9 = bool(game["skin_double_back_nine"])
skins_net = bool(game["skins_net"])
side_skins_enabled = bool(game["side_skins_enabled"])
if game["format"] == "skins" or side_skins_enabled:
carryover = bool(game["carryover"])
net_sph = strokes_per_hole if skins_net else None
skins_won, skins_hole_results, unclaimed, money_won, unclaimed_money = _calc_skins(
players, scores, holes, carryover, skin_value, skin_double_back9, net_sph)
if game["format"] == "skins":
results = sorted(players, key=lambda p: skins_won.get(p["id"], 0), reverse=True)
else:
results = sorted(players, key=lambda p: totals.get(p["id"], {}).get("total", 0), reverse=True)
return templates.TemplateResponse("games/summary.html", {
"request": request, "user": user, "game": dict(game),
"players": players, "scores": scores, "totals": totals,
"holes": holes, "hole_pars": hole_pars,
"playing_handicaps": playing_handicaps, "strokes_per_hole": strokes_per_hole,
"has_stroke_index": has_stroke_index,
"results": results,
"skins_won": skins_won, "skins_hole_results": skins_hole_results, "unclaimed": unclaimed,
"money_won": money_won, "unclaimed_money": unclaimed_money, "skin_value": skin_value,
"skin_double_back9": skin_double_back9, "skins_net": skins_net,
"side_skins_enabled": side_skins_enabled,
"carryover": bool(game["carryover"]),
})
@router.get("/games/{game_id}", response_class=HTMLResponse)
async def game_page(game_id: str, request: Request, user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
game = await _get_game(game_id, db)
if not game:
return RedirectResponse("/games", status_code=302)
players = await _get_players(game_id, db)
scores = await _get_scores(game_id, db)
totals = _totals(players, scores)
start_hole = game["start_hole"] if game["start_hole"] else 1
holes = list(range(start_hole, start_hole + game["holes_count"]))
hole_pars: dict[int, int] = {}
hole_stroke_indices: dict[int, int] = {}
if game["course_id"]:
async with db.execute(
"SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number",
(game["course_id"],),
) as c:
for row in await c.fetchall():
hole_pars[row["hole_number"]] = row["par"]
if row["stroke_index"] is not None:
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
computed = _strokes_per_hole(players, holes, hole_stroke_indices)
strokes_per_hole = {p["id"]: p["strokes_allocation"] or computed.get(p["id"], {}) for p in players}
has_stroke_index = bool(hole_stroke_indices) or any(strokes_per_hole.values())
return templates.TemplateResponse("games/game.html", {
"request": request, "user": user, "game": dict(game),
"players": players, "scores": scores, "totals": totals,
"holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices,
"strokes_per_hole": strokes_per_hole, "has_stroke_index": has_stroke_index,
"carryover": bool(game["carryover"]),
"skin_value": game["skin_value"],
"skin_double_back9": bool(game["skin_double_back_nine"]),
"skins_net": bool(game["skins_net"]),
"side_skins_enabled": bool(game["side_skins_enabled"]),
"start_hole": start_hole,
})
@router.post("/games/{game_id}/score")
async def save_score(game_id: str, request: Request, user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
form = await request.form()
hole = int(form["hole"])
now = datetime.now(timezone.utc).isoformat()
game = await _get_game(game_id, db)
if not game:
return HTMLResponse("Not found", status_code=404)
players = await _get_players(game_id, db)
player_ids = [p["id"] for p in players]
# Determine Bingo (first on green) and Bongo (first to hole out) — single winner
bingo_winner = form.get("bingo", "")
bongo_winner = form.get("bongo", "")
# Bango: may be multiple winners (split point)
bango_winners = form.getlist("bango")
bango_value = round(1.0 / len(bango_winners), 4) if bango_winners else 0.0
for uid in player_ids:
bingo = 1 if uid == bingo_winner else 0
bango = bango_value if uid in bango_winners else 0.0
bongo = 1 if uid == bongo_winner else 0
strokes_val = form.get(f"strokes_{uid}")
strokes = int(strokes_val) if strokes_val and strokes_val.isdigit() else None
await db.execute(
"""INSERT INTO hole_scores (id, game_id, hole_number, user_id, strokes, bingo, bango, bongo, updated_by, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(game_id, hole_number, user_id) DO UPDATE SET
strokes=excluded.strokes, bingo=excluded.bingo, bango=excluded.bango, bongo=excluded.bongo,
updated_by=excluded.updated_by, updated_at=excluded.updated_at""",
(secrets.token_urlsafe(10), game_id, hole, uid, strokes, bingo, bango, bongo, user["id"], now),
)
await db.commit()
# Broadcast updated scores to all connected players
scores = await _get_scores(game_id, db)
totals = _totals(players, scores)
hole_data = {uid: scores.get(hole, {}).get(uid, {}) for uid in player_ids}
await _broadcast(game_id, {
"type": "score_update",
"hole": hole,
"scores": {uid: {"strokes": d.get("strokes"), "bingo": d.get("bingo", 0),
"bango": d.get("bango", 0.0), "bongo": d.get("bongo", 0)}
for uid, d in hole_data.items()},
"totals": totals,
})
return HTMLResponse("", status_code=204)
@router.post("/games/{game_id}/finish")
async def finish_game(game_id: str, user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
await db.execute("UPDATE games SET status = 'completed' WHERE id = ?", (game_id,))
await db.commit()
return RedirectResponse(f"/games/{game_id}/summary", status_code=302)
# ── WebSocket ─────────────────────────────────────────────────────────────────
@router.websocket("/games/{game_id}/ws")
async def game_ws(game_id: str, websocket: WebSocket):
await websocket.accept()
_connections.setdefault(game_id, set()).add(websocket)
try:
while True:
await websocket.receive_text() # keep alive
except WebSocketDisconnect:
_connections.get(game_id, set()).discard(websocket)