2c0717948a
When creating a skins game, players can now choose between straight up (gross scores) or net scoring (strokes subtracted per hole based on playing handicap and stroke index). Net mode is stored on the game and applied in both the live scorecard and the summary. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
528 lines
24 KiB
Python
528 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_par_full = 0 # par for all holes on the course (needed for 9-of-18 halving)
|
|
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():
|
|
course_par_full += row["par"] or 0
|
|
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, then halve it.
|
|
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_full if nine_of_eighteen else course_par_played
|
|
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:
|
|
ph = round(hc * (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)
|