c658c970ac
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>
454 lines
19 KiB
Python
454 lines
19 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,
|
||
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)
|