Files
SkinsPins/app/routers/games.py
T
Rolf c658c970ac Deduplicate handicap calculation into _playing_data helper
Extracts playing_handicaps + strokes_per_hole into a single helper
used by both game_page and game_summary, instead of duplicating
the logic in each route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 11:43:03 +01:00

454 lines
19 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, 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,
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:
return [dict(r) for r in await c.fetchall()]
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 _playing_data(
players: list,
holes: list,
hole_pars: dict,
hole_stroke_indices: dict,
) -> tuple[dict, dict]:
"""Return (playing_handicaps, strokes_per_hole).
playing_handicaps {player_id: course_handicap (int) or None}
strokes_per_hole {player_id: {hole_number: strokes_received}}
"""
course_par = sum(hole_pars.get(h, 0) for h in holes if h in hole_pars)
playing_handicaps: dict = {}
for p in players:
hc = p.get("handicap_snapshot")
slope = p.get("slope")
rating = p.get("tee_rating")
if hc is not None and slope and rating and course_par:
playing_handicaps[p["id"]] = round(hc * (slope / 113) + (rating - course_par))
else:
playing_handicaps[p["id"]] = None
strokes_per_hole: dict = {}
played_with_si = {h: hole_stroke_indices[h] for h in holes if h in hole_stroke_indices}
if played_with_si:
n_si = len(played_with_si)
for p in players:
ph = playing_handicaps.get(p["id"])
if ph is not None and ph > 0:
base, extra = divmod(ph, n_si)
strokes_per_hole[p["id"]] = {
h: base + (1 if played_with_si[h] <= extra else 0)
for h in holes if h in played_with_si
}
return playing_handicaps, strokes_per_hole
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 id, name, holes, location FROM courses ORDER BY 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 id, name, holes, location FROM courses ORDER BY 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),
)
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
await db.execute(
"INSERT OR IGNORE INTO game_players (id, game_id, user_id, handicap_snapshot, tee_id, joined_at) VALUES (?,?,?,?,?,?)",
(secrets.token_urlsafe(10), game_id, uid, hc, tee_id, now),
)
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, strokes_per_hole = _playing_data(players, holes, hole_pars, 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, "hole_stroke_indices": hole_stroke_indices,
"results": results,
"playing_handicaps": playing_handicaps, "strokes_per_hole": strokes_per_hole,
"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))
# Load hole pars and stroke indices if the game has a course
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, strokes_per_hole = _playing_data(players, holes, hole_pars, 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,
"playing_handicaps": playing_handicaps, "strokes_per_hole": strokes_per_hole,
"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)