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:
+148
@@ -0,0 +1,148 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user