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
149 lines
7.7 KiB
JavaScript
149 lines
7.7 KiB
JavaScript
// Simulated tournament day: 12 teams, 3 pools of 4, 2 courts, one withdrawal mid-pool,
|
|
// then an 8-team single-elimination bracket with a 3rd-place match.
|
|
import { Tournament } from './engine/tournament.js';
|
|
import { CourtEngine } from './engine/courts.js';
|
|
import { resetIds } from './engine/formats.js';
|
|
|
|
const args = Object.fromEntries(process.argv.slice(2).map(a => a.replace(/^--/, '').split('=')));
|
|
const SEED = Number(args.seed ?? 7);
|
|
const BRACKET = args.bracket ?? 'single';
|
|
const VERBOSE = args.quiet === undefined;
|
|
|
|
// deterministic PRNG so a run is reproducible
|
|
let s = SEED >>> 0;
|
|
const rnd = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32);
|
|
const pick = arr => arr[Math.floor(rnd() * arr.length)];
|
|
|
|
// simulated clock: starts 9:00, advances as matches finish
|
|
let clock = new Date('2026-09-07T09:00:00-05:00').getTime();
|
|
const now = () => clock;
|
|
const hhmm = ms => new Date(ms).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: 'America/Chicago' });
|
|
|
|
resetIds();
|
|
const t = new Tournament({ name: 'HOA Labor Day 2s', rules: { teamSize: 2, pointsTo: 21, winBy: 2, cap: 25 }, courtCount: 2 });
|
|
const eng = new CourtEngine(t, { now, defaultMatchMinutes: 15, changeoverMinutes: 3 });
|
|
|
|
const names = ['Net Gains', 'Block Party', 'Sunburnt', 'Dig It', 'Kiss My Ace', 'Setting Ducks', 'The Spikers', 'Sandbaggers', 'Serves You Right', 'Bump Chumps', 'Ace Holes', 'Beach Please'];
|
|
// hidden "true strength" so results look plausible rather than uniformly random
|
|
const strength = new Map();
|
|
|
|
const printed = [];
|
|
const out = (...a) => { if (VERBOSE) console.log(...a); printed.push(a.join(' ')); };
|
|
|
|
t.on((e) => {
|
|
const at = hhmm(now());
|
|
switch (e.type) {
|
|
case 'team_registered': return;
|
|
case 'phase': return out(`${at} PHASE → ${e.phase}${e.champion ? ` 🏆 Champion: ${e.champion}` : ''}`);
|
|
case 'pools_generated': return out(`${at} Pools: ` + e.pools.map(p => `${p.id}[${p.teams.join(', ')}]`).join(' '));
|
|
case 'bracket_generated': return out(`${at} Bracket (${e.bracket}, ${e.size}): ` + e.teams.map((n, i) => `${i + 1}.${n}`).join(' '));
|
|
case 'match_started': return out(`${at} ▶ Court ${e.court}: ${e.a} vs ${e.b} (${e.match})`);
|
|
case 'result': { const m = t.match(e.match); return out(`${at} ✓ ${m.id} ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)} → ${t.teamName(e.winner)} ${e.score}`); }
|
|
case 'alert': return out(`${at} 📱 ${e.kind.toUpperCase().padEnd(9)} ${e.text}`);
|
|
case 'team_withdrawn': return out(`${at} ✗ WITHDRAWN: ${e.name} (${e.mode})`);
|
|
case 'court_paused': return out(`${at} ⏸ Court ${e.court} paused: ${e.reason}`);
|
|
case 'court_resumed': return out(`${at} ▶ Court ${e.court} resumed`);
|
|
default: return out(`${at} · ${e.type} ${JSON.stringify(e)}`);
|
|
}
|
|
});
|
|
|
|
// ---------- Phase 1: check-in ----------
|
|
for (const n of names) {
|
|
const team = t.registerTeam({ name: n, players: pick([2, 2, 2, 3]), captain: 'Captain', phone: '+1555' + String(Math.floor(rnd() * 1e7)).padStart(7, '0') });
|
|
strength.set(team.id, 0.35 + rnd() * 0.5);
|
|
}
|
|
out(`Registered ${t.teams.size} teams. Team codes e.g. ${[...t.teams.values()].slice(0, 3).map(x => `${x.name}=${x.code}`).join(', ')}`);
|
|
try { t.registerTeam({ name: 'net gains', players: 2 }); } catch (e) { out(` (rejected duplicate: ${e.message})`); }
|
|
try { t.registerTeam({ name: 'Solo', players: 1 }); } catch (e) { out(` (rejected: ${e.message})`); }
|
|
|
|
// ---------- Phase 2: closed, generate ----------
|
|
t.closeRegistration();
|
|
t.generatePools(4);
|
|
|
|
// ---------- Phase 3: live ----------
|
|
eng.start();
|
|
|
|
function playScore(m) {
|
|
// simulate a set to 21, win by 2, cap 25 with the stronger team favoured
|
|
const pa = strength.get(m.teamA), pb = strength.get(m.teamB);
|
|
let a = 0, b = 0;
|
|
const { pointsTo, winBy, cap } = t.rules;
|
|
while (true) {
|
|
if (rnd() < pa / (pa + pb)) a++; else b++;
|
|
const hi = Math.max(a, b), lo = Math.min(a, b);
|
|
if (hi === cap) break;
|
|
if (hi >= pointsTo && hi - lo >= winBy) break;
|
|
}
|
|
return [[a, b]];
|
|
}
|
|
|
|
let withdrawn = false;
|
|
let paused = false;
|
|
let step = 0;
|
|
while (t.phase === 'live' && step++ < 200) {
|
|
// pick the court whose match will finish first
|
|
const live = eng.courts.filter(c => c.matchId);
|
|
if (!live.length) {
|
|
// nothing running: maybe everything is waiting on a paused court
|
|
if (paused) { clock += 5 * 60_000; eng.resumeCourt(2); paused = false; continue; }
|
|
out('!! stalled: no live matches and nothing assignable'); break;
|
|
}
|
|
const durations = live.map(c => ({ c, end: c.startedAt + (11 + rnd() * 7) * 60_000 }));
|
|
durations.sort((x, y) => x.end - y.end);
|
|
const { c, end } = durations[0];
|
|
clock = Math.max(clock, end);
|
|
const m = t.match(c.matchId);
|
|
eng.recordResult(m.id, playScore(m));
|
|
|
|
const poolDone = t.matches.filter(x => x.stage === 'pool' && ['final','forfeit','void'].includes(x.status)).length;
|
|
const poolTotal = t.matches.filter(x => x.stage === 'pool').length;
|
|
|
|
// mid-pool: a team leaves (kid's soccer game)
|
|
if (!withdrawn && poolDone >= 7) {
|
|
withdrawn = true;
|
|
const victim = t.activeTeams().find(x => x.name === 'Sunburnt');
|
|
clock += 60_000;
|
|
eng.withdrawTeam(victim.id, { mode: 'forfeit' });
|
|
eng.broadcast('Lunch is out at the shelter. Court 2 keeps running.');
|
|
}
|
|
// rain delay on court 2 for a bit
|
|
if (!paused && poolDone === 12) { paused = true; eng.pauseCourt(2, 'net repair'); }
|
|
if (paused && poolDone === 14) { paused = false; eng.resumeCourt(2); }
|
|
|
|
// pool play over -> bracket
|
|
if (poolDone === poolTotal && !t.matches.some(x => x.stage !== 'pool')) {
|
|
out('');
|
|
for (const p of t.pools) {
|
|
out(` Pool ${p.id} standings`);
|
|
for (const r of t.standings(p.id)) out(` ${r.name.padEnd(18)} ${r.w}-${r.l} pts ${String(r.pf).padStart(3)}-${String(r.pa).padStart(3)} diff ${String(r.pointDiff).padStart(4)}${r.decidedBy ? ` (${r.decidedBy})` : ''}${r.status === 'withdrawn' ? ' WITHDRAWN' : ''}`);
|
|
}
|
|
const seeded = t.advanceFromPools({ perPool: 2, wildcards: 2 });
|
|
out('');
|
|
t.generateBracket(seeded, { type: BRACKET, thirdPlace: BRACKET === 'single' });
|
|
eng.tick();
|
|
const b = eng.board();
|
|
out(` Board: ` + b.courts.map(c => `C${c.court}: ${c.match ? `${c.match.a} vs ${c.match.b}` : c.status}`).join(' | ') + ` ‖ up next: ` + b.upNext.map(u => `${u.a} vs ${u.b} (C${u.court}, ~${u.etaMin}m)`).join('; '));
|
|
out('');
|
|
}
|
|
}
|
|
|
|
out('');
|
|
out(`Day finished at ${hhmm(now())}. ${t.matches.filter(m => m.status === 'final').length} matches played, ${t.matches.filter(m => m.status === 'forfeit').length} forfeits, ${eng.alerts.length} alerts sent, avg match ${eng.board().avgMatchMin} min.`);
|
|
const finals = t.matches.filter(m => m.stage !== 'pool').map(m => `${m.label ?? `${m.stage} R${m.round}`}: ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)} → ${m.winner ? t.teamName(m.winner) : m.status}`);
|
|
out(finals.join('\n'));
|
|
|
|
// ---------- invariants ----------
|
|
const problems = [];
|
|
for (const m of t.matches) {
|
|
if (m.status === 'final' && !m.winner) problems.push(`${m.id} final without winner`);
|
|
if (m.status === 'live') problems.push(`${m.id} still live at end`);
|
|
}
|
|
const teamsPlayingTwice = eng.courts.filter(c => c.matchId).length;
|
|
if (teamsPlayingTwice) problems.push('courts still occupied');
|
|
const sunburnt = [...t.teams.values()].find(x => x.name === 'Sunburnt');
|
|
if (t.matches.some(m => m.stage !== 'pool' && (m.teamA === sunburnt.id || m.teamB === sunburnt.id))) problems.push('withdrawn team reached bracket');
|
|
const upNowPerMatch = eng.alerts.filter(a => a.kind === 'up_now').map(a => a.match);
|
|
if (new Set(upNowPerMatch).size !== upNowPerMatch.length) problems.push('duplicate up_now alert');
|
|
console.log(problems.length ? `\nINVARIANT FAILURES:\n - ${problems.join('\n - ')}` : `\nAll invariants hold.`);
|
|
process.exitCode = problems.length ? 1 : 0;
|