Files
bkvargyasandClaude Fable 5.1 5ade592384 Courtside MVP: engine, web app, Docker + Cloudflare Tunnel deploy, docs
Tournament engine (pools, single/double elimination, standings with proper
tiebreaks, withdrawals, two-court queue with ETAs and alerts), a single-process
Node server with SQLite via node:sqlite and a WebSocket live board, organizer
desk, QR landing page that follows the tournament phase, Dockerfile and
compose with cloudflared, and documentation for deploying and running a day.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-03 20:31:46 +00:00

191 lines
7.5 KiB
JavaScript

// Bracket and pool generation. Pure functions: teams in, match objects out.
// A match is { id, stage, round, poolId, slot, teamA, teamB, feeds, feedsSide, status, sets, winner }
// teamA/teamB are team ids, or null when the slot is fed by another match.
let nextId = 1;
export function resetIds(n = 1) { nextId = n; }
const mid = () => `m${nextId++}`;
export function makeMatch(p) {
return {
id: mid(), stage: 'pool', round: 1, poolId: null, slot: null,
teamA: null, teamB: null, // team ids
feeds: null, feedsSide: null, // winner goes to match `feeds`, side 'A' | 'B'
loserFeeds: null, loserFeedsSide: null,
status: 'pending', // pending | on_deck | live | final | forfeit | void | bye
sets: [], winner: null, loser: null, court: null,
...p,
};
}
/** Split team ids into pools of ~poolSize using snake seeding (1,2,3 / 6,5,4 / 7,8,9 ...). */
export function makePools(teamIds, poolSize = 4) {
const n = teamIds.length;
const poolCount = Math.max(1, Math.round(n / poolSize));
const pools = Array.from({ length: poolCount }, (_, i) => ({ id: String.fromCharCode(65 + i), teams: [] }));
teamIds.forEach((t, i) => {
const lap = Math.floor(i / poolCount);
const idx = lap % 2 === 0 ? i % poolCount : poolCount - 1 - (i % poolCount);
pools[idx].teams.push(t);
});
return pools;
}
/** Round robin via the circle method. Returns matches with round numbers 1..(n-1). */
export function roundRobin(teamIds, poolId = null) {
const ids = [...teamIds];
if (ids.length % 2 === 1) ids.push(null); // bye marker
const n = ids.length, rounds = n - 1, half = n / 2;
const out = [];
for (let r = 0; r < rounds; r++) {
for (let i = 0; i < half; i++) {
const a = ids[i], b = ids[n - 1 - i];
if (a === null || b === null) continue;
// alternate home/away so the same team isn't always listed first
const [ta, tb] = (r + i) % 2 === 0 ? [a, b] : [b, a];
out.push(makeMatch({ stage: 'pool', round: r + 1, poolId, teamA: ta, teamB: tb }));
}
ids.splice(1, 0, ids.pop()); // rotate all but the first
}
return out;
}
/** Standard seeding order for a bracket of `size` (power of two): 1 v size, then 2 v size-1 etc. */
export function seedOrder(size) {
let order = [1];
while (order.length < size) {
const len = order.length * 2;
const next = [];
for (const s of order) next.push(s, len + 1 - s);
order = next;
}
return order;
}
/**
* Single elimination with byes. `seeded` is team ids in seed order (best first).
* Returns { matches, size }. Byes are resolved immediately (status 'bye', winner set) and
* propagated into the next round.
*/
export function singleElimination(seeded, { thirdPlace = false } = {}) {
const size = 1 << Math.ceil(Math.log2(Math.max(2, seeded.length)));
const rounds = Math.log2(size);
const order = seedOrder(size);
const byRound = [];
// Round 1
const r1 = [];
for (let i = 0; i < size / 2; i++) {
const a = seeded[order[2 * i] - 1] ?? null;
const b = seeded[order[2 * i + 1] - 1] ?? null;
r1.push(makeMatch({ stage: 'bracket', round: 1, slot: i + 1, teamA: a, teamB: b }));
}
byRound.push(r1);
for (let r = 2; r <= rounds; r++) {
const prev = byRound[r - 2];
const cur = [];
for (let i = 0; i < prev.length / 2; i++) {
const m = makeMatch({ stage: 'bracket', round: r, slot: i + 1 });
prev[2 * i].feeds = m.id; prev[2 * i].feedsSide = 'A';
prev[2 * i + 1].feeds = m.id; prev[2 * i + 1].feedsSide = 'B';
cur.push(m);
}
byRound.push(cur);
}
const matches = byRound.flat();
if (thirdPlace && rounds >= 2) {
const semis = byRound[rounds - 2];
const tp = makeMatch({ stage: 'bracket', round: rounds, slot: 2, label: '3rd place' });
semis[0].loserFeeds = tp.id; semis[0].loserFeedsSide = 'A';
semis[1].loserFeeds = tp.id; semis[1].loserFeedsSide = 'B';
matches.push(tp);
}
// Resolve byes
const index = Object.fromEntries(matches.map(m => [m.id, m]));
for (const m of r1) {
if (m.teamA && !m.teamB) settleBye(m, m.teamA, index);
else if (!m.teamA && m.teamB) settleBye(m, m.teamB, index);
}
return { matches, size, rounds };
}
function settleBye(m, winner, index) {
m.status = 'bye'; m.winner = winner;
const next = index[m.feeds];
if (next) next[m.feedsSide === 'A' ? 'teamA' : 'teamB'] = winner;
}
/**
* Double elimination for power-of-two sizes (byes are padded like single elim).
* Winners bracket + losers bracket + grand final (+ optional reset match if the LB team wins).
*/
export function doubleElimination(seeded, { resetMatch = true } = {}) {
const wb = singleElimination(seeded);
const wbRounds = wb.rounds;
const wbByRound = Array.from({ length: wbRounds }, (_, r) => wb.matches.filter(m => m.round === r + 1 && m.label !== '3rd place'));
const lb = []; // array of rounds, each an array of matches
let lbRound = 0;
// LB round 1: losers of WB R1 pair up
let carry = [];
for (let r = 0; r < wbRounds; r++) {
const losers = wbByRound[r]; // matches whose losers drop here
if (r === 0) {
lbRound++;
const cur = [];
for (let i = 0; i < losers.length / 2; i++) {
const m = makeMatch({ stage: 'losers', round: lbRound, slot: i + 1 });
losers[2 * i].loserFeeds = m.id; losers[2 * i].loserFeedsSide = 'A';
losers[2 * i + 1].loserFeeds = m.id; losers[2 * i + 1].loserFeedsSide = 'B';
cur.push(m);
}
lb.push(cur); carry = cur;
} else {
// "drop" round: WB losers of round r vs carry winners (reverse order to delay rematches)
lbRound++;
const cur = [];
const rev = [...losers].reverse();
for (let i = 0; i < carry.length; i++) {
const m = makeMatch({ stage: 'losers', round: lbRound, slot: i + 1 });
rev[i].loserFeeds = m.id; rev[i].loserFeedsSide = 'A';
carry[i].feeds = m.id; carry[i].feedsSide = 'B';
cur.push(m);
}
lb.push(cur); carry = cur;
if (carry.length > 1) {
// "consolidation" round: carry winners pair up
lbRound++;
const nxt = [];
for (let i = 0; i < carry.length / 2; i++) {
const m = makeMatch({ stage: 'losers', round: lbRound, slot: i + 1 });
carry[2 * i].feeds = m.id; carry[2 * i].feedsSide = 'A';
carry[2 * i + 1].feeds = m.id; carry[2 * i + 1].feedsSide = 'B';
nxt.push(m);
}
lb.push(nxt); carry = nxt;
}
}
}
const wbFinal = wbByRound[wbRounds - 1][0];
const lbFinal = carry[0];
const gf = makeMatch({ stage: 'final', round: 1, slot: 1, label: 'Grand final' });
wbFinal.feeds = gf.id; wbFinal.feedsSide = 'A';
lbFinal.feeds = gf.id; lbFinal.feedsSide = 'B';
const matches = [...wb.matches, ...lb.flat(), gf];
if (resetMatch) {
const reset = makeMatch({ stage: 'final', round: 2, slot: 1, label: 'Bracket reset', conditional: true });
gf.resetMatch = reset.id;
matches.push(reset);
}
// Byes in WB R1 mean LB R1 gets a null loser -> treat as bye there too.
const index = Object.fromEntries(matches.map(m => [m.id, m]));
for (const m of wbByRound[0]) if (m.status === 'bye') {
const l = index[m.loserFeeds];
l[m.loserFeedsSide === 'A' ? 'teamA' : 'teamB'] = '__bye__';
}
for (const m of lb[0]) {
if (m.teamA === '__bye__' && m.teamB === '__bye__') { m.status = 'void'; }
else if (m.teamA === '__bye__') { m.teamA = null; m.pendingBye = 'A'; }
else if (m.teamB === '__bye__') { m.teamB = null; m.pendingBye = 'B'; }
}
return { matches, size: wb.size, wbRounds, lbRounds: lbRound };
}