Files
SkinsPins/app/routers/games.py
T
Rolf 8601584099 Add playing handicap, 6-digit login code, and game UX improvements
- 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>
2026-03-23 20:37:08 +01:00

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)