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:
bkvargyasandClaude Fable 5.1 committed 2026-09-05 15:33:36 +00:00
1 parent a5222953dd
commit a4bee22d96
13 files changed
+214 -25

No files matched your search

+21
View File
@@ -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)}">