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
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user