From 6317422316a1a39a434d5487e5f67a5c41526097 Mon Sep 17 00:00:00 2001 From: Rolf Date: Mon, 23 Mar 2026 20:56:13 +0100 Subject: [PATCH] Add skin value and back-nine doubling to skins games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Players can now set a monetary value per skin when starting a skins game. For 18-hole games, an option to double the value on holes 10–18 is available. The value is stored at game creation and displayed throughout the live game and summary (leaderboard, scorecard, and totals). Co-Authored-By: Claude Sonnet 4.6 --- app/database.py | 2 + app/routers/games.py | 69 +++++++++++++++++++++++-------- app/templates/games/game.html | 28 +++++++++---- app/templates/games/new_game.html | 29 ++++++++++--- app/templates/games/summary.html | 28 +++++++++---- 5 files changed, 118 insertions(+), 38 deletions(-) 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 %}