d81d6a2d3c
Replace game.get("side_skins_enabled", 0) with game["side_skins_enabled"]
in game_page and game_summary — both columns have NOT NULL DEFAULT so
direct key access is safe after migration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
564 lines
26 KiB
Python
564 lines
26 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, 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()]
|
||
|
||
# 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"],
|
||
"gender": t["gender"], "front_slope": t["front_slope"], "front_rating": t["front_rating"],
|
||
"back_slope": t["back_slope"], "back_rating": t["back_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, 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 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()
|
||
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
|
||
|
||
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
|
||
|
||
tee_row = None
|
||
if tee_id:
|
||
async with db.execute(
|
||
"SELECT slope, rating, stroke_indices, front_slope, front_rating, back_slope, back_rating FROM course_tees WHERE id = ?",
|
||
(tee_id,),
|
||
) as c:
|
||
tee_row = await c.fetchone()
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|