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:
1 parent
3f60a39e45
commit
103c221a12
10 files changed
+114
-19
No files matched your search
+43
-2
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user