Average player age at registration; age-matched round 1 for bracket-only tournaments

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
This commit is contained in:
bkvargyasandClaude Fable 5.1 committed 2026-09-05 16:28:12 +00:00
1 parent 3f60a39e45
commit 103c221a12
10 files changed
+114 -19

No files matched your search

+43 -2
View File
@@ -26,7 +26,7 @@ export class Tournament {
emit(evt) { this.log.push({ ts: this.log.length, ...evt }); for (const f of this.listeners) f(evt, this); }
// ---------- registration ----------
registerTeam({ name, players = 2, captain = null, phone = null, playerNames = [] }, { force = false } = {}) {
registerTeam({ name, players = 2, captain = null, phone = null, playerNames = [], avgAge = null }, { force = false } = {}) {
if (this.phase !== 'checkin' && !force) throw new Error('Registration is closed');
if (force && this.matches.length) throw new Error('Pools or bracket already generated; regenerate after adding a team');
name = String(name ?? '').trim();
@@ -36,8 +36,9 @@ export class Tournament {
if (!Number.isInteger(players) || players < 1 || players > 20) throw new Error('Player count must be a whole number between 1 and 20');
if ([...this.teams.values()].some(t => t.name.toLowerCase() === name.toLowerCase())) throw new Error(`Team name "${name}" already taken`);
if (players < this.rules.teamSize) throw new Error(`Need at least ${this.rules.teamSize} players for ${this.rules.teamSize}s`);
avgAge = parseAge(avgAge);
const id = `t${this._seq++}`;
const team = { id, name, players, captain, phone, playerNames: playerNames.filter(Boolean), seed: this.teams.size + 1, status: 'registered', poolId: null, code: code4(), registeredAt: this.now?.() ?? Date.now() };
const team = { id, name, players, captain, phone, avgAge, playerNames: playerNames.filter(Boolean), seed: this.teams.size + 1, status: 'registered', poolId: null, code: code4(), registeredAt: this.now?.() ?? Date.now() };
this.teams.set(id, team);
this.emit({ type: 'team_registered', team: id, name });
return team;
@@ -55,6 +56,7 @@ export class Tournament {
for (const k of ['captain', 'phone']) if (patch[k] !== undefined) t[k] = patch[k];
if (patch.players !== undefined) { const p = Number(patch.players); if (!Number.isInteger(p) || p < 1) throw new Error('Bad player count'); t.players = p; }
if (patch.playerNames !== undefined) t.playerNames = patch.playerNames.filter(Boolean);
if (patch.avgAge !== undefined) t.avgAge = parseAge(patch.avgAge);
this.emit({ type: 'team_updated', team: id, name: t.name });
return t;
}
@@ -138,6 +140,32 @@ export class Tournament {
return this.generateBracket(seeded, { ...opts, manual: true });
}
/**
* Round-1 pairs that put teams of similar average age against each other. Teams are sorted by
* age (teams without an age are slotted in at random) and laid down the sheet in order; the byes needed to fill the bracket
* are spread evenly so each bye team's round-2 opponent is also close in age. Returns pairs in
* sheet order, ready for generateBracketFromPairs.
*/
pairsByAge(teamIds = this.activeTeams().map(t => t.id), rnd = Math.random) {
const teams = teamIds.map(id => this.teams.get(id));
const known = teams.filter(t => t.avgAge != null).sort((a, b) => a.avgAge - b.avgAge);
// teams that gave no age are dropped in at random positions (fully random when nobody gave one)
const ordered = known.map(t => t.id);
for (const t of shuffle(teams.filter(t => t.avgAge == null), rnd)) ordered.splice(Math.floor(rnd() * (ordered.length + 1)), 0, t.id);
const size = 1 << Math.ceil(Math.log2(Math.max(2, ordered.length)));
const matches = size / 2, full = ordered.length - matches; // matches with two teams; the rest are byes
// choose which match positions are full, spread evenly down the sheet
const fullAt = new Set(Array.from({ length: full }, (_, i) => Math.floor((i + 0.5) * matches / full)));
const pairs = []; let k = 0;
for (let i = 0; i < matches; i++) {
if (fullAt.has(i)) pairs.push([ordered[k++], ordered[k++]]);
else pairs.push([ordered[k++] ?? null, null]);
}
return pairs;
}
hasAges() { return this.activeTeams().some(t => t.avgAge != null); }
/** 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)));
@@ -383,6 +411,19 @@ export function validateSet(a, b, rules) {
return true;
}
function shuffle(arr, rnd = Math.random) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
return a;
}
function parseAge(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
if (!Number.isFinite(n) || n < 5 || n > 99) throw new Error('Average age should be a number between 5 and 99');
return Math.round(n);
}
function groupBy(sortedList, key) {
const out = [];
for (const item of sortedList) {