Optional age on signup and the desk. Auto-generated brackets pair neighbours by age with byes spread down the sheet; teams without an age are placed at random, fully random when no ages exist. Release 0.6.0. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
278 lines
12 KiB
JavaScript
278 lines
12 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { Tournament, validateSet } from '../engine/tournament.js';
|
|
import { CourtEngine } from '../engine/courts.js';
|
|
import { roundRobin, makePools, singleElimination, doubleElimination, seedOrder, resetIds } from '../engine/formats.js';
|
|
|
|
const setup = (n, opts = {}) => {
|
|
resetIds();
|
|
const t = new Tournament({ name: 'test', ...opts });
|
|
for (let i = 1; i <= n; i++) t.registerTeam({ name: `T${i}`, players: 2 });
|
|
return t;
|
|
};
|
|
const ids = t => [...t.teams.keys()];
|
|
|
|
test('round robin: every pair once, each team once per round', () => {
|
|
resetIds();
|
|
const ms = roundRobin(['a', 'b', 'c', 'd', 'e']);
|
|
assert.equal(ms.length, 10);
|
|
const pairs = new Set(ms.map(m => [m.teamA, m.teamB].sort().join()));
|
|
assert.equal(pairs.size, 10);
|
|
for (let r = 1; r <= 5; r++) {
|
|
const teams = ms.filter(m => m.round === r).flatMap(m => [m.teamA, m.teamB]);
|
|
assert.equal(new Set(teams).size, teams.length);
|
|
}
|
|
});
|
|
|
|
test('pools: snake seeding of 12 into 3 pools', () => {
|
|
const p = makePools(Array.from({ length: 12 }, (_, i) => `s${i + 1}`), 4);
|
|
assert.deepEqual(p.map(x => x.teams), [['s1', 's6', 's7', 's12'], ['s2', 's5', 's8', 's11'], ['s3', 's4', 's9', 's10']]);
|
|
});
|
|
|
|
test('seed order for 8 is the standard 1-8, 4-5, 2-7, 3-6', () => {
|
|
assert.deepEqual(seedOrder(8), [1, 8, 4, 5, 2, 7, 3, 6]);
|
|
});
|
|
|
|
test('single elimination with 6 teams gives top two seeds byes into round 2', () => {
|
|
resetIds();
|
|
const { matches, size } = singleElimination(['s1', 's2', 's3', 's4', 's5', 's6']);
|
|
assert.equal(size, 8);
|
|
const r2 = matches.filter(m => m.round === 2);
|
|
assert.deepEqual(r2.map(m => [m.teamA, m.teamB]).flat().filter(Boolean).sort(), ['s1', 's2']);
|
|
assert.equal(matches.filter(m => m.status === 'bye').length, 2);
|
|
});
|
|
|
|
test('scores are validated against rally rules', () => {
|
|
const r = { pointsTo: 21, winBy: 2, cap: 25 };
|
|
assert.ok(validateSet(21, 19, r));
|
|
assert.ok(validateSet(25, 24, r));
|
|
assert.ok(validateSet(23, 21, r));
|
|
assert.throws(() => validateSet(21, 20, r));
|
|
assert.throws(() => validateSet(24, 20, r));
|
|
assert.throws(() => validateSet(26, 24, r));
|
|
assert.throws(() => validateSet(19, 17, r));
|
|
});
|
|
|
|
test('withdrawal in forfeit mode: remaining games lost, standings recompute', () => {
|
|
const t = setup(4);
|
|
t.closeRegistration(); t.generatePools(4);
|
|
const [a, b, c, d] = ids(t);
|
|
const m = t.matches.find(x => [x.teamA, x.teamB].includes(a) && [x.teamA, x.teamB].includes(b));
|
|
t.recordResult(m.id, [[21, 10]].map(s => m.teamA === a ? s : s.reverse()));
|
|
t.withdrawTeam(a, { mode: 'forfeit' });
|
|
const rows = t.standings('A');
|
|
assert.equal(rows.at(-1).teamId, a);
|
|
assert.equal(rows.at(-1).status, 'withdrawn');
|
|
const forfeits = t.matches.filter(x => x.status === 'forfeit');
|
|
assert.equal(forfeits.length, 2);
|
|
assert.ok(forfeits.every(x => x.winner !== a));
|
|
});
|
|
|
|
test('withdrawal in void mode removes the team\'s played games from standings', () => {
|
|
const t = setup(4);
|
|
t.closeRegistration(); t.generatePools(4);
|
|
const [a, b] = ids(t);
|
|
const m = t.matches.find(x => [x.teamA, x.teamB].includes(a) && [x.teamA, x.teamB].includes(b));
|
|
t.recordResult(m.id, m.teamA === a ? [[21, 10]] : [[10, 21]]);
|
|
t.withdrawTeam(a, { mode: 'void' });
|
|
const rowB = t.standings('A').find(r => r.teamId === b);
|
|
assert.equal(rowB.played, 0);
|
|
assert.equal(t.matches.filter(x => x.status === 'void').length, 3);
|
|
});
|
|
|
|
test('three-way circular head-to-head falls through to the next tiebreak', () => {
|
|
const t = setup(3, { rules: { tiebreaks: ['headToHead', 'pointDiff'] } });
|
|
t.closeRegistration(); t.generatePools(3);
|
|
const [a, b, c] = ids(t);
|
|
const find = (x, y) => t.matches.find(m => [m.teamA, m.teamB].includes(x) && [m.teamA, m.teamB].includes(y));
|
|
const win = (x, y, s) => { const m = find(x, y); t.recordResult(m.id, m.teamA === x ? [s] : [[s[1], s[0]]]); };
|
|
win(a, b, [21, 10]); win(b, c, [21, 15]); win(c, a, [21, 19]);
|
|
const rows = t.standings('A');
|
|
assert.deepEqual(rows.map(r => r.w), [1, 1, 1]);
|
|
assert.equal(rows[0].teamId, a); // best point diff (+9)
|
|
assert.equal(rows[0].decidedBy, 'pointDiff');
|
|
});
|
|
|
|
test('court engine: two courts, no team on two courts, alerts once per match', () => {
|
|
let clock = 0;
|
|
const t = setup(8, { stages: ['pool'] });
|
|
t.closeRegistration(); t.generatePools(4);
|
|
const eng = new CourtEngine(t, { now: () => clock });
|
|
eng.start();
|
|
assert.equal(eng.courts.filter(c => c.matchId).length, 2);
|
|
let guard = 0;
|
|
while (t.phase === 'live' && guard++ < 100) {
|
|
const busy = eng.courts.filter(c => c.matchId).flatMap(c => { const m = t.match(c.matchId); return [m.teamA, m.teamB]; });
|
|
assert.equal(new Set(busy).size, busy.length, 'a team is on two courts');
|
|
const c = eng.courts.find(c => c.matchId);
|
|
clock += 12 * 60_000;
|
|
eng.recordResult(c.matchId, [[21, 15]]);
|
|
}
|
|
assert.equal(t.phase, 'final');
|
|
const upNow = eng.alerts.filter(a => a.kind === 'up_now');
|
|
assert.equal(upNow.length, 12);
|
|
assert.equal(new Set(upNow.map(a => a.match)).size, 12);
|
|
});
|
|
|
|
test('court engine: paused court is skipped, resume picks up the queue', () => {
|
|
let clock = 0;
|
|
const t = setup(4, { stages: ['pool'] });
|
|
t.closeRegistration(); t.generatePools(4);
|
|
const eng = new CourtEngine(t, { now: () => clock });
|
|
eng.start();
|
|
eng.pauseCourt(2, 'rain');
|
|
clock += 10 * 60_000;
|
|
eng.recordResult(eng.courts[0].matchId, [[21, 5]]);
|
|
eng.recordResult(eng.courts[1].matchId, [[21, 5]]);
|
|
assert.equal(eng.courts[1].matchId, null);
|
|
assert.ok(eng.courts[0].matchId);
|
|
eng.resumeCourt(2);
|
|
assert.ok(eng.courts[1].matchId);
|
|
});
|
|
|
|
test('double elimination with 6 teams (byes) plays to a champion', () => {
|
|
const t = setup(6, { 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++ < 100) {
|
|
const c = eng.courts.find(c => c.matchId);
|
|
assert.ok(c, 'engine stalled');
|
|
clock += 10 * 60_000;
|
|
const m = t.match(c.matchId);
|
|
// lower seed (later id) always loses, except grand final where LB side wins to force the reset
|
|
const lbWins = m.label === 'Grand final';
|
|
eng.recordResult(m.id, lbWins ? [[10, 21]] : [[21, 10]]);
|
|
}
|
|
assert.equal(t.phase, 'final');
|
|
const reset = t.matches.find(m => m.label === 'Bracket reset');
|
|
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/);
|
|
});
|
|
|
|
test('withdrawal mid-bracket shows as forfeits (not byes) and play continues to a champion', () => {
|
|
const t = setup(8, { stages: ['bracket'] });
|
|
t.closeRegistration();
|
|
t.generateBracket(ids(t), { type: 'double' });
|
|
let clock = 0;
|
|
const eng = new CourtEngine(t, { now: () => clock });
|
|
eng.start();
|
|
// play two matches, then withdraw a team that is live on a court
|
|
for (let i = 0; i < 2; i++) { const c = eng.courts.find(c => c.matchId); clock += 10 * 60_000; eng.recordResult(c.matchId, [[21, 10]]); }
|
|
const liveMatch = t.match(eng.courts.find(c => c.matchId).matchId);
|
|
const quitter = liveMatch.teamA;
|
|
eng.withdrawTeam(quitter, { mode: 'forfeit' });
|
|
// the live match is now a forfeit by the quitter, and any later match they land in forfeits too
|
|
assert.equal(liveMatch.status, 'forfeit'); assert.equal(liveMatch.loser, quitter);
|
|
let guard = 0;
|
|
while (t.phase === 'live' && guard++ < 100) {
|
|
const c = eng.courts.find(c => c.matchId); assert.ok(c, 'stalled');
|
|
clock += 10 * 60_000; eng.recordResult(c.matchId, [[21, 10]]);
|
|
}
|
|
assert.equal(t.phase, 'final');
|
|
const theirs = t.matches.filter(m => [m.teamA, m.teamB].includes(quitter) && m.stage !== 'pool');
|
|
assert.ok(theirs.length >= 2, 'quitter appears in WB and LB');
|
|
assert.ok(theirs.every(m => m.status === 'forfeit' && m.loser === quitter), 'every match of the quitter is a forfeit against them');
|
|
assert.equal(t.matches.filter(m => m.status === 'bye' && m.round > 1).length, 0, 'no silent byes created by the withdrawal');
|
|
});
|
|
|
|
test('age-matched round 1: neighbours in age play each other, byes spread evenly, everyone placed once', () => {
|
|
const t = setup(0, { stages: ['bracket'] });
|
|
const ages = [52, 23, 41, 38, 19, 60, 44, 25, 33, 29, null, 47];
|
|
ages.forEach((a, i) => t.registerTeam({ name: `T${i + 1}`, players: 2, avgAge: a }));
|
|
t.closeRegistration();
|
|
const pairs = t.pairsByAge();
|
|
assert.equal(pairs.length, 8); // 12 teams -> 16 bracket -> 8 matches
|
|
assert.equal(pairs.filter(([a, b]) => a && b).length, 4); // 4 real matches, 4 byes
|
|
const placed = pairs.flat().filter(Boolean);
|
|
assert.equal(new Set(placed).size, 12);
|
|
// every real match pairs two teams within 10 years of each other
|
|
for (const [a, b] of pairs) if (a && b) {
|
|
const x = t.teams.get(a).avgAge, y = t.teams.get(b).avgAge;
|
|
if (x != null && y != null) assert.ok(Math.abs(x - y) <= 10, `${x} vs ${y}`);
|
|
}
|
|
// byes are spread, not bunched at the bottom
|
|
const byeRows = pairs.map(([a, b], i) => (!a || !b) ? i : -1).filter(i => i >= 0);
|
|
assert.ok(byeRows[0] < 4 && byeRows.at(-1) >= 4);
|
|
t.generateBracketFromPairs(pairs, { type: 'double' });
|
|
assert.throws(() => t.registerTeam({ name: 'Bad', players: 2, avgAge: 150 }, { force: true }));
|
|
});
|
|
|
|
test('no ages at all: round-1 pairing is random, still valid', () => {
|
|
const t = setup(8, { stages: ['bracket'] });
|
|
t.closeRegistration();
|
|
let seed = 3; const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32);
|
|
const pairs = t.pairsByAge(undefined, rnd);
|
|
assert.equal(pairs.length, 4);
|
|
assert.equal(new Set(pairs.flat()).size, 8);
|
|
assert.notDeepEqual(pairs.flat(), ids(t), 'order should not simply be seed order');
|
|
t.generateBracketFromPairs(pairs, { type: 'single' });
|
|
});
|