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

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;