2c0717948a
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>
559 lines
22 KiB
HTML
559 lines
22 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Game – Skins & Pins{% endblock %}
|
||
|
||
|
||
{% block content %}
|
||
<div id="game-app"
|
||
x-data="gameApp()"
|
||
x-init="init()"
|
||
style="position:fixed; top:0; left:0; right:0; bottom:calc(var(--nav-height) + env(safe-area-inset-bottom)); display:flex; flex-direction:column; overflow:hidden;">
|
||
|
||
{# ── Header ── #}
|
||
<div class="game-header">
|
||
<div style="display:flex; align-items:center; gap:0.75rem;">
|
||
<button @click="view === 'score' ? prevHole() : cardView = 'strokes'" class="icon-btn"
|
||
:disabled="view === 'score' ? currentHole <= 1 : cardView === 'strokes'">‹</button>
|
||
<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 style="font-size:0.72rem; color:var(--text-muted); margin-top:0.1rem;">
|
||
<template x-if="view === 'score'">
|
||
<span x-text="(HOLE_PARS[currentHole] ? 'Par ' + HOLE_PARS[currentHole] : '') + (HOLE_SIS[currentHole] ? ' · SI ' + HOLE_SIS[currentHole] : '')"></span>
|
||
</template>
|
||
<template x-if="view === 'card'">
|
||
<span>{{ game.course_name or '' }}</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<button @click="view === 'score' ? nextHole() : cardView = (GAME_FORMAT === 'skins' ? 'skins' : 'points')" class="icon-btn"
|
||
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView !== 'strokes'">›</button>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{# ── Swipeable panels ── #}
|
||
<div class="swipe-container"
|
||
@touchstart="touchStart($event)"
|
||
@touchend="touchEnd($event)">
|
||
|
||
{# Score entry panel #}
|
||
<div class="swipe-panel" :class="{ active: view === 'score' }">
|
||
<div class="panel-scroll">
|
||
|
||
{# Skip hole #}
|
||
<div style="display:flex; justify-content:flex-end; padding: 0 0 0.75rem;">
|
||
<button class="btn-skip"
|
||
:class="{ skipped: isSkipped(currentHole) }"
|
||
@click="toggleSkip(currentHole)">
|
||
<span x-text="isSkipped(currentHole) ? '↩ Unskip hole' : 'Skip hole'"></span>
|
||
</button>
|
||
</div>
|
||
|
||
<div x-show="!isSkipped(currentHole)" id="player-list">
|
||
<template x-for="(p, idx) in players" :key="p.id">
|
||
<div class="player-score-card"
|
||
draggable="true"
|
||
:class="{ 'drag-source': dragSrc === idx, 'drag-over': dragOver === idx }"
|
||
@dragstart="dragSrc = idx; $event.dataTransfer.effectAllowed = 'move'"
|
||
@dragenter.prevent="dragOver = idx"
|
||
@dragover.prevent
|
||
@drop.prevent="dropPlayer(idx)"
|
||
@dragend="endDrag()">
|
||
<div class="psc-name">
|
||
<span class="drag-handle"
|
||
@touchstart.stop="dragSrc = idx; touchStartX = null"
|
||
@touchmove.stop.prevent="touchDragMove($event)"
|
||
@touchend.stop="touchDragEnd()">⠿</span>
|
||
<span x-text="p.name"></span>
|
||
<template x-if="HAS_STROKE_INDEX && (STROKES_PER_HOLE[p.id] || {})[currentHole] > 0">
|
||
<span style="font-size:0.7rem; color:var(--accent); margin-left:0.35rem; font-weight:600;"
|
||
x-text="'+' + (STROKES_PER_HOLE[p.id] || {})[currentHole]"></span>
|
||
</template>
|
||
<template x-if="!HAS_STROKE_INDEX && p.playing_handicap != null">
|
||
<span style="font-size:0.7rem; color:var(--text-muted); margin-left:0.35rem;"
|
||
x-text="'HC ' + p.playing_handicap"></span>
|
||
</template>
|
||
</div>
|
||
<div class="psc-controls">
|
||
<span class="stroke-controls">
|
||
<button type="button" class="stroke-btn stroke-minus"
|
||
@click="adjustStrokes(currentHole, p.id, -1)">−</button>
|
||
<span class="stroke-val"
|
||
x-text="getScore(currentHole, p.id).strokes || '—'"></span>
|
||
<button type="button" class="stroke-btn stroke-plus"
|
||
@click="adjustStrokes(currentHole, p.id, 1)">+</button>
|
||
</span>
|
||
<template x-if="GAME_FORMAT === 'bingo_bango_bongo'">
|
||
<span>
|
||
<button type="button" class="bbb-btn bingo-btn"
|
||
:class="{ active: getScore(currentHole, p.id).bingo }"
|
||
@click="setBingo(currentHole, p.id)">Bingo</button>
|
||
<button type="button" class="bbb-btn bango-btn"
|
||
:class="{ active: getScore(currentHole, p.id).bango > 0 }"
|
||
@click="toggleBango(currentHole, p.id)">Bango</button>
|
||
<button type="button" class="bbb-btn bongo-btn"
|
||
:class="{ active: getScore(currentHole, p.id).bongo }"
|
||
@click="setBongo(currentHole, p.id)">Bongo</button>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div x-show="isSkipped(currentHole)" class="empty-state" style="padding: 2rem 0;">
|
||
<div class="empty-icon">⏭</div>
|
||
<p>Hole {{ '{{ currentHole }}' }} skipped</p>
|
||
</div>
|
||
|
||
{% if game.status == 'active' %}
|
||
<template x-if="allHolesDone()">
|
||
<form method="post" action="/games/{{ game.id }}/finish" style="margin-top: 1rem;">
|
||
<button type="submit" class="btn btn-primary">Finish game</button>
|
||
</form>
|
||
</template>
|
||
{% endif %}
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{# Scorecard panel #}
|
||
<div class="swipe-panel" :class="{ active: view === 'card' }">
|
||
<div class="panel-scroll">
|
||
{# Strokes scorecard #}
|
||
<table class="scorecard" x-show="cardView === 'strokes'">
|
||
<thead>
|
||
<tr>
|
||
<th>Hole</th>
|
||
{% if hole_pars %}<th style="color:var(--text-muted);">Par</th>{% endif %}
|
||
{% for p in players %}
|
||
<th>
|
||
{% if p.avatar and p.avatar.startswith('/') %}
|
||
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||
{% else %}
|
||
{{ p.avatar or '👤' }}
|
||
{% endif %}<br>
|
||
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
|
||
</th>
|
||
{% endfor %}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for h in holes %}
|
||
<tr :class="{ 'hole-skipped': isSkipped({{ h }}) }">
|
||
<td class="hole-num">{{ h }}</td>
|
||
{% if hole_pars %}<td class="score-cell" style="color:var(--text-muted);">{{ hole_pars.get(h, '—') }}</td>{% endif %}
|
||
{% for p in players %}
|
||
{% set sr = strokes_per_hole.get(p.id, {}).get(h, 0) %}
|
||
<td class="score-cell">
|
||
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
|
||
:class="holeScoreClass({{ h }}, '{{ p.id }}')"></span>
|
||
{% if has_stroke_index and sr > 0 %}<sup style="color:var(--accent); font-size:0.6em; line-height:0;">{{ '/' * sr }}</sup>{% endif %}
|
||
</td>
|
||
{% endfor %}
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr class="totals-row">
|
||
<td>Total</td>
|
||
{% if hole_pars %}<td style="color:var(--text-muted);">{{ hole_pars.values() | sum }}</td>{% endif %}
|
||
{% for p in players %}
|
||
<td x-text="Object.values(scores).reduce((sum, h) => sum + (h['{{ p.id }}']?.strokes || 0), 0) || '—'"></td>
|
||
{% endfor %}
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
|
||
{# Points scorecard #}
|
||
<table class="scorecard" x-show="cardView === 'points'">
|
||
<thead>
|
||
<tr>
|
||
<th>Hole</th>
|
||
{% for p in players %}
|
||
<th>
|
||
{% if p.avatar and p.avatar.startswith('/') %}
|
||
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||
{% else %}
|
||
{{ p.avatar or '👤' }}
|
||
{% endif %}<br>
|
||
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
|
||
</th>
|
||
{% endfor %}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for h in holes %}
|
||
<tr :class="{ 'hole-skipped': isSkipped({{ h }}) }">
|
||
<td class="hole-num">{{ h }}</td>
|
||
{% for p in players %}
|
||
<td class="score-cell">
|
||
<span x-text="holeTotalStr({{ h }}, '{{ p.id }}')"></span>
|
||
</td>
|
||
{% endfor %}
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr class="totals-row">
|
||
<td>Total</td>
|
||
{% for p in players %}
|
||
<td x-text="fmt(totals['{{ p.id }}']?.total ?? 0)"></td>
|
||
{% endfor %}
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
|
||
{# Skins scorecard #}
|
||
<table class="scorecard" x-show="cardView === 'skins'">
|
||
<thead>
|
||
<tr>
|
||
<th>Hole</th>
|
||
{% if hole_pars %}<th style="color:var(--text-muted);">Par</th>{% endif %}
|
||
{% for p in players %}
|
||
<th>
|
||
{% if p.avatar and p.avatar.startswith('/') %}
|
||
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||
{% else %}
|
||
{{ p.avatar or '👤' }}
|
||
{% endif %}<br>
|
||
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
|
||
</th>
|
||
{% endfor %}
|
||
<th style="color:var(--text-muted);">Skin</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for h in holes %}
|
||
<tr :class="{ 'hole-skipped': isSkipped({{ h }}) }">
|
||
<td class="hole-num">{{ h }}</td>
|
||
{% if hole_pars %}<td class="score-cell" style="color:var(--text-muted);">{{ hole_pars.get(h, '—') }}</td>{% endif %}
|
||
{% for p in players %}
|
||
{% set sr = strokes_per_hole.get(p.id, {}).get(h, 0) %}
|
||
<td class="score-cell">
|
||
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
|
||
:class="[holeScoreClass({{ h }}, '{{ p.id }}'), isSkinWinner({{ h }}, '{{ p.id }}') ? 'skin-winner' : '']"></span>
|
||
{% if has_stroke_index and sr > 0 %}<sup style="color:var(--accent); font-size:0.6em; line-height:0;">{{ '/' * sr }}</sup>{% endif %}
|
||
</td>
|
||
{% endfor %}
|
||
<td class="score-cell" x-text="skinHoleLabel({{ h }})"></td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr class="totals-row">
|
||
<td x-text="SKIN_VALUE ? 'Value' : 'Skins'"></td>
|
||
{% if hole_pars %}<td></td>{% endif %}
|
||
{% for p in players %}
|
||
<td x-text="SKIN_VALUE ? (parseFloat(calcSkins().money['{{ p.id }}'].toPrecision(4)) || 0) : (calcSkins().skins['{{ p.id }}'] || 0)"></td>
|
||
{% endfor %}
|
||
<td x-text="SKIN_VALUE ? (calcSkins().unclaimedMoney ? '(' + parseFloat(calcSkins().unclaimedMoney.toPrecision(4)) + ')' : '') : (calcSkins().unclaimed ? '(' + calcSkins().unclaimed + ')' : '')"></td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
|
||
{% if game.status == 'active' %}
|
||
<template x-if="allHolesDone()">
|
||
<form method="post" action="/games/{{ game.id }}/finish" style="margin-top: 1rem;">
|
||
<button type="submit" class="btn btn-primary">Finish game</button>
|
||
</form>
|
||
</template>
|
||
{% endif %}
|
||
|
||
</div>
|
||
</div>
|
||
|
||
</div>{# end swipe-container #}
|
||
|
||
{# ── View toggle ── #}
|
||
<div class="view-toggle">
|
||
<button @click="view = 'score'" :class="{ active: view === 'score' }">Score</button>
|
||
<button @click="if (view === 'score') saveHole(currentHole); view = 'card'" :class="{ active: view === 'card' }">Card</button>
|
||
</div>
|
||
|
||
|
||
</div>
|
||
|
||
<script>
|
||
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 SKINS_NET = {{ skins_net | tojson }};
|
||
const HOLE_PARS = {{ hole_pars | tojson }};
|
||
const HOLE_SIS = {{ hole_stroke_indices | tojson }};
|
||
const STROKES_PER_HOLE = {{ strokes_per_hole | tojson }};
|
||
const HAS_STROKE_INDEX = {{ has_stroke_index | tojson }};
|
||
const PLAYERS_DATA = {{ players | tojson }};
|
||
|
||
// Initial scores from server
|
||
const initialScores = {{ scores | tojson }};
|
||
const initialTotals = {{ totals | tojson }};
|
||
|
||
function gameApp() {
|
||
return {
|
||
view: 'score',
|
||
cardView: 'strokes',
|
||
currentHole: 1,
|
||
players: [],
|
||
scores: {}, // { hole: { uid: {strokes,bingo,bango,bongo} } }
|
||
totals: {},
|
||
skipped: new Set(),
|
||
dirty: new Set(),
|
||
ws: null,
|
||
touchStartX: 0,
|
||
dragSrc: null,
|
||
dragOver: null,
|
||
|
||
init() {
|
||
this.players = PLAYERS_DATA;
|
||
|
||
// Load initial scores from server
|
||
this.totals = initialTotals;
|
||
for (const [hole, playerScores] of Object.entries(initialScores)) {
|
||
const h = parseInt(hole);
|
||
this.scores[h] = {};
|
||
for (const [uid, s] of Object.entries(playerScores)) {
|
||
this.scores[h][uid] = { strokes: s.strokes ?? 0, bingo: s.bingo, bango: s.bango, bongo: s.bongo };
|
||
}
|
||
}
|
||
// Jump to the last scored hole for active games
|
||
const scoredHoles = Object.keys(initialScores).map(Number).filter(h => Object.keys(initialScores[h]).length > 0);
|
||
if (scoredHoles.length > 0) this.currentHole = Math.max(...scoredHoles);
|
||
|
||
this.connectWs();
|
||
},
|
||
|
||
connectWs() {
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
this.ws = new WebSocket(`${proto}://${location.host}/games/${GAME_ID}/ws`);
|
||
this.ws.onmessage = (e) => {
|
||
const msg = JSON.parse(e.data);
|
||
if (msg.type === 'score_update') {
|
||
const h = msg.hole;
|
||
if (!this.scores[h]) this.scores[h] = {};
|
||
for (const [uid, s] of Object.entries(msg.scores)) {
|
||
this.scores[h][uid] = s;
|
||
}
|
||
this.totals = msg.totals;
|
||
}
|
||
};
|
||
this.ws.onclose = () => setTimeout(() => this.connectWs(), 2000);
|
||
},
|
||
|
||
getScore(hole, uid) {
|
||
return this.scores[hole]?.[uid] ?? { strokes: 0, bingo: 0, bango: 0.0, bongo: 0 };
|
||
},
|
||
|
||
_ensurePlayer(hole, uid) {
|
||
if (!this.scores[hole]) this.scores[hole] = {};
|
||
if (!this.scores[hole][uid]) this.scores[hole][uid] = { strokes: 0, bingo: 0, bango: 0.0, bongo: 0 };
|
||
},
|
||
|
||
adjustStrokes(hole, uid, delta) {
|
||
this._ensurePlayer(hole, uid);
|
||
const cur = this.scores[hole][uid].strokes;
|
||
this.scores[hole][uid].strokes = cur ? Math.max(1, cur + delta) : (HOLE_PARS[hole] || 4);
|
||
this.dirty.add(hole);
|
||
},
|
||
|
||
setBingo(hole, uid) {
|
||
this._ensurePlayer(hole, uid);
|
||
const current = this.scores[hole][uid].bingo;
|
||
for (const p in this.scores[hole]) this.scores[hole][p].bingo = 0;
|
||
this.scores[hole][uid].bingo = current ? 0 : 1;
|
||
this.dirty.add(hole);
|
||
},
|
||
|
||
toggleBango(hole, uid) {
|
||
this._ensurePlayer(hole, uid);
|
||
this.scores[hole][uid].bango = this.scores[hole][uid].bango > 0 ? 0 : 1;
|
||
// Recalculate split
|
||
const winners = Object.entries(this.scores[hole]).filter(([, s]) => s.bango > 0).map(([u]) => u);
|
||
const share = winners.length ? Math.round(10000 / winners.length) / 10000 : 0;
|
||
for (const p in this.scores[hole]) this.scores[hole][p].bango = winners.includes(p) ? share : 0;
|
||
this.dirty.add(hole);
|
||
},
|
||
|
||
setBongo(hole, uid) {
|
||
this._ensurePlayer(hole, uid);
|
||
const current = this.scores[hole][uid].bongo;
|
||
for (const p in this.scores[hole]) this.scores[hole][p].bongo = 0;
|
||
this.scores[hole][uid].bongo = current ? 0 : 1;
|
||
this.dirty.add(hole);
|
||
},
|
||
|
||
saveHole(hole) {
|
||
if (!this.dirty.has(hole)) return;
|
||
this.dirty.delete(hole);
|
||
const holeScores = this.scores[hole] ?? {};
|
||
const params = new URLSearchParams();
|
||
params.set('hole', hole);
|
||
|
||
for (const [uid, s] of Object.entries(holeScores)) {
|
||
if (s.bingo) params.set('bingo', uid);
|
||
if (s.bongo) params.set('bongo', uid);
|
||
if (s.strokes) params.append('strokes_' + uid, s.strokes);
|
||
}
|
||
const bangoWinners = Object.entries(holeScores).filter(([, s]) => s.bango > 0).map(([uid]) => uid);
|
||
for (const uid of bangoWinners) params.append('bango', uid);
|
||
|
||
fetch(`/games/${GAME_ID}/score`, { method: 'POST', body: params });
|
||
},
|
||
|
||
holeScoreClass(hole, uid) {
|
||
const s = this.getScore(hole, uid);
|
||
if (!s.strokes || this.isSkipped(hole)) return '';
|
||
const par = HOLE_PARS[hole];
|
||
if (!par) return '';
|
||
if (s.strokes === 1) return 'score-hio';
|
||
const diff = s.strokes - par;
|
||
if (diff <= -2) return 'score-eagle';
|
||
if (diff === -1) return 'score-birdie';
|
||
if (diff === 1) return 'score-bogey';
|
||
if (diff >= 2) return 'score-double-bogey';
|
||
return '';
|
||
},
|
||
|
||
holeStrokesStr(hole, uid) {
|
||
if (this.isSkipped(hole)) return '—';
|
||
const s = this.getScore(hole, uid);
|
||
return s.strokes ? s.strokes : '';
|
||
},
|
||
|
||
holeTotalStr(hole, uid) {
|
||
if (this.isSkipped(hole)) return '—';
|
||
const holeScores = this.scores[hole];
|
||
if (!holeScores || Object.keys(holeScores).length === 0) return '';
|
||
const s = this.getScore(hole, uid);
|
||
const t = s.bingo + s.bango + s.bongo;
|
||
return this.fmt(t);
|
||
},
|
||
|
||
toggleSkip(hole) {
|
||
if (this.skipped.has(hole)) {
|
||
this.skipped.delete(hole);
|
||
} else {
|
||
this.skipped.add(hole);
|
||
}
|
||
// Alpine reactivity workaround for Set
|
||
this.skipped = new Set(this.skipped);
|
||
},
|
||
|
||
isSkipped(hole) {
|
||
return this.skipped.has(hole);
|
||
},
|
||
|
||
allHolesDone() {
|
||
for (let h = 1; h <= GAME_HOLES; h++) {
|
||
if (this.isSkipped(h)) continue;
|
||
const holeScores = this.scores[h];
|
||
if (!holeScores || Object.keys(holeScores).length === 0) return false;
|
||
}
|
||
return true;
|
||
},
|
||
|
||
prevHole() { if (this.currentHole > 1) { this.saveHole(this.currentHole); this.currentHole--; } },
|
||
nextHole() { if (this.currentHole < GAME_HOLES) { this.saveHole(this.currentHole); this.currentHole++; } },
|
||
|
||
fmt(v) {
|
||
return Number.isInteger(v) || v % 1 === 0 ? v.toFixed(0) : v.toFixed(1);
|
||
},
|
||
|
||
dropPlayer(targetIdx) {
|
||
if (this.dragSrc === null || targetIdx === null || this.dragSrc === targetIdx) {
|
||
this.dragSrc = null; this.dragOver = null; return;
|
||
}
|
||
const moved = this.players.splice(this.dragSrc, 1)[0];
|
||
this.players.splice(targetIdx, 0, moved);
|
||
this.dragSrc = null; this.dragOver = null;
|
||
},
|
||
|
||
endDrag() { this.dragSrc = null; this.dragOver = null; },
|
||
|
||
touchDragMove(evt) {
|
||
const touch = evt.touches[0];
|
||
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
||
const card = el?.closest('.player-score-card');
|
||
if (!card) return;
|
||
const cards = [...document.getElementById('player-list').querySelectorAll('.player-score-card')];
|
||
const idx = cards.indexOf(card);
|
||
if (idx !== -1) this.dragOver = idx;
|
||
},
|
||
|
||
touchDragEnd() { this.dropPlayer(this.dragOver ?? this.dragSrc); },
|
||
|
||
calcSkins() {
|
||
const skins = {};
|
||
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] || {};
|
||
const scored = PLAYERS_DATA
|
||
.map(p => {
|
||
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;
|
||
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;
|
||
money[winners[0].id] += moneyPot;
|
||
holes[hole] = { winner: winners[0].id, pot, moneyPot };
|
||
pot = 0;
|
||
moneyPot = 0;
|
||
} else {
|
||
holes[hole] = { winner: null, pot, moneyPot };
|
||
if (!SKINS_CARRYOVER) { pot = 0; moneyPot = 0; }
|
||
}
|
||
}
|
||
return { skins, money, holes, unclaimed: pot, unclaimedMoney: moneyPot };
|
||
},
|
||
|
||
isSkinWinner(hole, uid) {
|
||
return this.calcSkins().holes[hole]?.winner === uid;
|
||
},
|
||
|
||
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;
|
||
},
|
||
|
||
touchStart(e) {
|
||
if (e.target.closest('.drag-handle')) return;
|
||
this.touchStartX = e.changedTouches[0].screenX;
|
||
},
|
||
touchEnd(e) {
|
||
if (!this.touchStartX) return;
|
||
const dx = e.changedTouches[0].screenX - this.touchStartX;
|
||
this.touchStartX = 0;
|
||
if (Math.abs(dx) < 60) return;
|
||
if (this.view === 'score') {
|
||
if (dx < 0) this.nextHole(); else this.prevHole();
|
||
} else {
|
||
const secondary = GAME_FORMAT === 'skins' ? 'skins' : 'points';
|
||
this.cardView = dx < 0 ? secondary : 'strokes';
|
||
}
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|