c298c473f4
Dedicated 9-hole courses have 9-hole ratings and pars, so the WHS formula is: round((HI÷2) × (slope/113) + (rating − par)). The previous code used the full HI, giving roughly double the correct result (e.g. 22 instead of 11 on Augwil White Male for HI 18). The three cases are now handled correctly: - Dedicated 9-hole course: HI÷2 with 9-hole ratings - 9 holes on 18-hole course: full HI with 18-hole ratings, result halved - Full 18 holes: full HI with 18-hole ratings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
531 lines
24 KiB
Python
531 lines
24 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,
|
||
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
|
||
|
||
|
||
# ── 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
|
||
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
|
||
|
||
await db.execute(
|
||
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover, skin_value, skin_double_back_nine, skins_net) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||
(game_id, format, holes_count, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine, skins_net),
|
||
)
|
||
|
||
# Pre-fetch course holes for handicap calculation
|
||
course_par_played = 0 # par for the 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():
|
||
if row["hole_number"] <= holes_count:
|
||
course_par_played += row["par"] or 0
|
||
if row["stroke_index"] is not None:
|
||
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
|
||
|
||
# For a 9-hole game on an 18-hole course the tee ratings are 18-hole values.
|
||
# Per WHS: compute 18-hole course handicap using played-par×2 as the 18-hole par, then halve.
|
||
# Using played-par×2 avoids relying on back-nine holes being present in course_holes.
|
||
nine_of_eighteen = (holes_count == 9 and course_total_holes == 18)
|
||
|
||
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
|
||
par_for_formula = course_par_played * 2 if nine_of_eighteen else course_par_played
|
||
# Dedicated 9-hole courses have 9-hole ratings: WHS uses HI÷2 in the formula.
|
||
# For 9-of-18 the full HI is used and the result halved instead.
|
||
nine_hole_dedicated = (holes_count == 9 and not nine_of_eighteen)
|
||
if hc is not None and tee_id and par_for_formula:
|
||
async with db.execute("SELECT slope, rating FROM course_tees WHERE id = ?", (tee_id,)) as c:
|
||
tee_row = await c.fetchone()
|
||
if tee_row:
|
||
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
|
||
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, 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"])
|
||
if game["format"] == "skins":
|
||
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)
|
||
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,
|
||
"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"]),
|
||
"skin_value": game["skin_value"],
|
||
"skin_double_back9": bool(game["skin_double_back_nine"]),
|
||
"skins_net": bool(game["skins_net"]),
|
||
})
|
||
|
||
|
||
@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)
|