Add skin value and back-nine doubling to skins games

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 <noreply@anthropic.com>
This commit is contained in:
Rolf
2026-03-23 20:56:13 +01:00
parent 8601584099
commit 6317422316
5 changed files with 118 additions and 38 deletions
+2
View File
@@ -124,6 +124,8 @@ async def init_db():
"ALTER TABLE game_players ADD COLUMN playing_handicap INTEGER", "ALTER TABLE game_players ADD COLUMN playing_handicap INTEGER",
"ALTER TABLE game_players ADD COLUMN strokes_allocation TEXT", "ALTER TABLE game_players ADD COLUMN strokes_allocation TEXT",
"ALTER TABLE magic_link_tokens ADD COLUMN code 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: try:
await db.execute(migration) await db.execute(migration)
+49 -16
View File
@@ -89,27 +89,36 @@ def _strokes_per_hole(players: list, holes: list, hole_stroke_indices: dict) ->
return result return result
def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True): def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True,
"""Returns (skins_per_player, hole_results, unclaimed_pot)""" 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} skins = {p["id"]: 0 for p in players}
money = {p["id"]: 0.0 for p in players}
hole_results = {} hole_results = {}
pot = 0 pot = 0
money_pot = 0.0
for hole in holes: for hole in holes:
scored = {uid: s["strokes"] for uid, s in scores.get(hole, {}).items() if s.get("strokes")} scored = {uid: s["strokes"] for uid, s in scores.get(hole, {}).items() if s.get("strokes")}
if not scored: if not scored:
continue continue
pot += 1 pot += 1
if skin_value:
money_pot += skin_value * (2 if skin_double_back9 and hole >= 10 else 1)
min_score = min(scored.values()) min_score = min(scored.values())
winners = [uid for uid, s in scored.items() if s == min_score] winners = [uid for uid, s in scored.items() if s == min_score]
if len(winners) == 1: if len(winners) == 1:
skins[winners[0]] += pot 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 pot = 0
money_pot = 0.0
else: 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: if not carryover:
pot = 0 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: 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) players_g = await _get_players(gid, db)
scores_g = await _get_scores(gid, db) scores_g = await _get_scores(gid, db)
holes_g = list(range(1, g["holes_count"] + 1)) 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: if skins_g:
winner_id = max(skins_g, key=skins_g.get) winner_id = max(skins_g, key=skins_g.get)
if skins_g[winner_id] > 0: if skins_g[winner_id] > 0:
@@ -265,25 +274,40 @@ async def create_game(request: Request, holes_count: int = Form(...),
game_id = secrets.token_urlsafe(10) game_id = secrets.token_urlsafe(10)
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
carryover = 1 if form.get("carryover") == "1" else 0 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( await db.execute(
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover) VALUES (?,?,?,?,?,?,?,?)", "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), (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 # 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] = {} hole_stroke_indices: dict[int, int] = {}
if course_id: 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( async with db.execute(
"SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? AND hole_number <= ? ORDER BY hole_number", "SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number",
(course_id, holes_count), (course_id,),
) as c: ) as c:
for row in await c.fetchall(): for row in await c.fetchall():
course_par += row["par"] or 0 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: if row["stroke_index"] is not None:
hole_stroke_indices[row["hole_number"]] = row["stroke_index"] 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_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} played_with_si = {h: hole_stroke_indices[h] for h in played_holes if h in hole_stroke_indices}
hole_rank: dict[int, int] = {} hole_rank: dict[int, int] = {}
@@ -300,11 +324,13 @@ async def create_game(request: Request, holes_count: int = Form(...),
playing_handicap = None playing_handicap = None
strokes_allocation_json = 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: async with db.execute("SELECT slope, rating FROM course_tees WHERE id = ?", (tee_id,)) as c:
tee_row = await c.fetchone() tee_row = await c.fetchone()
if tee_row: 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: if hole_rank and playing_handicap > 0:
base, extra = divmod(playing_handicap, n_si) base, extra = divmod(playing_handicap, n_si)
allocation = {str(h): base + (1 if hole_rank[h] <= extra else 0) 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} strokes_per_hole = {p["id"]: p["strokes_allocation"] or computed.get(p["id"], {}) for p in players}
has_stroke_index = bool(hole_stroke_indices) 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": if game["format"] == "skins":
carryover = bool(game["carryover"]) 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) results = sorted(players, key=lambda p: skins_won.get(p["id"], 0), reverse=True)
else: else:
results = sorted(players, key=lambda p: totals.get(p["id"], {}).get("total", 0), reverse=True) 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, "has_stroke_index": has_stroke_index,
"results": results, "results": results,
"skins_won": skins_won, "skins_hole_results": skins_hole_results, "unclaimed": unclaimed, "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"]), "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, "holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices,
"strokes_per_hole": strokes_per_hole, "has_stroke_index": has_stroke_index, "strokes_per_hole": strokes_per_hole, "has_stroke_index": has_stroke_index,
"carryover": bool(game["carryover"]), "carryover": bool(game["carryover"]),
"skin_value": game["skin_value"],
"skin_double_back9": bool(game["skin_double_back_nine"]),
}) })
+20 -8
View File
@@ -241,12 +241,12 @@
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="totals-row"> <tr class="totals-row">
<td>Skins</td> <td x-text="SKIN_VALUE ? 'Value' : 'Skins'"></td>
{% if hole_pars %}<td></td>{% endif %} {% if hole_pars %}<td></td>{% endif %}
{% for p in players %} {% for p in players %}
<td x-text="calcSkins().skins['{{ p.id }}'] || 0"></td> <td x-text="SKIN_VALUE ? (parseFloat(calcSkins().money['{{ p.id }}'].toPrecision(4)) || 0) : (calcSkins().skins['{{ p.id }}'] || 0)"></td>
{% endfor %} {% endfor %}
<td x-text="calcSkins().unclaimed ? '(' + calcSkins().unclaimed + ')' : ''"></td> <td x-text="SKIN_VALUE ? (calcSkins().unclaimedMoney ? '(' + parseFloat(calcSkins().unclaimedMoney.toPrecision(4)) + ')' : '') : (calcSkins().unclaimed ? '(' + calcSkins().unclaimed + ')' : '')"></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -278,6 +278,8 @@ const GAME_ID = "{{ game.id }}";
const GAME_HOLES = {{ game.holes_count }}; const GAME_HOLES = {{ game.holes_count }};
const GAME_FORMAT = "{{ game.format }}"; const GAME_FORMAT = "{{ game.format }}";
const SKINS_CARRYOVER = {{ carryover | tojson }}; 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_PARS = {{ hole_pars | tojson }};
const HOLE_SIS = {{ hole_stroke_indices | tojson }}; const HOLE_SIS = {{ hole_stroke_indices | tojson }};
const STROKES_PER_HOLE = {{ strokes_per_hole | tojson }}; const STROKES_PER_HOLE = {{ strokes_per_hole | tojson }};
@@ -483,9 +485,11 @@ function gameApp() {
calcSkins() { calcSkins() {
const skins = {}; 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 = {}; const holes = {};
let pot = 0; let pot = 0;
let moneyPot = 0;
for (let hole = 1; hole <= GAME_HOLES; hole++) { for (let hole = 1; hole <= GAME_HOLES; hole++) {
if (this.isSkipped(hole)) continue; if (this.isSkipped(hole)) continue;
const holeScores = this.scores[hole] || {}; const holeScores = this.scores[hole] || {};
@@ -494,18 +498,21 @@ function gameApp() {
.filter(x => x.strokes > 0); .filter(x => x.strokes > 0);
if (scored.length === 0) continue; if (scored.length === 0) continue;
pot += 1; 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 min = Math.min(...scored.map(x => x.strokes));
const winners = scored.filter(x => x.strokes === min); const winners = scored.filter(x => x.strokes === min);
if (winners.length === 1) { if (winners.length === 1) {
skins[winners[0].id] += pot; 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; pot = 0;
moneyPot = 0;
} else { } else {
holes[hole] = { winner: null, pot }; holes[hole] = { winner: null, pot, moneyPot };
if (!SKINS_CARRYOVER) pot = 0; if (!SKINS_CARRYOVER) { pot = 0; moneyPot = 0; }
} }
} }
return { skins, holes, unclaimed: pot }; return { skins, money, holes, unclaimed: pot, unclaimedMoney: moneyPot };
}, },
isSkinWinner(hole, uid) { isSkinWinner(hole, uid) {
@@ -515,6 +522,11 @@ function gameApp() {
skinHoleLabel(hole) { skinHoleLabel(hole) {
const h = this.calcSkins().holes[hole]; const h = this.calcSkins().holes[hole];
if (!h) return ''; 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 : '✓'; if (h.winner) return h.pot > 1 ? '✓ ' + h.pot : '✓';
return '→ ' + h.pot; return '→ ' + h.pot;
}, },
+24 -5
View File
@@ -28,6 +28,8 @@
courseSearch: '', courseSearch: '',
format: 'bingo_bango_bongo', format: 'bingo_bango_bongo',
carryoverEnabled: true, carryoverEnabled: true,
skinValue: '',
skinDoubleBackNine: false,
holesCount: '', holesCount: '',
selectedPlayers: ['{{ user.id }}'], selectedPlayers: ['{{ user.id }}'],
selectedTees: {}, selectedTees: {},
@@ -62,10 +64,10 @@
const player = this.getPlayer(uid); const player = this.getPlayer(uid);
const tee = this.getTee(uid); const tee = this.getTee(uid);
const course = COURSES_DATA.find(c => c.id === this.courseId); const course = COURSES_DATA.find(c => c.id === this.courseId);
if (!course || !tee || player.handicap == null) return null; if (!course || !tee || player.handicap == null || !course.par) return null;
const coursePar = (parseInt(this.holesCount) === 9 && course.holes === 18) ? course.par_9 : course.par; const nineOfEighteen = parseInt(this.holesCount) === 9 && course.holes === 18;
if (!coursePar) return null; const ph = Math.round(player.handicap * (tee.slope / 113) + (tee.rating - course.par));
return Math.round(player.handicap * (tee.slope / 113) + (tee.rating - coursePar)); return nineOfEighteen ? Math.round(ph / 2) : ph;
}, },
goToConfirm() { this.step = 3; }, goToConfirm() { this.step = 3; },
backFromConfirm() { this.step = this.hasTees() ? 2 : 1; }, backFromConfirm() { this.step = this.hasTees() ? 2 : 1; },
@@ -101,12 +103,25 @@
<span>Skins</span> <span>Skins</span>
</label> </label>
</div> </div>
<div x-show="format === 'skins'" style="margin-top:0.75rem;"> <div x-show="format === 'skins'" style="margin-top:0.75rem; display:flex; flex-direction:column; gap:0.5rem;">
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;"> <label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" name="carryover" value="1" checked style="width:auto;" <input type="checkbox" name="carryover" value="1" checked style="width:auto;"
@change="carryoverEnabled = $event.target.checked"> @change="carryoverEnabled = $event.target.checked">
<span style="font-weight:400; color:var(--text-muted);">Carryover (ties carry to next hole)</span> <span style="font-weight:400; color:var(--text-muted);">Carryover (ties carry to next hole)</span>
</label> </label>
<div style="display:flex; align-items:center; gap:0.75rem;">
<span style="font-weight:400; color:var(--text-muted); font-size:0.95rem;">Value per skin</span>
<input type="number" name="skin_value" x-model="skinValue" min="0" step="0.5"
placeholder="—"
style="width:5rem; padding:0.4rem 0.5rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:1rem; text-align:center;">
</div>
<div x-show="skinValue && holesCount == 18">
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" name="skin_double_back_nine" value="1" style="width:auto;"
@change="skinDoubleBackNine = $event.target.checked">
<span style="font-weight:400; color:var(--text-muted);">Double value on back nine (holes 1018)</span>
</label>
</div>
</div> </div>
</div> </div>
@@ -257,6 +272,10 @@
<span class="confirm-label">Carryover</span> <span class="confirm-label">Carryover</span>
<span class="confirm-value" x-text="carryoverEnabled ? 'Yes' : 'No'"></span> <span class="confirm-value" x-text="carryoverEnabled ? 'Yes' : 'No'"></span>
</div> </div>
<div class="confirm-row" x-show="format === 'skins' && skinValue">
<span class="confirm-label">Skin value</span>
<span class="confirm-value" x-text="skinValue + (skinDoubleBackNine ? ' · doubles on back 9' : '')"></span>
</div>
<div class="confirm-row"> <div class="confirm-row">
<span class="confirm-label">Course</span> <span class="confirm-label">Course</span>
<span class="confirm-value" x-text="courseLabel || '—'"></span> <span class="confirm-value" x-text="courseLabel || '—'"></span>
+17 -3
View File
@@ -16,8 +16,13 @@
<div class="card" style="padding:0.75rem 1rem;"> <div class="card" style="padding:0.75rem 1rem;">
{% for p in results %} {% for p in results %}
{% if game.format == 'skins' %} {% if game.format == 'skins' %}
{% 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_val = skins_won.get(p.id, 0) %}
{% set score_label = score_val | string + ' skin' + ('s' if score_val != 1 else '') %} {% set score_label = score_val | string + ' skin' + ('s' if score_val != 1 else '') %}
{% endif %}
{% else %} {% else %}
{% set t = totals.get(p.id, {}) %} {% set t = totals.get(p.id, {}) %}
{% set score_val = t.total %} {% set score_val = t.total %}
@@ -43,7 +48,8 @@
{% endfor %} {% endfor %}
{% if game.format == 'skins' and unclaimed %} {% if game.format == 'skins' and unclaimed %}
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:0.5rem; text-align:center;"> <div style="font-size:0.8rem; color:var(--text-muted); margin-top:0.5rem; text-align:center;">
{{ 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 %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -246,21 +252,29 @@
{% endfor %} {% endfor %}
<td class="score-cell"> <td class="score-cell">
{% if hr %} {% if hr %}
{% 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 %} {% if hr.winner %}✓{% if hr.pot > 1 %} {{ hr.pot }}{% endif %}
{% else %}→ {{ hr.pot }}{% endif %} {% else %}→ {{ hr.pot }}{% endif %}
{% endif %} {% endif %}
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="totals-row"> <tr class="totals-row">
<td>Skins</td> <td>{% if skin_value %}Value{% else %}Skins{% endif %}</td>
{% if hole_pars %}<td></td>{% endif %} {% if hole_pars %}<td></td>{% endif %}
{% for p in players %} {% for p in players %}
{% if skin_value and money_won %}
<td>{{ "%.4g" % money_won.get(p.id, 0) }}</td>
{% else %}
<td>{{ skins_won.get(p.id, 0) }}</td> <td>{{ skins_won.get(p.id, 0) }}</td>
{% endif %}
{% endfor %} {% endfor %}
<td>{% if unclaimed %}({{ unclaimed }}){% endif %}</td> <td>{% if skin_value and unclaimed_money %}({{ "%.4g" % unclaimed_money }}){% elif unclaimed %}({{ unclaimed }}){% endif %}</td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>