From a4bee22d969bb15b8b2f5bf0ed0d88b7a3012539 Mon Sep 17 00:00:00 2001 From: Brian Vargyas Date: Sat, 5 Sep 2026 15:33:36 +0000 Subject: [PATCH] Fix double elimination with byes never finishing; hand-built brackets Byes now cascade through the whole bracket (a slot nobody can fill is a bye or a void), re-run on server start so stuck tournaments recover. New manual bracket builder on the desk: pick round-1 pairings per match. Tests at 5-23 teams for both bracket types. Release 0.5.0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi --- CHANGELOG.md | 17 ++++++++ docs/API.md | 2 +- docs/KNOWN-GAPS.md | 1 - docs/ORGANIZER-GUIDE.md | 1 + engine/formats.js | 13 +----- engine/tournament.js | 90 ++++++++++++++++++++++++++++++++++++----- package.json | 2 +- server/public/admin.js | 11 +++++ server/public/app.css | 3 ++ server/routes.js | 6 +++ server/state.js | 8 ++++ server/views/admin.js | 21 ++++++++++ test/engine.test.js | 64 +++++++++++++++++++++++++++++ 13 files changed, 214 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45afd9c..97ec86b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ so you can always tell what a deployment is actually serving. ## [Unreleased] +## [0.5.0] - 2026-09-03 + +### Fixed +- **Double elimination with byes could never finish.** With many byes (e.g. 18 teams in a + 32 bracket) losers-bracket slots fed by matches that never happen were left waiting forever; + the tournament stayed live with both courts open. Byes now cascade through the whole bracket: + a slot nobody can ever fill is a bye for the team on the other side, or a void match if both + sides are empty. This is re-run when the server starts, so a stuck tournament unsticks itself + on the first deploy of this version and goes Final if everything else is played. +- Tested at 5, 6, 9, 12, 17, 18 and 23 teams, single and double elimination. + +### Added +- **Build the bracket by hand.** On the desk, "Build the bracket by hand" lists every round-1 + match with a team picker on each side, pre-filled with standard seeding. Pick who plays whom, + leave a side as "bye" for a walkover, choose single or double elimination, and build. Teams + picked twice are flagged before you submit and rejected by the server. + ## [0.4.2] - 2026-09-03 ### Fixed diff --git a/docs/API.md b/docs/API.md index 9dff612..af3c7f5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -78,7 +78,7 @@ All organizer routes require the session cookie set by `/admin/login` (or Cloudf | POST | `/admin/t//phase` | `phase` = `checkin` / `closed` / `live` / `final` | Change phase. `live` requires generated play and starts court assignment. | | POST | `/admin/t//banner` | `banner` | Set (or clear with empty) the persistent banner. | | POST | `/admin/t//broadcast` | `text` | One-time alert to all team pages. | -| POST | `/admin/t//generate` | `what=pools`, `poolSize` — or `what=bracket`, `perPool`, `wildcards`, `type` (`single`/`double`), `thirdPlace` | Generate pools or bracket. | +| POST | `/admin/t//generate` | `what=pools`, `poolSize` — or `what=bracket`, `perPool`, `wildcards`, `type` (`single`/`double`), `thirdPlace` — or `what=manual`, `type`, `thirdPlace`, `a1`,`b1`,`a2`,`b2`,… (team ids per round-1 match; empty = bye) | Generate pools or bracket, automatically or from hand-picked pairs. | | POST | `/admin/t//regenerate` | | Clear all generated play (only before any score). | | POST | `/admin/t//score` | `match`, `sets` (`"21-18, 19-21, 15-9"`) | Record or correct a result. Validated against the rules. | | POST | `/admin/t//live` | `match`, `a`, `b` | Update the running score of a live match (JSON, used by the score pad). | diff --git a/docs/KNOWN-GAPS.md b/docs/KNOWN-GAPS.md index 7bff7fc..4d8f7cc 100644 --- a/docs/KNOWN-GAPS.md +++ b/docs/KNOWN-GAPS.md @@ -4,7 +4,6 @@ Honest list of what the MVP does not do well yet. None of these block running a - **No real-event mileage yet.** The engine has been exercised by `simulate.js` (dozens of seeds, single and double elimination) and the HTTP smoke test, and the pages have been eyeballed on phone and desktop sizes. It has not run an actual tournament. - **Bracket corrections don't re-propagate.** Correcting a bracket match after its winner has already played the next round does not rewind the later match; you must correct that one too. Pool corrections are fine (standings are recomputed). -- **Double elimination with byes** is tested at 6 teams (8-bracket). Very uneven fields (9–10 teams in a 16-bracket) get the same bye logic but have had less scrutiny. - **Queue priority is round-first.** A round-2 match between two rested teams waits behind a round-1 match whose teams just came off court. That keeps pools fair (everyone plays round N before anyone plays round N+1) but can leave a court briefly idle. The engine is a single function (`_eligible`) if you want a different policy. - **ETAs are estimates.** They come from the rolling average of the last six matches and assume a fixed changeover. Early in the day (fewer than one finished match) the default is 15 minutes. - **Alerts are on-page only.** A team must have their team page open (or the notification permission granted) to be alerted. SMS and Web Push are the top roadmap items. diff --git a/docs/ORGANIZER-GUIDE.md b/docs/ORGANIZER-GUIDE.md index dc935e9..8e54fe5 100644 --- a/docs/ORGANIZER-GUIDE.md +++ b/docs/ORGANIZER-GUIDE.md @@ -20,6 +20,7 @@ From the desk you can rename a team, fix the captain or player count, remove a t 1. **Close registration.** The public page switches to the roster and "schedule coming." 2. **Generate pools** (pick the pool size) or, for bracket-only, generate the bracket. You can clear and regenerate freely until the first score is entered. A late team? Clear generated play, add the team, regenerate. + To decide the first-round matchups yourself, open **Build the bracket by hand**: one row per round-1 match with a team picker on each side, pre-filled with standard seeding. Leave a side as "bye" to give the other team a walkover. Every team must appear exactly once; winners of matches 1 and 2 meet next, 3 and 4 meet, and so on down the sheet. 3. **Go live.** The engine immediately assigns the first matches to the courts. Teams whose match is next see "on deck for Court 2, ~15 min" on their team page and the public board. ## Scoring diff --git a/engine/formats.js b/engine/formats.js index 8bcac41..029cd05 100644 --- a/engine/formats.js +++ b/engine/formats.js @@ -175,16 +175,7 @@ export function doubleElimination(seeded, { resetMatch = 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'; } - } + // Byes cascade through the losers bracket (a bye has no loser; a void match has no winner); + // Tournament.settleByes() resolves them after generation and after every result. return { matches, size: wb.size, wbRounds, lbRounds: lbRound }; } diff --git a/engine/tournament.js b/engine/tournament.js index 928708b..c4ec030 100644 --- a/engine/tournament.js +++ b/engine/tournament.js @@ -1,5 +1,5 @@ // Tournament state: teams, matches, results, standings, withdrawals, bracket propagation. -import { makePools, roundRobin, singleElimination, doubleElimination, makeMatch } from './formats.js'; +import { makePools, roundRobin, singleElimination, doubleElimination, makeMatch, seedOrder } from './formats.js'; export const DEFAULT_RULES = { teamSize: 2, @@ -104,15 +104,47 @@ export class Tournament { return [...qualified, ...rest.slice(0, wildcards)].map(r => r.teamId); } - generateBracket(seededIds, { type = 'single', thirdPlace = false } = {}) { + generateBracket(seededIds, { type = 'single', thirdPlace = false, manual = false } = {}) { if (this.matches.some(m => m.stage !== 'pool' && m.status === 'final')) throw new Error('Bracket already has results'); this.matches = this.matches.filter(m => m.stage === 'pool'); const res = type === 'double' ? doubleElimination(seededIds) : singleElimination(seededIds, { thirdPlace }); this.matches.push(...res.matches); - this.emit({ type: 'bracket_generated', bracket: type, size: res.size, teams: seededIds.map(id => this.teams.get(id).name) }); + this.settleByes(); + this.emit({ type: 'bracket_generated', bracket: type, size: res.size, manual, teams: seededIds.filter(Boolean).map(id => this.teams.get(id)?.name ?? id) }); return res; } + /** + * Build a bracket from hand-picked first-round pairs: pairs = [[teamIdA|null, teamIdB|null], ...] + * in the order the matches appear top to bottom. null = bye. Every active team must appear once. + */ + generateBracketFromPairs(pairs, opts = {}) { + const size = pairs.length * 2; + if (size < 2 || (size & (size - 1)) !== 0) throw new Error(`Need a power-of-two number of slots (got ${size})`); + const used = new Set(); + for (const [a, b] of pairs) for (const id of [a, b]) { + if (!id) continue; + if (!this.teams.get(id) || this.teams.get(id).status !== 'registered') throw new Error(`Unknown or withdrawn team in bracket: ${id}`); + if (used.has(id)) throw new Error(`${this.teamName(id)} is placed twice`); + used.add(id); + } + const missing = this.activeTeams().filter(t => !used.has(t.id)); + if (missing.length) throw new Error(`Not placed: ${missing.map(t => t.name).join(', ')}`); + if (pairs.some(([a, b]) => !a && !b)) throw new Error('A match needs at least one team; leave at most one side empty for a bye'); + // singleElimination lays out by seed order; invert that so pair i lands in round-1 match i + const order = seedOrder(size); + const seeded = new Array(size).fill(null); + pairs.forEach(([a, b], i) => { seeded[order[2 * i] - 1] = a ?? null; seeded[order[2 * i + 1] - 1] = b ?? null; }); + return this.generateBracket(seeded, { ...opts, manual: true }); + } + + /** Default first-round pairs (standard seeding) for `seededIds`, for pre-filling the manual builder. */ + defaultPairs(seededIds) { + const size = 1 << Math.ceil(Math.log2(Math.max(2, seededIds.length))); + const order = seedOrder(size); + return Array.from({ length: size / 2 }, (_, i) => [seededIds[order[2 * i] - 1] ?? null, seededIds[order[2 * i + 1] - 1] ?? null]); + } + // ---------- results ---------- match(id) { return this.matches.find(m => m.id === id); } teamName(id) { return id ? this.teams.get(id)?.name ?? id : 'TBD'; } @@ -143,15 +175,10 @@ export class Tournament { } _place(matchId, side, teamId) { - if (!matchId) return; + if (!matchId || !teamId) return; const next = this.match(matchId); if (!next) return; next[side === 'A' ? 'teamA' : 'teamB'] = teamId; - // losers-bracket bye: the other side was a bye, so this team walks through - if (next.pendingBye && next.status === 'pending') { - next.status = 'bye'; next.winner = teamId; delete next.pendingBye; - this._propagate(next); - } } _propagate(m) { @@ -163,7 +190,46 @@ export class Tournament { if (m.winner === m.teamB) { reset.teamA = m.teamA; reset.teamB = m.teamB; reset.conditional = false; } else { reset.status = 'void'; } } - if (this.isComplete()) { this.phase = 'final'; this.emit({ type: 'phase', phase: 'final', champion: this.champion() }); } + this.settleByes(); + this._checkComplete(); + } + + _checkComplete() { + if (this.phase !== 'final' && this.isComplete()) { this.phase = 'final'; this.emit({ type: 'phase', phase: 'final', champion: this.champion() }); } + } + + /** + * Resolve byes anywhere in a bracket. A slot is "dead" when nobody can ever fill it: its feeder + * is void (both of that match's slots were dead), or the slot takes the loser of a bye/void match. + * Dead + dead = void; dead + team = bye (team advances); dead + unknown = wait (pendingBye). + * Loops until stable, so a bye in round 1 cascades through the losers bracket correctly. + */ + settleByes() { + let changed = true, guard = 0; + while (changed && guard++ < 50) { + changed = false; + for (const m of this.matches) { + if (m.stage === 'pool' || m.conditional || !['pending', 'on_deck'].includes(m.status)) continue; + const deadA = !m.teamA && (m.pendingBye === 'A' || this._sideDead(m, 'A')); + const deadB = !m.teamB && (m.pendingBye === 'B' || this._sideDead(m, 'B')); + if (deadA && deadB) { m.status = 'void'; m.court = null; delete m.pendingBye; changed = true; } + else if ((deadA && m.teamB) || (deadB && m.teamA)) { + m.status = 'bye'; m.winner = m.teamA ?? m.teamB; m.court = null; delete m.pendingBye; changed = true; + if (m.feeds) this._place(m.feeds, m.feedsSide, m.winner); + } else if (deadA || deadB) { + const side = deadA ? 'A' : 'B'; + if (m.pendingBye !== side) { m.pendingBye = side; changed = true; } + } + } + } + } + + _sideDead(m, side) { + const w = this.matches.find(x => x.feeds === m.id && x.feedsSide === side); + if (w) return w.status === 'void'; + const l = this.matches.find(x => x.loserFeeds === m.id && x.loserFeedsSide === side); + if (l) return l.status === 'void' || l.status === 'bye'; + return false; } /** Complete when every configured stage has matches and all of them are decided. */ @@ -201,10 +267,12 @@ export class Tournament { // bracket: opponent walks over, if known; otherwise the slot becomes a bye const opp = m.teamA === teamId ? m.teamB : m.teamA; if (opp) this.recordResult(m.id, [], { actor, forfeit: teamId }); - else { m[m.teamA === teamId ? 'teamA' : 'teamB'] = null; m.pendingBye = m.teamA === null ? 'A' : 'B'; } + else { const side = m.teamA === teamId ? 'A' : 'B'; m[side === 'A' ? 'teamA' : 'teamB'] = null; m.pendingBye = side; } m.court = null; } } + this.settleByes(); + this._checkComplete(); this.emit({ type: 'team_withdrawn', team: teamId, name: t.name, mode }); } diff --git a/package.json b/package.json index 9edc9a8..ea577c7 100644 --- a/package.json +++ b/package.json @@ -1 +1 @@ -{"name":"courtside","type":"module","version":"0.4.2","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}} \ No newline at end of file +{"name":"courtside","type":"module","version":"0.5.0","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}} \ No newline at end of file diff --git a/server/public/admin.js b/server/public/admin.js index 6c90ba7..d9522a9 100644 --- a/server/public/admin.js +++ b/server/public/admin.js @@ -25,6 +25,17 @@ document.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', () => navigator.clipboard?.writeText(b.dataset.copy).then(() => { b.textContent = 'Copied'; }))); + // manual bracket builder: highlight a team picked twice before the form is submitted + const pairsForm = document.querySelector('.manualform'); + if (pairsForm) { + const check = () => { + const seen = new Map(); + pairsForm.querySelectorAll('select[name^=a],select[name^=b]').forEach(sel => { if (sel.value) seen.set(sel.value, (seen.get(sel.value) ?? 0) + 1); }); + pairsForm.querySelectorAll('tr').forEach(tr => tr.classList.toggle('dup', [...tr.querySelectorAll('select')].some(sel => sel.value && seen.get(sel.value) > 1))); + }; + pairsForm.addEventListener('change', check); check(); + } + // keep the desk fresh when another organizer enters a score const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${slug}`); let last = null; diff --git a/server/public/app.css b/server/public/app.css index f87b4e2..16af683 100644 --- a/server/public/app.css +++ b/server/public/app.css @@ -115,6 +115,9 @@ body.display .queue{font-size:26px}body.display details{display:none} .keeperbox .button{margin-top:6px} .qrgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}.qrgrid .qr img{width:min(80vw,300px)} @media print{.qrgrid{grid-template-columns:1fr 1fr}.qrgrid .card{break-inside:avoid;border:1px solid #000}} +.manual{margin-top:10px}.manual summary{font-weight:600;color:var(--accent)} +.pairs select{max-width:220px;font:inherit;padding:4px 6px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--ink)} +.pairs td{padding:4px 6px}.pairs tr.dup select{border-color:var(--crit)} /* score keeper (Courtside Manager) */ body.keeper-body{background:#0F1418;color:#F2F5F7}body.keeper-body .top,body.keeper-body .foot{display:none} body.keeper-body main{max-width:720px;padding:12px 12px 40px} diff --git a/server/routes.js b/server/routes.js index 8d0f15b..3576701 100644 --- a/server/routes.js +++ b/server/routes.js @@ -170,6 +170,12 @@ export function buildRouter({ registry, auth, store }) { action('/broadcast', ({ t, engine, body }) => { const text = String(body.text ?? '').trim().slice(0, 300); if (!text) throw new Error('Nothing to send'); engine.broadcast(text); return 'Broadcast sent'; }); action('/generate', ({ t, body }) => { if (body.what === 'pools') { t.generatePools(+body.poolSize || 4); return `Pools generated: ${t.pools.map(p => `${p.id} (${p.teams.length})`).join(', ')}`; } + if (body.what === 'manual') { + const pairs = []; + for (let i = 1; body[`a${i}`] !== undefined || body[`b${i}`] !== undefined; i++) pairs.push([body[`a${i}`] || null, body[`b${i}`] || null]); + const res = t.generateBracketFromPairs(pairs, { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace }); + return `${body.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} built from your pairings`; + } const seeded = t.pools.length ? t.advanceFromPools({ perPool: +body.perPool || 2, wildcards: +body.wildcards || 0 }) : t.activeTeams().map(x => x.id); if (seeded.length < 2) throw new Error('Need at least two teams'); const res = t.generateBracket(seeded, { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace }); diff --git a/server/state.js b/server/state.js index 20ff845..0066d37 100644 --- a/server/state.js +++ b/server/state.js @@ -16,6 +16,14 @@ export class Registry { t.slug = row.slug; const engine = row.engine ? CourtEngine.fromJSON(t, row.engine) : new CourtEngine(t, { courtCount: t.courtCount }); this._attach(row.slug, t, engine); + // Re-run bye settling on load: a tournament saved by an older version may have bracket slots + // that could never be filled (the 18-team double-elim case). Cheap and idempotent. + if (t.matches.some(m => m.stage !== 'pool') && t.phase !== 'final') { + const before = JSON.stringify(t.matches.map(m => [m.status, m.teamA, m.teamB])); + t.settleByes(); t._checkComplete(); + if (t.phase === 'live') engine.tick(); + if (before !== JSON.stringify(t.matches.map(m => [m.status, m.teamA, m.teamB]))) { console.log(`[load] ${row.slug}: settled bracket byes`); this.persist(row.slug); } + } } } diff --git a/server/views/admin.js b/server/views/admin.js index 36f1c37..3b4b500 100644 --- a/server/views/admin.js +++ b/server/views/admin.js @@ -95,6 +95,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke ` : ''} + ${!hasBracket && t.stages.includes('bracket') && (t.pools.length || !t.stages.includes('pool')) ? manualBuilder(t) : ''} ${generated ? `
All matches (${t.matches.length}) — enter or correct any score ${t.matches.filter(m => !m.conditional || m.teamA).map(m => ``).join('')}
IDStageMatchStatusScore
${esc(m.id)}${m.stage === 'pool' ? `Pool ${esc(m.poolId)} R${m.round}` : esc(m.label ?? `${m.stage} R${m.round}`)}${esc(t.teamName(m.teamA))} vs ${esc(t.teamName(m.teamB))}${esc(m.status)}${m.court ? ` C${m.court}` : ''}${m.sets.map(x => x.join('-')).join(', ')} ${m.teamA && m.teamB && !['void', 'bye'].includes(m.status) ? `
` : ''}
` : ''} @@ -120,6 +121,26 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke return layout({ title: `${t.name} desk`, admin: who, body, script: '/static/admin.js' }); } +/** Hand-built bracket: one row per first-round match with a team picker on each side. */ +function manualBuilder(t) { + const teams = t.activeTeams(); + const seeded = t.pools.length ? t.advanceFromPools({ perPool: 2, wildcards: Math.max(0, (1 << Math.ceil(Math.log2(Math.max(2, t.pools.length * 2)))) - t.pools.length * 2) }) : teams.map(x => x.id); + const pairs = t.defaultPairs(seeded); + const opt = sel => `` + teams.map(x => ``).join(''); + return `
Build the bracket by hand — choose who plays whom in round 1 +
+ +
+ + ${teams.length} teams in a bracket of ${pairs.length * 2}; ${pairs.length * 2 - teams.length} bye${pairs.length * 2 - teams.length === 1 ? '' : 's'}. Every team must appear exactly once; a side left as "bye" gives the other team a walkover.
+ + ${pairs.map(([a, b], i) => ``).join('')} +
MatchSide ASide B
${i + 1}vs
+

Pre-filled with standard seeding (1 v ${pairs.length * 2}, 2 v ${pairs.length * 2 - 1}, …). Winners of matches 1 and 2 meet, 3 and 4 meet, and so on down the sheet.

+ +
`; +} + function scorePad(t, m) { const live = m.live ?? [0, 0]; return `
diff --git a/test/engine.test.js b/test/engine.test.js index 2788341..f44dd2b 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -152,3 +152,67 @@ test('double elimination with 6 teams (byes) plays to a champion', () => { assert.equal(reset.status, 'final'); assert.equal(t.champion(), t.teamName(reset.winner)); }); + +for (const n of [5, 9, 12, 17, 18, 23]) { + test(`double elimination with ${n} teams (many byes) plays to a champion with no stuck matches`, () => { + const t = setup(n, { stages: ['bracket'] }); + t.closeRegistration(); + t.generateBracket(ids(t), { type: 'double' }); + let clock = 0; + const eng = new CourtEngine(t, { now: () => clock }); + eng.start(); + let guard = 0; + while (t.phase === 'live' && guard++ < 400) { + const c = eng.courts.find(c => c.matchId); + assert.ok(c, `engine stalled with ${t.matches.filter(m => m.status === 'pending').length} pending matches`); + clock += 10 * 60_000; + eng.recordResult(c.matchId, [[21, 10]]); + } + assert.equal(t.phase, 'final'); + assert.ok(t.champion()); + assert.equal(t.matches.filter(m => m.status === 'pending').length, 0); + }); + test(`single elimination with ${n} teams plays to a champion`, () => { + const t = setup(n, { stages: ['bracket'] }); + t.closeRegistration(); + t.generateBracket(ids(t), { type: 'single', thirdPlace: true }); + let clock = 0; + const eng = new CourtEngine(t, { now: () => clock }); + eng.start(); + let guard = 0; + while (t.phase === 'live' && guard++ < 200) { + const c = eng.courts.find(c => c.matchId); + assert.ok(c, 'engine stalled'); + clock += 10 * 60_000; + eng.recordResult(c.matchId, [[21, 10]]); + } + assert.equal(t.phase, 'final'); + }); +} + +test('withdrawal from a bracket before the opponent is known becomes a bye for the opponent', () => { + const t = setup(4, { stages: ['bracket'] }); + t.closeRegistration(); + const [a, b, c, d] = ids(t); + t.generateBracket([a, b, c, d], { type: 'single' }); + const r1 = t.matches.filter(m => m.round === 1); + const mA = r1.find(m => [m.teamA, m.teamB].includes(a)); + t.recordResult(mA.id, mA.teamA === a ? [[21, 5]] : [[5, 21]]); // a advances to final + t.withdrawTeam(a, { mode: 'forfeit' }); // a leaves before the final is set + const other = r1.find(m => m !== mA); + t.recordResult(other.id, [[21, 5]]); + assert.equal(t.phase, 'final'); + assert.equal(t.champion(), t.teamName(other.teamA)); +}); + +test('manual bracket: hand-picked round-1 pairs land in the chosen matches', () => { + const t = setup(6, { stages: ['bracket'] }); + t.closeRegistration(); + const [a, b, c, d, e, f] = ids(t); + t.generateBracketFromPairs([[a, f], [b, null], [c, d], [null, e]], { type: 'single' }); + const r1 = t.matches.filter(m => m.round === 1).sort((x, y) => x.slot - y.slot); + assert.deepEqual(r1.map(m => [m.teamA, m.teamB]), [[a, f], [b, null], [c, d], [null, e]]); + assert.equal(r1[1].status, 'bye'); assert.equal(r1[3].status, 'bye'); + assert.throws(() => t.generateBracketFromPairs([[a, a], [b, c], [d, e], [f, null]]), /placed twice/); + assert.throws(() => t.generateBracketFromPairs([[a, b], [c, d], [e, null], [null, null]]), /Not placed/); +});