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
a4bee22d96
13 files changed
+214
-25
No files matched your search
+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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user