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 <[email protected]> Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
This commit is contained in:
1 parent
a5222953dd
commit
3d0016b317
13 files changed
+214
-25
No files matched your search
@@ -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
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ All organizer routes require the session cookie set by `/admin/login` (or Cloudf
|
||||
| POST | `/admin/t/<slug>/phase` | `phase` = `checkin` / `closed` / `live` / `final` | Change phase. `live` requires generated play and starts court assignment. |
|
||||
| POST | `/admin/t/<slug>/banner` | `banner` | Set (or clear with empty) the persistent banner. |
|
||||
| POST | `/admin/t/<slug>/broadcast` | `text` | One-time alert to all team pages. |
|
||||
| POST | `/admin/t/<slug>/generate` | `what=pools`, `poolSize` — or `what=bracket`, `perPool`, `wildcards`, `type` (`single`/`double`), `thirdPlace` | Generate pools or bracket. |
|
||||
| POST | `/admin/t/<slug>/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/<slug>/regenerate` | | Clear all generated play (only before any score). |
|
||||
| POST | `/admin/t/<slug>/score` | `match`, `sets` (`"21-18, 19-21, 15-9"`) | Record or correct a result. Validated against the rules. |
|
||||
| POST | `/admin/t/<slug>/live` | `match`, `a`, `b` | Update the running score of a live match (JSON, used by the score pad). |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-11
@@ -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 };
|
||||
}
|
||||
+79
-11
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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"}}
|
||||
{"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"}}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
|
||||
<label><input type="checkbox" name="thirdPlace" value="1" checked> 3rd place</label>
|
||||
<button class="primary small" type="submit" name="what" value="bracket">Generate bracket${poolsDone ? '' : ' (pools not finished)'}</button>
|
||||
</form>` : ''}
|
||||
${!hasBracket && t.stages.includes('bracket') && (t.pools.length || !t.stages.includes('pool')) ? manualBuilder(t) : ''}
|
||||
${generated ? `<details><summary>All matches (${t.matches.length}) — enter or correct any score</summary><table class="adm-table"><tr><th>ID</th><th>Stage</th><th>Match</th><th>Status</th><th>Score</th><th></th></tr>
|
||||
${t.matches.filter(m => !m.conditional || m.teamA).map(m => `<tr><td class="mono">${esc(m.id)}</td><td>${m.stage === 'pool' ? `Pool ${esc(m.poolId)} R${m.round}` : esc(m.label ?? `${m.stage} R${m.round}`)}</td><td>${esc(t.teamName(m.teamA))} vs ${esc(t.teamName(m.teamB))}</td><td>${esc(m.status)}${m.court ? ` C${m.court}` : ''}</td><td class="mono">${m.sets.map(x => x.join('-')).join(', ')}</td>
|
||||
<td>${m.teamA && m.teamB && !['void', 'bye'].includes(m.status) ? `<form method="post" action="/admin/t/${esc(t.slug)}/score" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><input name="sets" placeholder="21-18" value="${esc(m.sets.map(x => x.join('-')).join(', '))}" style="width:110px"><button class="small" type="submit">Save</button></form>` : ''}</td></tr>`).join('')}</table></details>` : ''}
|
||||
@@ -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 => `<option value="">— bye —</option>` + teams.map(x => `<option value="${esc(x.id)}" ${x.id === sel ? 'selected' : ''}>${esc(x.name)}${x.seed ? ` (${x.seed})` : ''}</option>`).join('');
|
||||
return `<details class="manual"><summary>Build the bracket by hand — choose who plays whom in round 1</summary>
|
||||
<form method="post" action="/admin/t/${esc(t.slug)}/generate" class="manualform">
|
||||
<input type="hidden" name="what" value="manual">
|
||||
<div class="inline"><label>Bracket <select name="type"><option value="single">Single elimination</option><option value="double">Double elimination</option></select></label>
|
||||
<label><input type="checkbox" name="thirdPlace" value="1"> 3rd place</label>
|
||||
<span class="muted small">${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.</span></div>
|
||||
<table class="adm-table pairs"><tr><th>Match</th><th>Side A</th><th></th><th>Side B</th></tr>
|
||||
${pairs.map(([a, b], i) => `<tr><td>${i + 1}</td><td><select name="a${i + 1}">${opt(a)}</select></td><td class="muted">vs</td><td><select name="b${i + 1}">${opt(b)}</select></td></tr>`).join('')}
|
||||
</table>
|
||||
<p class="muted small">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.</p>
|
||||
<button class="primary small" type="submit">Build bracket from these pairings</button>
|
||||
</form></details>`;
|
||||
}
|
||||
|
||||
function scorePad(t, m) {
|
||||
const live = m.live ?? [0, 0];
|
||||
return `<div class="scorepad" data-match="${esc(m.id)}">
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user