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.get("side_skins_enabled", 0)) 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.get("side_skins_enabled", 0)), "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)