Add straight up / net handicap option for skins games
When creating a skins game, players can now choose between straight up (gross scores) or net scoring (strokes subtracted per hole based on playing handicap and stroke index). Net mode is stored on the game and applied in both the live scorecard and the summary. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -126,6 +126,7 @@ async def init_db():
|
|||||||
"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_value REAL",
|
||||||
"ALTER TABLE games ADD COLUMN skin_double_back_nine INTEGER NOT NULL DEFAULT 0",
|
"ALTER TABLE games ADD COLUMN skin_double_back_nine INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE games ADD COLUMN skins_net INTEGER NOT NULL DEFAULT 0",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
await db.execute(migration)
|
await db.execute(migration)
|
||||||
|
|||||||
+17
-7
@@ -90,16 +90,22 @@ def _strokes_per_hole(players: list, holes: list, hole_stroke_indices: dict) ->
|
|||||||
|
|
||||||
|
|
||||||
def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True,
|
def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True,
|
||||||
skin_value: float | None = None, skin_double_back9: bool = False):
|
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)
|
"""Returns (skins_per_player, hole_results, unclaimed_pot, money_per_player, unclaimed_money)
|
||||||
money_per_player is None when skin_value is not set."""
|
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}
|
skins = {p["id"]: 0 for p in players}
|
||||||
money = {p["id"]: 0.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
|
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")}
|
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:
|
if not scored:
|
||||||
continue
|
continue
|
||||||
pot += 1
|
pot += 1
|
||||||
@@ -277,10 +283,11 @@ async def create_game(request: Request, holes_count: int = Form(...),
|
|||||||
skin_value_str = (form.get("skin_value") or "").strip()
|
skin_value_str = (form.get("skin_value") or "").strip()
|
||||||
skin_value = float(skin_value_str) if skin_value_str else None
|
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
|
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
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover, skin_value, skin_double_back_nine) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover, skin_value, skin_double_back_nine, skins_net) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
(game_id, format, holes_count, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine),
|
(game_id, format, holes_count, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine, skins_net),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pre-fetch course holes for handicap calculation
|
# Pre-fetch course holes for handicap calculation
|
||||||
@@ -378,10 +385,12 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
|
|||||||
skins_won, skins_hole_results, unclaimed, money_won, unclaimed_money = None, None, 0, None, 0.0
|
skins_won, skins_hole_results, unclaimed, money_won, unclaimed_money = None, None, 0, None, 0.0
|
||||||
skin_value = game["skin_value"]
|
skin_value = game["skin_value"]
|
||||||
skin_double_back9 = bool(game["skin_double_back_nine"])
|
skin_double_back9 = bool(game["skin_double_back_nine"])
|
||||||
|
skins_net = bool(game["skins_net"])
|
||||||
if game["format"] == "skins":
|
if game["format"] == "skins":
|
||||||
carryover = bool(game["carryover"])
|
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(
|
skins_won, skins_hole_results, unclaimed, money_won, unclaimed_money = _calc_skins(
|
||||||
players, scores, holes, carryover, skin_value, skin_double_back9)
|
players, scores, holes, carryover, skin_value, skin_double_back9, net_sph)
|
||||||
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)
|
||||||
@@ -395,7 +404,7 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
|
|||||||
"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,
|
"money_won": money_won, "unclaimed_money": unclaimed_money, "skin_value": skin_value,
|
||||||
"skin_double_back9": skin_double_back9,
|
"skin_double_back9": skin_double_back9, "skins_net": skins_net,
|
||||||
"carryover": bool(game["carryover"]),
|
"carryover": bool(game["carryover"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -436,6 +445,7 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur
|
|||||||
"carryover": bool(game["carryover"]),
|
"carryover": bool(game["carryover"]),
|
||||||
"skin_value": game["skin_value"],
|
"skin_value": game["skin_value"],
|
||||||
"skin_double_back9": bool(game["skin_double_back_nine"]),
|
"skin_double_back9": bool(game["skin_double_back_nine"]),
|
||||||
|
"skins_net": bool(game["skins_net"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ const GAME_FORMAT = "{{ game.format }}";
|
|||||||
const SKINS_CARRYOVER = {{ carryover | tojson }};
|
const SKINS_CARRYOVER = {{ carryover | tojson }};
|
||||||
const SKIN_VALUE = {{ skin_value | tojson }};
|
const SKIN_VALUE = {{ skin_value | tojson }};
|
||||||
const SKIN_DOUBLE_BACK9 = {{ skin_double_back9 | tojson }};
|
const SKIN_DOUBLE_BACK9 = {{ skin_double_back9 | tojson }};
|
||||||
|
const SKINS_NET = {{ skins_net | 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 }};
|
||||||
@@ -494,8 +495,12 @@ function gameApp() {
|
|||||||
if (this.isSkipped(hole)) continue;
|
if (this.isSkipped(hole)) continue;
|
||||||
const holeScores = this.scores[hole] || {};
|
const holeScores = this.scores[hole] || {};
|
||||||
const scored = PLAYERS_DATA
|
const scored = PLAYERS_DATA
|
||||||
.map(p => ({ id: p.id, strokes: holeScores[p.id]?.strokes || 0 }))
|
.map(p => {
|
||||||
.filter(x => x.strokes > 0);
|
const gross = holeScores[p.id]?.strokes || 0;
|
||||||
|
const net = SKINS_NET ? gross - ((STROKES_PER_HOLE[p.id] || {})[hole] || 0) : gross;
|
||||||
|
return { id: p.id, strokes: net, gross };
|
||||||
|
})
|
||||||
|
.filter(x => x.gross > 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);
|
if (SKIN_VALUE) moneyPot += SKIN_VALUE * (SKIN_DOUBLE_BACK9 && hole >= 10 ? 2 : 1);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
carryoverEnabled: true,
|
carryoverEnabled: true,
|
||||||
skinValue: '',
|
skinValue: '',
|
||||||
skinDoubleBackNine: false,
|
skinDoubleBackNine: false,
|
||||||
|
skinsNet: false,
|
||||||
holesCount: '',
|
holesCount: '',
|
||||||
selectedPlayers: ['{{ user.id }}'],
|
selectedPlayers: ['{{ user.id }}'],
|
||||||
selectedTees: {},
|
selectedTees: {},
|
||||||
@@ -104,6 +105,19 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="format === 'skins'" style="margin-top:0.75rem; display:flex; flex-direction:column; gap:0.5rem;">
|
<div x-show="format === 'skins'" style="margin-top:0.75rem; display:flex; flex-direction:column; gap:0.5rem;">
|
||||||
|
<input type="hidden" name="skins_net" :value="skinsNet ? '1' : '0'">
|
||||||
|
<div style="display:flex; gap:0.75rem;">
|
||||||
|
<label class="radio-card" style="flex:1;">
|
||||||
|
<input type="radio" name="_skins_mode" value="0" checked
|
||||||
|
@change="skinsNet = false">
|
||||||
|
<span>Straight up</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-card" style="flex:1;">
|
||||||
|
<input type="radio" name="_skins_mode" value="1"
|
||||||
|
@change="skinsNet = true">
|
||||||
|
<span>Net (handicap)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<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">
|
||||||
@@ -268,6 +282,10 @@
|
|||||||
<span class="confirm-label">Format</span>
|
<span class="confirm-label">Format</span>
|
||||||
<span class="confirm-value" x-text="formatLabel()"></span>
|
<span class="confirm-value" x-text="formatLabel()"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="confirm-row" x-show="format === 'skins'">
|
||||||
|
<span class="confirm-label">Scoring</span>
|
||||||
|
<span class="confirm-value" x-text="skinsNet ? 'Net (handicap)' : 'Straight up'"></span>
|
||||||
|
</div>
|
||||||
<div class="confirm-row" x-show="format === 'skins'">
|
<div class="confirm-row" x-show="format === 'skins'">
|
||||||
<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>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<a href="javascript:history.back()" style="color:var(--text-muted); font-size:0.9rem;">← Back</a>
|
<a href="javascript:history.back()" style="color:var(--text-muted); font-size:0.9rem;">← Back</a>
|
||||||
<div style="text-align:center;">
|
<div style="text-align:center;">
|
||||||
<div style="font-weight:600;">{{ game.course_name or 'Bingo Bango Bongo' }}</div>
|
<div style="font-weight:600;">{{ game.course_name or 'Bingo Bango Bongo' }}</div>
|
||||||
<div style="font-size:0.8rem; color:var(--text-muted);">{{ game.holes_count }} holes · {{ game.created_at[:10] }}</div>
|
<div style="font-size:0.8rem; color:var(--text-muted);">{{ game.holes_count }} holes · {{ game.created_at[:10] }}{% if game.format == 'skins' and skins_net %} · net{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="width:3rem;"></div>
|
<div style="width:3rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user