Add Skins as a side game for Bingo Bango Bongo
Players can now opt into a Skins side game when creating a BBB round. The side game uses the same skins settings (carryover, net/gross, skin value, double back nine). The scorecard gains a third Skins tab during play and on the summary; the BBB leaderboard continues to rank by BBB points. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -127,6 +127,7 @@ async def init_db():
|
|||||||
"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",
|
"ALTER TABLE games ADD COLUMN skins_net INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE games ADD COLUMN side_skins_enabled INTEGER NOT NULL DEFAULT 0",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
await db.execute(migration)
|
await db.execute(migration)
|
||||||
|
|||||||
@@ -284,10 +284,11 @@ async def create_game(request: Request, holes_count: int = Form(...),
|
|||||||
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
|
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(
|
await db.execute(
|
||||||
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover, skin_value, skin_double_back_nine, skins_net) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
"INSERT INTO games (id, format, holes_count, 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, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine, skins_net),
|
(game_id, format, holes_count, "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
|
# Pre-fetch course holes for handicap calculation
|
||||||
@@ -389,11 +390,14 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
|
|||||||
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"])
|
skins_net = bool(game["skins_net"])
|
||||||
if game["format"] == "skins":
|
side_skins_enabled = bool(game.get("side_skins_enabled", 0))
|
||||||
|
if game["format"] == "skins" or side_skins_enabled:
|
||||||
carryover = bool(game["carryover"])
|
carryover = bool(game["carryover"])
|
||||||
net_sph = strokes_per_hole if skins_net else None
|
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, net_sph)
|
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)
|
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)
|
||||||
@@ -408,6 +412,7 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
|
|||||||
"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, "skins_net": skins_net,
|
"skin_double_back9": skin_double_back9, "skins_net": skins_net,
|
||||||
|
"side_skins_enabled": side_skins_enabled,
|
||||||
"carryover": bool(game["carryover"]),
|
"carryover": bool(game["carryover"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -449,6 +454,7 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur
|
|||||||
"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"]),
|
"skins_net": bool(game["skins_net"]),
|
||||||
|
"side_skins_enabled": bool(game.get("side_skins_enabled", 0)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
{# ── Header ── #}
|
{# ── Header ── #}
|
||||||
<div class="game-header">
|
<div class="game-header">
|
||||||
<div style="display:flex; align-items:center; gap:0.75rem;">
|
<div style="display:flex; align-items:center; gap:0.75rem;">
|
||||||
<button @click="view === 'score' ? prevHole() : cardView = 'strokes'" class="icon-btn"
|
<button @click="view === 'score' ? prevHole() : cardPrev()" class="icon-btn"
|
||||||
:disabled="view === 'score' ? currentHole <= 1 : cardView === 'strokes'">‹</button>
|
:disabled="view === 'score' ? currentHole <= 1 : cardView === 'strokes'">‹</button>
|
||||||
<div style="text-align:center; flex:1;">
|
<div style="text-align:center; flex:1;">
|
||||||
<div class="hole-label" x-text="view === 'score' ? 'Hole ' + currentHole + ' / {{ game.holes_count }}' : (cardView === 'strokes' ? 'Strokes' : (GAME_FORMAT === 'skins' ? 'Skins' : 'Points'))"></div>
|
<div class="hole-label" x-text="view === 'score' ? 'Hole ' + currentHole + ' / {{ game.holes_count }}' : (cardView === 'strokes' ? 'Strokes' : cardView === 'points' ? 'Points' : 'Skins')"></div>
|
||||||
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.1rem;">
|
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.1rem;">
|
||||||
<template x-if="view === 'score'">
|
<template x-if="view === 'score'">
|
||||||
<span x-text="(HOLE_PARS[currentHole] ? 'Par ' + HOLE_PARS[currentHole] : '') + (HOLE_SIS[currentHole] ? ' · SI ' + HOLE_SIS[currentHole] : '')"></span>
|
<span x-text="(HOLE_PARS[currentHole] ? 'Par ' + HOLE_PARS[currentHole] : '') + (HOLE_SIS[currentHole] ? ' · SI ' + HOLE_SIS[currentHole] : '')"></span>
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="view === 'score' ? nextHole() : cardView = (GAME_FORMAT === 'skins' ? 'skins' : 'points')" class="icon-btn"
|
<button @click="view === 'score' ? nextHole() : cardNext()" class="icon-btn"
|
||||||
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView !== 'strokes'">›</button>
|
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView === (GAME_FORMAT === 'skins' || SIDE_SKINS_ENABLED ? 'skins' : 'points')">›</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,6 +281,7 @@ 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 SKINS_NET = {{ skins_net | tojson }};
|
||||||
|
const SIDE_SKINS_ENABLED = {{ side_skins_enabled | 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 }};
|
||||||
@@ -457,6 +458,22 @@ function gameApp() {
|
|||||||
prevHole() { if (this.currentHole > 1) { this.saveHole(this.currentHole); this.currentHole--; } },
|
prevHole() { if (this.currentHole > 1) { this.saveHole(this.currentHole); this.currentHole--; } },
|
||||||
nextHole() { if (this.currentHole < GAME_HOLES) { this.saveHole(this.currentHole); this.currentHole++; } },
|
nextHole() { if (this.currentHole < GAME_HOLES) { this.saveHole(this.currentHole); this.currentHole++; } },
|
||||||
|
|
||||||
|
cardTabs() {
|
||||||
|
if (GAME_FORMAT === 'skins') return ['strokes', 'skins'];
|
||||||
|
if (SIDE_SKINS_ENABLED) return ['strokes', 'points', 'skins'];
|
||||||
|
return ['strokes', 'points'];
|
||||||
|
},
|
||||||
|
cardNext() {
|
||||||
|
const tabs = this.cardTabs();
|
||||||
|
const idx = tabs.indexOf(this.cardView);
|
||||||
|
if (idx < tabs.length - 1) this.cardView = tabs[idx + 1];
|
||||||
|
},
|
||||||
|
cardPrev() {
|
||||||
|
const tabs = this.cardTabs();
|
||||||
|
const idx = tabs.indexOf(this.cardView);
|
||||||
|
if (idx > 0) this.cardView = tabs[idx - 1];
|
||||||
|
},
|
||||||
|
|
||||||
fmt(v) {
|
fmt(v) {
|
||||||
return Number.isInteger(v) || v % 1 === 0 ? v.toFixed(0) : v.toFixed(1);
|
return Number.isInteger(v) || v % 1 === 0 ? v.toFixed(0) : v.toFixed(1);
|
||||||
},
|
},
|
||||||
@@ -548,8 +565,7 @@ function gameApp() {
|
|||||||
if (this.view === 'score') {
|
if (this.view === 'score') {
|
||||||
if (dx < 0) this.nextHole(); else this.prevHole();
|
if (dx < 0) this.nextHole(); else this.prevHole();
|
||||||
} else {
|
} else {
|
||||||
const secondary = GAME_FORMAT === 'skins' ? 'skins' : 'points';
|
if (dx < 0) this.cardNext(); else this.cardPrev();
|
||||||
this.cardView = dx < 0 ? secondary : 'strokes';
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
skinValue: '',
|
skinValue: '',
|
||||||
skinDoubleBackNine: false,
|
skinDoubleBackNine: false,
|
||||||
skinsNet: false,
|
skinsNet: false,
|
||||||
|
sideSkinsEnabled: false,
|
||||||
holesCount: '',
|
holesCount: '',
|
||||||
selectedPlayers: ['{{ user.id }}'],
|
selectedPlayers: ['{{ user.id }}'],
|
||||||
selectedTees: {},
|
selectedTees: {},
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
backFromConfirm() { this.step = this.hasTees() ? 2 : 1; },
|
backFromConfirm() { this.step = this.hasTees() ? 2 : 1; },
|
||||||
formatLabel() {
|
formatLabel() {
|
||||||
if (this.format === 'skins') return 'Skins';
|
if (this.format === 'skins') return 'Skins';
|
||||||
|
if (this.sideSkinsEnabled) return 'Bingo Bango Bongo + Skins';
|
||||||
return 'Bingo Bango Bongo';
|
return 'Bingo Bango Bongo';
|
||||||
}
|
}
|
||||||
}">
|
}">
|
||||||
@@ -106,11 +108,18 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="radio-card" style="flex:1;">
|
<label class="radio-card" style="flex:1;">
|
||||||
<input type="radio" name="format" value="skins"
|
<input type="radio" name="format" value="skins"
|
||||||
@change="format = $event.target.value">
|
@change="format = $event.target.value; sideSkinsEnabled = false">
|
||||||
<span>Skins</span>
|
<span>Skins</span>
|
||||||
</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 === 'bingo_bango_bongo'" style="margin-top:0.5rem;">
|
||||||
|
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
|
||||||
|
<input type="checkbox" style="width:auto;"
|
||||||
|
@change="sideSkinsEnabled = $event.target.checked">
|
||||||
|
<span style="font-weight:400; color:var(--text-muted);">Add Skins as a side game</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div x-show="format === 'skins' || sideSkinsEnabled" style="margin-top:0.75rem; display:flex; flex-direction:column; gap:0.5rem;">
|
||||||
<input type="hidden" name="skins_net" :value="skinsNet ? '1' : '0'">
|
<input type="hidden" name="skins_net" :value="skinsNet ? '1' : '0'">
|
||||||
<div style="display:flex; gap:0.75rem;">
|
<div style="display:flex; gap:0.75rem;">
|
||||||
<label class="radio-card" style="flex:1;">
|
<label class="radio-card" style="flex:1;">
|
||||||
@@ -288,15 +297,19 @@
|
|||||||
<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'">
|
<div class="confirm-row" x-show="format === 'bingo_bango_bongo' && sideSkinsEnabled">
|
||||||
|
<span class="confirm-label">Side game</span>
|
||||||
|
<span class="confirm-value">Skins</span>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-row" x-show="format === 'skins' || sideSkinsEnabled">
|
||||||
<span class="confirm-label">Scoring</span>
|
<span class="confirm-label">Scoring</span>
|
||||||
<span class="confirm-value" x-text="skinsNet ? 'Net (handicap)' : 'Straight up'"></span>
|
<span class="confirm-value" x-text="skinsNet ? 'Net (handicap)' : 'Straight up'"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="confirm-row" x-show="format === 'skins'">
|
<div class="confirm-row" x-show="format === 'skins' || sideSkinsEnabled">
|
||||||
<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">
|
<div class="confirm-row" x-show="(format === 'skins' || sideSkinsEnabled) && skinValue">
|
||||||
<span class="confirm-label">Skin value</span>
|
<span class="confirm-label">Skin value</span>
|
||||||
<span class="confirm-value" x-text="skinValue + (skinDoubleBackNine ? ' · doubles on back 9' : '')"></span>
|
<span class="confirm-value" x-text="skinValue + (skinDoubleBackNine ? ' · doubles on back 9' : '')"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,6 +367,7 @@
|
|||||||
<template x-for="pid in selectedPlayers" :key="pid">
|
<template x-for="pid in selectedPlayers" :key="pid">
|
||||||
<input type="hidden" name="players" :value="pid">
|
<input type="hidden" name="players" :value="pid">
|
||||||
</template>
|
</template>
|
||||||
|
<input type="hidden" name="side_skins_enabled" :value="sideSkinsEnabled ? '1' : '0'">
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,11 +84,12 @@
|
|||||||
{# Scorecard toggle #}
|
{# Scorecard toggle #}
|
||||||
<div class="scorecard-toggle" style="margin-top:1rem;">
|
<div class="scorecard-toggle" style="margin-top:1rem;">
|
||||||
<button @click="view = 'strokes'" :class="{ active: view === 'strokes' }">Strokes</button>
|
<button @click="view = 'strokes'" :class="{ active: view === 'strokes' }">Strokes</button>
|
||||||
{% if game.format == 'skins' %}
|
{% if game.format != 'skins' %}
|
||||||
<button @click="view = 'skins'" :class="{ active: view === 'skins' }">Skins</button>
|
|
||||||
{% else %}
|
|
||||||
<button @click="view = 'points'" :class="{ active: view === 'points' }">Points</button>
|
<button @click="view = 'points'" :class="{ active: view === 'points' }">Points</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if game.format == 'skins' or side_skins_enabled %}
|
||||||
|
<button @click="view = 'skins'" :class="{ active: view === 'skins' }">Skins</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Strokes scorecard #}
|
{# Strokes scorecard #}
|
||||||
@@ -204,7 +205,7 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
{# Skins scorecard #}
|
{# Skins scorecard #}
|
||||||
{% if game.format == 'skins' %}
|
{% if game.format == 'skins' or side_skins_enabled %}
|
||||||
<table class="scorecard" x-show="view === 'skins'">
|
<table class="scorecard" x-show="view === 'skins'">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user