/* ============================================================
Weekend Games library — formats, rules, and scoring.
Shared by the manager Tour Setup and the member Weekend Games tab.
============================================================ */
const GAME_LIBRARY = [
{
id: "skins",
name: "Skins",
best: "Perfect for any group size",
blurb: "Each hole is worth a skin (points or money). Low unique score wins it; ties carry over.",
rules: [
"Each hole is worth a set value — a number of points or a dollar amount (a “skin”).",
"The player with the lowest score on a hole wins that hole’s skin outright.",
"If two or more players tie for low, no one wins the skin — it “carries over” and is added to the next hole’s value (when carryover is on).",
"Can be played gross or net (handicap strokes applied per hole by stroke index).",
],
scoring: "Add up the skins (and any carried-over value) each player wins across 18 holes.",
},
{
id: "wolf",
name: "Wolf",
best: "Best for foursomes",
blurb: "A rotating “Wolf” picks a partner — or goes Lone Wolf — on every tee.",
rules: [
"Players rotate as the “Wolf” on each tee, usually in a set order for all 18 holes.",
"After each player hits their drive, the Wolf may choose that player as a partner for the hole.",
"Once the Wolf passes on a player, they can’t go back to them.",
"If the Wolf likes no one, they play “Lone Wolf” — 1 against 3 — for bigger points.",
"Points are awarded based on which side wins the hole.",
],
scoring: "Tally points each hole; Lone Wolf wins/losses are worth more. Most points after 18 wins.",
},
{
id: "stableford",
name: "Stableford",
best: "Great for mixed skill levels",
blurb: "Earn points per hole based on your score relative to par — high total wins.",
rules: [
"Instead of total strokes, you earn points on each hole based on your score vs. par.",
"A common scale: Bogey = 1, Par = 2, Birdie = 3, Eagle = 4 (Double bogey or worse = 0).",
"Net Stableford applies handicap strokes, making it friendly for mixed abilities.",
"A bad hole costs you at most a zero — it never wrecks your whole round.",
],
scoring: "Highest cumulative point total after 18 holes wins.",
},
{
id: "bbb",
name: "Bingo Bango Bongo",
best: "Fast-paced & fun",
blurb: "Three points per hole: first on, closest once all on, first in the cup.",
rules: [
"Three points are available on every hole.",
"Bingo: first player to get their ball on the green.",
"Bango: closest to the pin once every ball is on the green.",
"Bongo: first player to hole out.",
"Honors-based and order-of-play matters, so it rewards course management over raw distance.",
],
scoring: "One point each for Bingo, Bango, Bongo. Most total points after 18 wins.",
},
{
id: "split-sixes",
name: "Split Sixes",
best: "Best for multi-foursome groups",
blurb: "Also called Hollywood — six points per hole split by finish order.",
rules: [
"Each hole is worth 6 points, divided among the group by individual score.",
"Best score takes 4, second-best takes 2, third takes 0.",
"Ties split the points evenly (e.g. a tie for first splits 4+2 as 3–3).",
"A three-way tie splits all six points 2–2–2.",
],
scoring: "Add each player’s points across 18 holes; highest total wins.",
},
{
id: "four-ball",
name: "Four-Ball",
best: "Better-ball team match play",
blurb: "Two-player teams; the lower partner’s score counts on each hole.",
rules: [
"Two two-player teams compete head-to-head.",
"Each golfer plays their own ball the whole hole.",
"On each hole, the lower score between the two partners is the team’s score.",
"Matches are tracked hole-by-hole (e.g. “2 up with 3 to play”).",
],
scoring: "Win, lose, or halve each hole. The team ahead when holes run out wins the match.",
},
{
id: "scramble",
name: "Scramble",
best: "Great for a mix of beginners & veterans",
blurb: "Team picks the best shot each time and everyone plays from there.",
rules: [
"Every player on the team tees off.",
"The team selects the best of those shots and all players play their next shot from that spot.",
"Repeat until the ball is holed.",
"Played as a 2-person (Two-Man Scramble) or 4-person team — speeds up play and hides bad shots.",
],
scoring: "One team score per hole. Lowest team total over 18 wins.",
},
{
id: "nassau",
name: "Nassau",
best: "The classic betting format",
blurb: "Three separate matches in one round: front 9, back 9, and overall 18.",
rules: [
"Your 18-hole round is split into three contests: the front nine, the back nine, and the total 18.",
"Each segment is its own match (or its own wager).",
"A poor start on the front nine doesn’t sink your back-nine or overall result.",
"Often played alongside another format (e.g. match play or skins) for the scoring.",
],
scoring: "Settle three results — front, back, and overall — separately.",
},
{
id: "six-six-six",
name: "6-6-6 (3-3-3)",
best: "Keeps a foursome engaged",
blurb: "Change the format every few holes to keep things fresh.",
rules: [
"The round is broken into segments of holes that each use a different format.",
"Example (3-3-3): Scramble for holes 1–3, Alternate Shot for 4–6, Best Ball for 7–9.",
"Repeat the rotation across each nine (6-6-6 uses six-hole segments).",
"Teams of two are typical, but it adapts to the group.",
],
scoring: "Score each segment by its own format, then total the segments.",
},
{
id: "captain",
name: "Captain / The Ace",
best: "Rotating one-vs-group",
blurb: "One player is the “Captain” for a set of holes against the rest of the group.",
rules: [
"One player is designated the “Captain” (or “Ace”) for a stretch of holes — typically 3 or 6.",
"The Captain competes against the rest of the group on those holes.",
"The Captain’s score is pitted against the others’ best ball (or combined score).",
"The role rotates so everyone takes a turn as Captain through the round.",
],
scoring: "Award points per hole to the Captain or the field; rotate and total at the end.",
},
];
function gameById(id) { return GAME_LIBRARY.find((g) => g.id === id) || GAME_LIBRARY[0]; }
/* ============================================================
Daily ADD-ON games — side contests layered on top of the main
game, configured per day in Tour Setup. Closest-to-the-Pin runs
on every par 3; Longest Drive runs on one designated hole.
============================================================ */
const SIDE_GAMES = [
{
id: "ctp", name: "Closest to the Pin", short: "CTP", icon: "pin", scope: "Every par 3",
blurb: "On each par 3, the tee shot that finishes nearest the hole wins. The group marks a winner on every par 3.",
},
{
id: "longDrive", name: "Longest Drive", short: "Long drive", icon: "flag", scope: "One designated hole",
blurb: "On the designated hole, the longest drive in the fairway wins. The group marks one winner for the round.",
},
];
function sideGameById(id) { return SIDE_GAMES.find((s) => s.id === id) || null; }
// Default add-on config — Closest-to-the-Pin on by default both days, on every par 3.
function defaultSideGames() {
const p3 = parThreeHoles();
return {
sat: { ctp: true, longDrive: false, ctpHoles: p3.slice(), longDriveHole: 7 },
sun: { ctp: true, longDrive: false, ctpHoles: p3.slice(), longDriveHole: 7 },
};
}
// Resolve the add-ons enabled for a given day ("sat" | "sun").
function sideGamesForDay(gameConfig, dayKey) {
const sg = (gameConfig && gameConfig.sideGames) || defaultSideGames();
return sg[dayKey] || {};
}
// Which add-on ids are switched on for that day.
function activeSideGames(gameConfig, dayKey) {
const day = sideGamesForDay(gameConfig, dayKey);
return SIDE_GAMES.filter((s) => day[s.id]).map((s) => s.id);
}
// Par-3 hole numbers from the live scorecard.
function parThreeHoles() {
const Y = window.YCC;
return (Y && Y.HOLES ? Y.HOLES : []).filter((h) => h.par === 3).map((h) => h.n);
}
// Closest-to-the-Pin holes selected for a day (defaults to every par 3).
function ctpHolesForDay(gameConfig, dayKey) {
const day = sideGamesForDay(gameConfig, dayKey);
if (Array.isArray(day.ctpHoles)) return day.ctpHoles;
return parThreeHoles();
}
// Longest-Drive hole for a day (per-day; falls back to legacy shared value, then #7).
function longDriveHoleForDay(gameConfig, dayKey) {
const day = sideGamesForDay(gameConfig, dayKey);
if (day.longDriveHole) return day.longDriveHole;
const sg = (gameConfig && gameConfig.sideGames) || {};
return sg.longDriveHole || 7;
}
/* -------- Rules modal: opened from a game tile -------- */
function GameRulesModal({ game, onClose }) {
if (!game) return null;
return (
{game.blurb}
How it's played
{game.rules.map((r, i) => (
{i + 1}{r}
))}
Scoring
{game.scoring}
);
}
/* -------- A single game tile (click to open rules) -------- */
function GameTile({ game, active, onOpen }) {
return (
);
}
/* -------- Grid of all game tiles + the rules modal -------- */
function GameRulesGrid({ activeId, onPick }) {
const [open, setOpen] = useState(null);
return (
<>
{GAME_LIBRARY.map((g) => (
setOpen(g)} />
))}
{open && (
setOpen(null)}
/>
)}
>
);
}
window.YCC_GAMES = { GAME_LIBRARY, gameById, GameRulesModal, GameTile, GameRulesGrid, SIDE_GAMES, sideGameById, defaultSideGames, sideGamesForDay, activeSideGames, parThreeHoles, ctpHolesForDay, longDriveHoleForDay };
/* ============================================================
Scoring engine — turns 18 gross scores into the SELECTED
game's standings + winnings. Stroke-based games are computed
live; games that need extra inputs (Wolf, Bingo-Bango-Bongo,
etc.) fall back to a net-stroke leaderboard the group settles.
============================================================ */
function whichGame(gameConfig, dayISO, satISO) {
if (!gameConfig) return "skins";
if (gameConfig.splitDays) return dayISO === satISO ? (gameConfig.gameSat || "skins") : (gameConfig.gameSun || "skins");
return gameConfig.gameType || "skins";
}
// c = { players, scores (matrix w/ null=blank), holes, cHcps, siOf(pi,hi), stake, scoring, carryover }
function computeGame(gameId, c) {
const Y = window.YCC;
const P = c.players.length;
const isNum = (v) => typeof v === "number" && isFinite(v);
const stroke = (pi, hi) => Y.strokesOnHole(c.cHcps[pi] == null ? 0 : c.cHcps[pi], c.siOf(pi, hi));
const gross = (pi, hi) => c.scores[pi][hi];
const net = (pi, hi) => gross(pi, hi) - stroke(pi, hi);
const holeLive = (hi) => P > 0 && c.players.every((_, pi) => isNum(gross(pi, hi)));
const liveHoles = c.holes.map((h, hi) => hi).filter(holeLive);
if (gameId === "skins") {
const useNet = c.scoring === "net";
const val = c.stake, cells = c.holes.map(() => "");
const winnerByHole = c.holes.map(() => null);
const skins = c.players.map(() => 0), money = c.players.map(() => 0);
let pot = 0;
c.holes.forEach((h, hi) => {
if (!holeLive(hi)) { cells[hi] = ""; return; }
const vals = c.players.map((_, pi) => (useNet ? net(pi, hi) : gross(pi, hi)));
const min = Math.min(...vals);
const winners = vals.map((v, i) => (v === min ? i : -1)).filter((i) => i >= 0);
if (winners.length === 1) {
const amt = val + (c.carryover ? pot : 0);
skins[winners[0]] += 1 + (c.carryover ? pot / val : 0);
money[winners[0]] += amt; pot = 0; cells[hi] = "$" + amt; winnerByHole[hi] = winners[0];
} else if (c.carryover) { pot += val; cells[hi] = "↻"; } else cells[hi] = "—";
});
return { computable: true, stakeLabel: "Skin value", perHole: { label: "Skin", cells }, winnerByHole,
standings: c.players.map((p, idx) => ({ idx, lines: [{ label: "skins", value: Math.round(skins[idx]) }], money: money[idx] })),
summary: `${liveHoles.length} of 18 holes scored · ${useNet ? "net" : "gross"}${pot > 0 ? ` · $${pot} carrying` : ""}` };
}
if (gameId === "stableford") {
const pts = c.players.map(() => 0);
liveHoles.forEach((hi) => c.players.forEach((_, pi) => {
const d = net(pi, hi) - c.holes[hi].par;
pts[pi] += d <= -3 ? 5 : d === -2 ? 4 : d === -1 ? 3 : d === 0 ? 2 : d === 1 ? 1 : 0;
}));
const maxP = liveHoles.length ? Math.max(...pts) : 0;
const winners = pts.map((p, i) => (p === maxP && liveHoles.length ? i : -1)).filter((i) => i >= 0);
const pot = c.stake * P;
const money = c.players.map((_, i) => (winners.includes(i) ? Math.round(pot / winners.length) : 0));
return { computable: true, stakeLabel: "Ante (each)", perHole: null,
standings: c.players.map((p, idx) => ({ idx, lines: [{ label: "points", value: pts[idx] }], money: money[idx] })),
summary: `Net Stableford · ${liveHoles.length} of 18 holes · high points wins the $${pot} pot` };
}
if (gameId === "split-sixes") {
const W = { 2: [4, 2], 3: [4, 2, 0], 4: [3, 2, 1, 0] }[P] || [4, 2, 0];
const pts = c.players.map(() => 0);
liveHoles.forEach((hi) => {
const order = c.players.map((_, pi) => ({ pi, v: net(pi, hi) })).sort((a, b) => a.v - b.v);
let i = 0;
while (i < order.length) {
let j = i; while (j + 1 < order.length && order[j + 1].v === order[i].v) j++;
let sum = 0; for (let k = i; k <= j; k++) sum += (W[k] || 0);
const share = sum / (j - i + 1);
for (let k = i; k <= j; k++) pts[order[k].pi] += share;
i = j + 1;
}
});
const money = c.players.map((_, i) => Math.round(pts[i] * c.stake));
return { computable: true, stakeLabel: "Point value", perHole: null,
standings: c.players.map((p, idx) => ({ idx, lines: [{ label: "points", value: Math.round(pts[idx]) }], money: money[idx] })),
summary: `Split Sixes · ${liveHoles.length} of 18 holes · 6 points a hole` };
}
if (gameId === "nassau") {
const segs = [["Front", 0, 9], ["Back", 9, 18], ["Total", 0, 18]];
const money = c.players.map(() => 0);
const seg = c.players.map(() => []);
segs.forEach(([label, a, b]) => {
const live = liveHoles.filter((hi) => hi >= a && hi < b);
if (!live.length) { c.players.forEach((_, pi) => seg[pi].push("—")); return; }
const tot = c.players.map((_, pi) => live.reduce((s, hi) => s + net(pi, hi), 0));
const min = Math.min(...tot);
const winners = tot.map((v, i) => (v === min ? i : -1)).filter((i) => i >= 0);
c.players.forEach((_, pi) => seg[pi].push(String(tot[pi])));
if (winners.length === 1) money[winners[0]] += c.stake;
});
return { computable: true, stakeLabel: "Bet / match", perHole: null,
standings: c.players.map((p, idx) => ({ idx, lines: [{ label: "F / B / T net", value: seg[idx].join(" / ") }], money: money[idx] })),
summary: `Nassau · front, back & overall (net) · $${c.stake} a match` };
}
// Fallback — track gross, settle by the game's own rules.
const netTot = c.players.map((_, pi) => liveHoles.reduce((s, hi) => s + net(pi, hi), 0));
return { computable: false, stakeLabel: "Stake", perHole: null,
standings: c.players.map((p, idx) => ({ idx, lines: [{ label: "net thru " + liveHoles.length, value: netTot[idx] || 0 }], money: 0 })),
summary: "Enter gross scores as you play — this game is settled by your group using its own rules." };
}
window.YCC_SCORING = { computeGame, whichGame };
// Tally each member's WINS for a weekend (leader of a group's game, per day).
// Only auto-computable games count a win; ties award a win to each leader.
function weekendWins({ tour, scores, gameConfig }) {
const Y = window.YCC;
const tee = (gameConfig && gameConfig.groupTee) || "Blue";
const winsByName = {};
const results = [];
[["Saturday", tour.satISO], ["Sunday", tour.sunISO]].forEach(([dayLabel, dayISO]) => {
const gid = whichGame(gameConfig, dayISO, tour.satISO);
Y.sortByTime(tour.slots[dayISO] || []).forEach((slot) => {
if (!slot.players || !slot.players.length) return;
const gs = (scores && scores[dayISO + "|" + slot.id]) || {};
const matrix = slot.players.map((p) => Y.HOLES.map((h, i) => { const a = gs[p.name]; const v = a && a[i]; return (typeof v === "number" && isFinite(v)) ? v : null; }));
if (!matrix.some((r) => r.some((v) => v != null))) return;
const cHcps = slot.players.map((p) => { const n = typeof p.hi === "number" ? p.hi : parseFloat(p.hi); return isFinite(n) ? Y.courseHandicapForTee(n, tee) : 0; });
const eng = computeGame(gid, { players: slot.players, scores: matrix, holes: Y.HOLES, cHcps, siOf: (pi, hi) => Y.siForTee(tee, hi), stake: (gameConfig && gameConfig.skinValue) || 1, scoring: (gameConfig && gameConfig.scoring) || "gross", carryover: gameConfig && gameConfig.carryover });
if (!eng.computable) return;
const maxMoney = Math.max(...eng.standings.map((s) => s.money));
if (maxMoney <= 0) return;
const winners = eng.standings.filter((s) => s.money === maxMoney).map((s) => slot.players[s.idx].name);
winners.forEach((n) => { winsByName[n] = (winsByName[n] || 0) + 1; });
results.push({ dayLabel, time: slot.time, game: G_NAME(gid), winners });
});
});
return { winsByName, results };
}
function G_NAME(id) { const g = gameById(id); return g ? g.name : id; }
window.YCC_SCORING.weekendWins = weekendWins;
// Per-member scorecard stats for ONE weekend, derived from the live data:
// score distribution (eagles…bogey+), rounds, strokes/to-par, CTP & longest-drive
// wins, and game wins. Returns { [name]: statObj }. Used by the Season tab and
// stored on the season doc when a weekend is posted (so the season accumulates).
function weekendStats({ tour, scores, sideResults, gameConfig }) {
const Y = window.YCC;
const tee = (gameConfig && gameConfig.groupTee) || "Blue";
const out = {};
const blank = () => ({ eagles: 0, birdies: 0, pars: 0, bogeys: 0, doubles: 0, bogeyPlus: 0, holes: 0, strokes: 0, toPar: 0, rounds: 0, ctp: 0, longDrive: 0, wins: 0 });
const ensure = (n) => (out[n] || (out[n] = blank()));
[tour.satISO, tour.sunISO].forEach((dayISO) => {
const gid = whichGame(gameConfig, dayISO, tour.satISO);
Y.sortByTime(tour.slots[dayISO] || []).forEach((slot) => {
if (!slot.players || !slot.players.length) return;
const key = dayISO + "|" + slot.id;
const gs = (scores && scores[key]) || {};
const sr = (sideResults && sideResults[key]) || {};
// hole-by-hole scoring distribution
slot.players.forEach((p) => {
const arr = gs[p.name];
if (!arr) return;
let any = false;
arr.forEach((v, i) => {
if (typeof v !== "number" || !isFinite(v)) return;
any = true;
const d = v - Y.HOLES[i].par;
const a = ensure(p.name);
a.holes += 1; a.strokes += v; a.toPar += d;
if (d <= -2) a.eagles += 1;
else if (d === -1) a.birdies += 1;
else if (d === 0) a.pars += 1;
else if (d === 1) a.bogeys += 1;
else if (d === 2) a.doubles += 1;
else a.bogeyPlus += 1;
});
if (any) ensure(p.name).rounds += 1;
});
// closest to the pin (per par 3) + longest drive
Object.values(sr.ctp || {}).forEach((n) => { if (n) ensure(n).ctp += 1; });
if (sr.longDrive) ensure(sr.longDrive).longDrive += 1;
// game win (group leader), only for auto-computable games
const matrix = slot.players.map((p) => Y.HOLES.map((h, i) => { const a = gs[p.name]; const v = a && a[i]; return (typeof v === "number" && isFinite(v)) ? v : null; }));
if (matrix.some((r) => r.some((v) => v != null))) {
const cHcps = slot.players.map((p) => { const n = typeof p.hi === "number" ? p.hi : parseFloat(p.hi); return isFinite(n) ? Y.courseHandicapForTee(n, tee) : 0; });
const eng = computeGame(gid, { players: slot.players, scores: matrix, holes: Y.HOLES, cHcps, siOf: (pi, hi) => Y.siForTee(tee, hi), stake: (gameConfig && gameConfig.skinValue) || 1, scoring: (gameConfig && gameConfig.scoring) || "gross", carryover: gameConfig && gameConfig.carryover });
if (eng.computable) {
const maxMoney = Math.max(...eng.standings.map((s) => s.money));
if (maxMoney > 0) eng.standings.filter((s) => s.money === maxMoney).forEach((s) => { ensure(slot.players[s.idx].name).wins += 1; });
}
}
});
});
return out;
}
window.YCC_SCORING.weekendStats = weekendStats;