diff --git a/app/database.py b/app/database.py index b291845..00559c1 100644 --- a/app/database.py +++ b/app/database.py @@ -124,6 +124,8 @@ async def init_db(): "ALTER TABLE game_players ADD COLUMN playing_handicap INTEGER", "ALTER TABLE game_players ADD COLUMN strokes_allocation TEXT", "ALTER TABLE magic_link_tokens ADD COLUMN code TEXT", + "ALTER TABLE games ADD COLUMN skin_value REAL", + "ALTER TABLE games ADD COLUMN skin_double_back_nine INTEGER NOT NULL DEFAULT 0", ]: try: await db.execute(migration) diff --git a/app/routers/games.py b/app/routers/games.py index 637e97b..c4b2977 100644 --- a/app/routers/games.py +++ b/app/routers/games.py @@ -89,27 +89,36 @@ def _strokes_per_hole(players: list, holes: list, hole_stroke_indices: dict) -> return result -def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True): - """Returns (skins_per_player, hole_results, unclaimed_pot)""" +def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True, + skin_value: float | None = None, skin_double_back9: bool = False): + """Returns (skins_per_player, hole_results, unclaimed_pot, money_per_player, unclaimed_money) + money_per_player is None when skin_value is not set.""" 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: scored = {uid: s["strokes"] for uid, s in scores.get(hole, {}).items() if s.get("strokes")} 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 - hole_results[hole] = {"winner": winners[0], "pot": pot, "min_score": min_score} + 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} + hole_results[hole] = {"winner": None, "pot": pot, "min_score": min_score, "money_pot": money_pot} if not carryover: pot = 0 - return skins, hole_results, pot + money_pot = 0.0 + return skins, hole_results, pot, (money if skin_value else None), money_pot def _totals(players: list, scores: dict) -> dict: @@ -165,7 +174,7 @@ async def games_list(request: Request, user: dict = Depends(get_current_user), 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"])) + 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: @@ -265,24 +274,39 @@ async def create_game(request: Request, holes_count: int = Form(...), game_id = secrets.token_urlsafe(10) now = datetime.now(timezone.utc).isoformat() 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 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), + "INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover, skin_value, skin_double_back_nine) VALUES (?,?,?,?,?,?,?,?,?,?)", + (game_id, format, holes_count, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine), ) # Pre-fetch course holes for handicap calculation - course_par = 0 + course_par_played = 0 # par for the holes being played + course_par_full = 0 # par for all holes on the course (needed for 9-of-18 halving) + 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 = ? AND hole_number <= ? ORDER BY hole_number", - (course_id, holes_count), + "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(): - course_par += row["par"] or 0 - if row["stroke_index"] is not None: - hole_stroke_indices[row["hole_number"]] = row["stroke_index"] + course_par_full += row["par"] or 0 + if row["hole_number"] <= holes_count: + course_par_played += row["par"] or 0 + if row["stroke_index"] is not None: + hole_stroke_indices[row["hole_number"]] = row["stroke_index"] + + # For a 9-hole game on an 18-hole course the tee ratings are 18-hole values. + # Per WHS: compute 18-hole course handicap, then halve it. + nine_of_eighteen = (holes_count == 9 and course_total_holes == 18) 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} @@ -300,11 +324,13 @@ async def create_game(request: Request, holes_count: int = Form(...), playing_handicap = None strokes_allocation_json = None - if hc is not None and tee_id and course_par: + par_for_formula = course_par_full if nine_of_eighteen else course_par_played + if hc is not None and tee_id and par_for_formula: 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)) + ph = round(hc * (tee_row["slope"] / 113) + (tee_row["rating"] - par_for_formula)) + playing_handicap = round(ph / 2) if nine_of_eighteen else ph 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) @@ -349,10 +375,13 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_ 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 + 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"]) if game["format"] == "skins": carryover = bool(game["carryover"]) - skins_won, skins_hole_results, unclaimed = _calc_skins(players, scores, holes, carryover) + skins_won, skins_hole_results, unclaimed, money_won, unclaimed_money = _calc_skins( + players, scores, holes, carryover, skin_value, skin_double_back9) 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) @@ -365,6 +394,8 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_ "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, "carryover": bool(game["carryover"]), }) @@ -403,6 +434,8 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur "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"]), }) diff --git a/app/templates/games/game.html b/app/templates/games/game.html index a81cf48..c03fb7e 100644 --- a/app/templates/games/game.html +++ b/app/templates/games/game.html @@ -241,12 +241,12 @@ - Skins + {% if hole_pars %}{% endif %} {% for p in players %} - + {% endfor %} - + @@ -278,6 +278,8 @@ const GAME_ID = "{{ game.id }}"; const GAME_HOLES = {{ game.holes_count }}; const GAME_FORMAT = "{{ game.format }}"; const SKINS_CARRYOVER = {{ carryover | tojson }}; +const SKIN_VALUE = {{ skin_value | tojson }}; +const SKIN_DOUBLE_BACK9 = {{ skin_double_back9 | tojson }}; const HOLE_PARS = {{ hole_pars | tojson }}; const HOLE_SIS = {{ hole_stroke_indices | tojson }}; const STROKES_PER_HOLE = {{ strokes_per_hole | tojson }}; @@ -483,9 +485,11 @@ function gameApp() { calcSkins() { const skins = {}; - PLAYERS_DATA.forEach(p => skins[p.id] = 0); + const money = {}; + PLAYERS_DATA.forEach(p => { skins[p.id] = 0; money[p.id] = 0; }); const holes = {}; let pot = 0; + let moneyPot = 0; for (let hole = 1; hole <= GAME_HOLES; hole++) { if (this.isSkipped(hole)) continue; const holeScores = this.scores[hole] || {}; @@ -494,18 +498,21 @@ function gameApp() { .filter(x => x.strokes > 0); if (scored.length === 0) continue; pot += 1; + if (SKIN_VALUE) moneyPot += SKIN_VALUE * (SKIN_DOUBLE_BACK9 && hole >= 10 ? 2 : 1); const min = Math.min(...scored.map(x => x.strokes)); const winners = scored.filter(x => x.strokes === min); if (winners.length === 1) { skins[winners[0].id] += pot; - holes[hole] = { winner: winners[0].id, pot }; + money[winners[0].id] += moneyPot; + holes[hole] = { winner: winners[0].id, pot, moneyPot }; pot = 0; + moneyPot = 0; } else { - holes[hole] = { winner: null, pot }; - if (!SKINS_CARRYOVER) pot = 0; + holes[hole] = { winner: null, pot, moneyPot }; + if (!SKINS_CARRYOVER) { pot = 0; moneyPot = 0; } } } - return { skins, holes, unclaimed: pot }; + return { skins, money, holes, unclaimed: pot, unclaimedMoney: moneyPot }; }, isSkinWinner(hole, uid) { @@ -515,6 +522,11 @@ function gameApp() { skinHoleLabel(hole) { const h = this.calcSkins().holes[hole]; if (!h) return ''; + if (SKIN_VALUE) { + const v = parseFloat(h.moneyPot.toPrecision(4)); + if (h.winner) return '' + v; + return '→ ' + v; + } if (h.winner) return h.pot > 1 ? '✓ ' + h.pot : '✓'; return '→ ' + h.pot; }, diff --git a/app/templates/games/new_game.html b/app/templates/games/new_game.html index 3173746..33e4001 100644 --- a/app/templates/games/new_game.html +++ b/app/templates/games/new_game.html @@ -28,6 +28,8 @@ courseSearch: '', format: 'bingo_bango_bongo', carryoverEnabled: true, + skinValue: '', + skinDoubleBackNine: false, holesCount: '', selectedPlayers: ['{{ user.id }}'], selectedTees: {}, @@ -62,10 +64,10 @@ const player = this.getPlayer(uid); const tee = this.getTee(uid); const course = COURSES_DATA.find(c => c.id === this.courseId); - if (!course || !tee || player.handicap == null) return null; - const coursePar = (parseInt(this.holesCount) === 9 && course.holes === 18) ? course.par_9 : course.par; - if (!coursePar) return null; - return Math.round(player.handicap * (tee.slope / 113) + (tee.rating - coursePar)); + if (!course || !tee || player.handicap == null || !course.par) return null; + const nineOfEighteen = parseInt(this.holesCount) === 9 && course.holes === 18; + const ph = Math.round(player.handicap * (tee.slope / 113) + (tee.rating - course.par)); + return nineOfEighteen ? Math.round(ph / 2) : ph; }, goToConfirm() { this.step = 3; }, backFromConfirm() { this.step = this.hasTees() ? 2 : 1; }, @@ -101,12 +103,25 @@ Skins -
+
+
+ Value per skin + +
+
+ +
@@ -257,6 +272,10 @@ Carryover +
+ Skin value + +
Course diff --git a/app/templates/games/summary.html b/app/templates/games/summary.html index 8417d84..5cc13ad 100644 --- a/app/templates/games/summary.html +++ b/app/templates/games/summary.html @@ -16,8 +16,13 @@
{% for p in results %} {% if game.format == 'skins' %} - {% set score_val = skins_won.get(p.id, 0) %} - {% set score_label = score_val | string + ' skin' + ('s' if score_val != 1 else '') %} + {% if skin_value and money_won %} + {% set score_val = money_won.get(p.id, 0) %} + {% set score_label = "%.4g" % score_val %} + {% else %} + {% set score_val = skins_won.get(p.id, 0) %} + {% set score_label = score_val | string + ' skin' + ('s' if score_val != 1 else '') %} + {% endif %} {% else %} {% set t = totals.get(p.id, {}) %} {% set score_val = t.total %} @@ -43,7 +48,8 @@ {% endfor %} {% if game.format == 'skins' and unclaimed %}
- {{ unclaimed }} skin{{ 's' if unclaimed != 1 else '' }} unclaimed + {% if skin_value and unclaimed_money %}{{ "%.4g" % unclaimed_money }} unclaimed + {% else %}{{ unclaimed }} skin{{ 's' if unclaimed != 1 else '' }} unclaimed{% endif %}
{% endif %}
@@ -246,8 +252,12 @@ {% endfor %} {% if hr %} - {% if hr.winner %}✓{% if hr.pot > 1 %} {{ hr.pot }}{% endif %} - {% else %}→ {{ hr.pot }}{% endif %} + {% if skin_value %} + {% if hr.winner %}{{ "%.4g" % hr.money_pot }}{% else %}→ {{ "%.4g" % hr.money_pot }}{% endif %} + {% else %} + {% if hr.winner %}✓{% if hr.pot > 1 %} {{ hr.pot }}{% endif %} + {% else %}→ {{ hr.pot }}{% endif %} + {% endif %} {% endif %} @@ -255,12 +265,16 @@ - Skins + {% if skin_value %}Value{% else %}Skins{% endif %} {% if hole_pars %}{% endif %} {% for p in players %} + {% if skin_value and money_won %} + {{ "%.4g" % money_won.get(p.id, 0) }} + {% else %} {{ skins_won.get(p.id, 0) }} + {% endif %} {% endfor %} - {% if unclaimed %}({{ unclaimed }}){% endif %} + {% if skin_value and unclaimed_money %}({{ "%.4g" % unclaimed_money }}){% elif unclaimed %}({{ unclaimed }}){% endif %}