8601584099
- Playing handicap: computed at game creation (WHS formula), stored on game_players alongside per-hole strokes allocation; displayed on game summary with HC card and / markers on scorecard (only when SI defined); live game shows +N per player card when SI exists, HC total otherwise; new game confirmation previews computed HC per player - Login: 6-digit code sent alongside magic link in every auth email; check_email page has auto-advancing digit inputs with paste support; new POST /auth/verify-code route handles code verification - Game view: Score/Card toggle fixed with position:fixed to always show above nav bar on mobile; swipe on score view changes holes, swipe on scorecard switches tabs; dirty tracking prevents saving unmodified holes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
485 lines
21 KiB
Python
485 lines
21 KiB
Python
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, DATABASE_PATH
|
|
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):
|
|
"""Returns (skins_per_player, hole_results, unclaimed_pot)"""
|
|
skins = {p["id"]: 0 for p in players}
|
|
hole_results = {}
|
|
pot = 0
|
|
for hole in holes:
|
|
scored = {uid: s["strokes"] for uid, s in scores.get(hole, {}).items() if s.get("strokes")}
|
|
if not scored:
|
|
continue
|
|
pot += 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
|
|
hole_results[hole] = {"winner": winners[0], "pot": pot, "min_score": min_score}
|
|
pot = 0
|
|
else:
|
|
hole_results[hole] = {"winner": None, "pot": pot, "min_score": min_score}
|
|
if not carryover:
|
|
pot = 0
|
|
return skins, hole_results, 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
|
|
|
|
|
|
# ── 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)):
|
|
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 FROM course_tees ORDER BY course_id, name"
|
|
) as c:
|
|
tee_rows = [dict(r) for r in await c.fetchall()]
|
|
|
|
# Group tees by course_id for JSON embed in template
|
|
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"]}
|
|
)
|
|
|
|
return templates.TemplateResponse(
|
|
"games/new_game.html",
|
|
{"request": request, "user": user, "all_users": all_users,
|
|
"courses": courses, "tees_by_course": tees_by_course},
|
|
)
|
|
|
|
|
|
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:
|
|
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 id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, 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"]}
|
|
)
|
|
return templates.TemplateResponse(
|
|
"games/new_game.html",
|
|
{"request": request, "user": user, "all_users": all_users,
|
|
"courses": courses, "tees_by_course": tees_by_course, "error": error},
|
|
)
|
|
|
|
game_id = secrets.token_urlsafe(10)
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
carryover = 1 if form.get("carryover") == "1" else 0
|
|
|
|
await db.execute(
|
|
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover) VALUES (?,?,?,?,?,?,?,?)",
|
|
(game_id, format, holes_count, "active", user["id"], now, course_id, carryover),
|
|
)
|
|
|
|
# Pre-fetch course holes for handicap calculation
|
|
course_par = 0
|
|
hole_stroke_indices: dict[int, int] = {}
|
|
if course_id:
|
|
async with db.execute(
|
|
"SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? AND hole_number <= ? ORDER BY hole_number",
|
|
(course_id, holes_count),
|
|
) as c:
|
|
for row in await c.fetchall():
|
|
course_par += row["par"] or 0
|
|
if row["stroke_index"] is not None:
|
|
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
|
|
|
|
played_holes = list(range(1, holes_count + 1))
|
|
played_with_si = {h: hole_stroke_indices[h] for h in played_holes if h in hole_stroke_indices}
|
|
hole_rank: dict[int, int] = {}
|
|
if 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)}
|
|
n_si = len(played_with_si)
|
|
|
|
for uid in player_ids:
|
|
async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c:
|
|
row = await c.fetchone()
|
|
hc = row["handicap"] if row else None
|
|
tee_id = form.get(f"tee_{uid}") or None
|
|
|
|
playing_handicap = None
|
|
strokes_allocation_json = None
|
|
if hc is not None and tee_id and course_par:
|
|
async with db.execute("SELECT slope, rating FROM course_tees WHERE id = ?", (tee_id,)) as c:
|
|
tee_row = await c.fetchone()
|
|
if tee_row:
|
|
playing_handicap = round(hc * (tee_row["slope"] / 113) + (tee_row["rating"] - course_par))
|
|
if hole_rank and playing_handicap > 0:
|
|
base, extra = divmod(playing_handicap, n_si)
|
|
allocation = {str(h): base + (1 if hole_rank[h] <= extra else 0)
|
|
for h in played_holes if h in played_with_si}
|
|
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)
|
|
holes = list(range(1, game["holes_count"] + 1))
|
|
|
|
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)
|
|
|
|
skins_won, skins_hole_results, unclaimed = None, None, 0
|
|
if game["format"] == "skins":
|
|
carryover = bool(game["carryover"])
|
|
skins_won, skins_hole_results, unclaimed = _calc_skins(players, scores, holes, carryover)
|
|
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,
|
|
"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)
|
|
holes = list(range(1, game["holes_count"] + 1))
|
|
|
|
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)
|
|
|
|
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"]),
|
|
})
|
|
|
|
|
|
@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)
|