From 103c221a1266a2f9b9e16c800437b6f899c5592c Mon Sep 17 00:00:00 2001 From: Brian Vargyas Date: Sat, 5 Sep 2026 16:28:12 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi --- CHANGELOG.md | 12 +++++++++++ docs/API.md | 4 ++-- docs/ORGANIZER-GUIDE.md | 4 ++-- engine/tournament.js | 45 +++++++++++++++++++++++++++++++++++++++-- package.json | 2 +- server/public/app.css | 1 + server/routes.js | 16 ++++++++++----- server/views/admin.js | 15 +++++++------- server/views/public.js | 1 + test/engine.test.js | 33 ++++++++++++++++++++++++++++++ 10 files changed, 114 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16fb2a7..81f1beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ so you can always tell what a deployment is actually serving. ## [Unreleased] +## [0.6.0] - 2026-09-03 + +### Added +- Registration asks for the team's **average player age** (optional), with a note that it is + used to match teams of similar age groups in round 1 and is not shown publicly. Editable on + the desk's team list and when adding a walk-up team. +- **Age-matched round 1** for bracket-only tournaments: teams are sorted by average age and + neighbours play each other; teams that gave no age are slotted in at random, and if nobody + gave an age the first round is fully random. Byes are spread down the sheet so round-2 + opponents are also close in age. On by default when generating; untick to use seed order. + The hand-built bracket form is pre-filled the same way. + ## [0.5.3] - 2026-09-03 ### Changed diff --git a/docs/API.md b/docs/API.md index af3c7f5..baee0c1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -9,7 +9,7 @@ All pages are server-rendered HTML except where noted. `` is the URL-safe | GET | `/` | List of tournaments. | | GET | `/healthz` | `{ ok, tournaments, uptime }` JSON. Used by the Docker healthcheck. | | GET | `/t/` | The QR landing page. Registration form while phase is `checkin`; otherwise the live board / results (client-rendered from state JSON). | -| POST | `/t//register` | Form fields `name`, `captain`, `phone`, `players`, `playerNames` (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. | +| POST | `/t//register` | Form fields `name`, `captain`, `phone`, `players`, `avgAge` (optional, 5–99), `playerNames` (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. | | GET | `/t//teams/` | The same personalized board addressed by public team id; this is what team-name links use. | | GET | `/t//team/` | Personalized board for the team with that 4-character code. `?new=1` shows the post-registration confirmation instead. | | GET | `/t//display` | TV display mode of the board. | @@ -78,7 +78,7 @@ All organizer routes require the session cookie set by `/admin/login` (or Cloudf | POST | `/admin/t//phase` | `phase` = `checkin` / `closed` / `live` / `final` | Change phase. `live` requires generated play and starts court assignment. | | POST | `/admin/t//banner` | `banner` | Set (or clear with empty) the persistent banner. | | POST | `/admin/t//broadcast` | `text` | One-time alert to all team pages. | -| POST | `/admin/t//generate` | `what=pools`, `poolSize` — or `what=bracket`, `perPool`, `wildcards`, `type` (`single`/`double`), `thirdPlace` — or `what=manual`, `type`, `thirdPlace`, `a1`,`b1`,`a2`,`b2`,… (team ids per round-1 match; empty = bye) | Generate pools or bracket, automatically or from hand-picked pairs. | +| POST | `/admin/t//generate` | `what=pools`, `poolSize` — or `what=bracket`, `perPool`, `wildcards`, `type` (`single`/`double`), `thirdPlace`, `byAge` (bracket-only: age-matched round 1, random where no age) — or `what=manual`, `type`, `thirdPlace`, `a1`,`b1`,`a2`,`b2`,… (team ids per round-1 match; empty = bye) | Generate pools or bracket, automatically or from hand-picked pairs. | | POST | `/admin/t//regenerate` | | Clear all generated play (only before any score). | | POST | `/admin/t//score` | `match`, `sets` (`"21-18, 19-21, 15-9"`) | Record or correct a result. Validated against the rules. | | POST | `/admin/t//live` | `match`, `a`, `b` | Update the running score of a live match (JSON, used by the score pad). | diff --git a/docs/ORGANIZER-GUIDE.md b/docs/ORGANIZER-GUIDE.md index 642dda7..56f789f 100644 --- a/docs/ORGANIZER-GUIDE.md +++ b/docs/ORGANIZER-GUIDE.md @@ -12,14 +12,14 @@ Everything an organizer does happens at `/admin` on a phone or laptop. Sign in w ## Check-in -Teams scan the code and fill in team name, captain, mobile, number of players, optional player names. They get a private team link on the confirmation screen with a "Text it to myself" button. The team link shows their schedule, record, and alerts all day. +Teams scan the code and fill in team name, captain, mobile, number of players, an optional average player age, and optional player names. They get a private team link on the confirmation screen with a "Text it to myself" button. The team link shows their schedule, record, and alerts all day. From the desk you can rename a team, fix the captain or player count, remove a team, add a walk-up team, and set seeds (1 = strongest). Seeds decide pool placement (snake order) and bracket placement. If you don't seed, registration order is used. ## Starting play 1. **Close registration.** The public page switches to the roster and "schedule coming." -2. **Generate pools** (pick the pool size) or, for bracket-only, generate the bracket. You can clear and regenerate freely until the first score is entered. A late team? Clear generated play, add the team, regenerate. +2. **Generate pools** (pick the pool size) or, for bracket-only, generate the bracket. For bracket-only days, "Match round 1 by similar age" (on by default) sorts teams by the average age they gave at registration and pairs neighbours, drops teams without an age in at random, and spreads byes so second-round opponents are close in age too; untick it to use seed order. You can clear and regenerate freely until the first score is entered. A late team? Clear generated play, add the team, regenerate. To decide the first-round matchups yourself, open **Build the bracket by hand**: one row per round-1 match with a team picker on each side, pre-filled with standard seeding. Leave a side as "bye" to give the other team a walkover. Every team must appear exactly once; winners of matches 1 and 2 meet next, 3 and 4 meet, and so on down the sheet. 3. **Go live.** The engine immediately assigns the first matches to the courts. Teams whose match is next see "on deck for Court 2, ~15 min" on their team page and the public board. diff --git a/engine/tournament.js b/engine/tournament.js index 1800530..67c5993 100644 --- a/engine/tournament.js +++ b/engine/tournament.js @@ -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) { diff --git a/package.json b/package.json index 5d83e6f..e404bec 100644 --- a/package.json +++ b/package.json @@ -1 +1 @@ -{"name":"courtside","type":"module","version":"0.5.3","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}} \ No newline at end of file +{"name":"courtside","type":"module","version":"0.6.0","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}} \ No newline at end of file diff --git a/server/public/app.css b/server/public/app.css index af6aed5..dd053de 100644 --- a/server/public/app.css +++ b/server/public/app.css @@ -29,6 +29,7 @@ main{max-width:960px;margin:0 auto;padding:16px 16px 64px} .banner{background:var(--sand-soft);border-left:3px solid var(--sand);padding:10px 14px;border-radius:0 6px 6px 0;margin:12px 0;font-weight:600} .champion{text-align:center;padding:22px 20px;margin:14px 0;border:2px solid var(--gold-deep);border-radius:8px;background:linear-gradient(160deg,var(--gold-light),var(--gold) 55%,var(--gold-deep));color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.25)}.champion .eyebrow{color:#FFF3C4}.champion h2{font-size:40px} /* forms */ +.form .hint{display:block;font-weight:400;font-size:14px;color:var(--muted);margin-top:4px} .form label{display:block;margin:12px 0;font-weight:600} .form input,.form textarea,.form select{display:block;width:100%;margin-top:4px;padding:10px 12px;font:inherit;font-weight:400;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--ink)} button,.button{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.06em;font-size:17px;font-weight:600;padding:10px 18px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer;text-decoration:none;display:inline-block;line-height:1.2} diff --git a/server/routes.js b/server/routes.js index 3576701..74efd56 100644 --- a/server/routes.js +++ b/server/routes.js @@ -42,7 +42,7 @@ export function buildRouter({ registry, auth, store }) { try { const team = registry.mutate(slug, t => t.registerTeam({ name: body.name, captain: String(body.captain ?? '').trim().slice(0, 60), phone: String(body.phone ?? '').trim().slice(0, 30), - players: body.players, playerNames: String(body.playerNames ?? '').split('\n').map(s => s.trim()).filter(Boolean).slice(0, 20), + players: body.players, avgAge: body.avgAge, playerNames: String(body.playerNames ?? '').split('\n').map(s => s.trim()).filter(Boolean).slice(0, 20), })); send.redirect(res, `/t/${slug}/team/${team.code}?new=1`); } catch (e) { @@ -176,10 +176,16 @@ export function buildRouter({ registry, auth, store }) { const res = t.generateBracketFromPairs(pairs, { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace }); return `${body.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} built from your pairings`; } + const opts = { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace }; + if (!t.pools.length && body.byAge) { + if (t.activeTeams().length < 2) throw new Error('Need at least two teams'); + const res = t.generateBracketFromPairs(t.pairsByAge(), opts); + return `${opts.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} generated; ${t.hasAges() ? 'round 1 matched by similar average age (teams without an age placed at random)' : 'no ages were given, so round 1 is random'}`; + } const seeded = t.pools.length ? t.advanceFromPools({ perPool: +body.perPool || 2, wildcards: +body.wildcards || 0 }) : t.activeTeams().map(x => x.id); if (seeded.length < 2) throw new Error('Need at least two teams'); - const res = t.generateBracket(seeded, { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace }); - return `${body.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} generated with ${seeded.length} teams`; + const res = t.generateBracket(seeded, opts); + return `${opts.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} generated with ${seeded.length} teams`; }); action('/regenerate', ({ t }) => { if (t.matches.some(m => ['final', 'forfeit'].includes(m.status))) throw new Error('Scores already entered'); t.matches = []; t.pools = []; for (const x of t.teams.values()) x.poolId = null; if (t.phase === 'live') t.phase = 'closed'; t.emit({ type: 'play_cleared' }); return 'Generated play cleared'; }); action('/score', ({ t, engine, body, who }) => { @@ -197,10 +203,10 @@ export function buildRouter({ registry, auth, store }) { action('/pushback', ({ engine, body }) => { engine.pushBack(body.match, 2); return 'Pushed back'; }); action('/court', ({ engine, body }) => { if (body.op === 'pause') engine.pauseCourt(+body.court, String(body.reason ?? '').slice(0, 60)); else engine.resumeCourt(+body.court); return `Court ${body.court} ${body.op}d`; }); action('/team', ({ t, body }) => { - if (body.op === 'add') { const team = t.registerTeam({ name: body.name, captain: body.captain, players: body.players }, { force: true }); return `Added ${team.name} (code ${team.code})`; } + if (body.op === 'add') { const team = t.registerTeam({ name: body.name, captain: body.captain, players: body.players, avgAge: body.avgAge }, { force: true }); return `Added ${team.name} (code ${team.code})`; } if (body.op === 'remove') { t.removeTeam(body.id); return 'Team removed'; } if (body.op === 'seed') { const list = t.activeTeams().filter(x => x.id !== body.id); list.splice(Math.max(0, +body.seed - 1), 0, t.teams.get(body.id)); t.setSeeds(list.map(x => x.id)); return 'Seeds updated'; } - t.updateTeam(body.id, { name: body.name, captain: body.captain, players: body.players }); return 'Team saved'; + t.updateTeam(body.id, { name: body.name, captain: body.captain, players: body.players, avgAge: body.avgAge }); return 'Team saved'; }); action('/withdraw', ({ t, engine, body, who }) => { const team = t.teams.get(body.id); if (!team) throw new Error('No such team'); if (t.phase === 'live') engine.withdrawTeam(body.id, { mode: body.mode, actor: who }); else t.withdrawTeam(body.id, { mode: body.mode, actor: who }); return `${team.name} withdrawn (${body.mode})`; }); r.post('/admin/t/:slug/delete', async (req, res, { slug }) => { diff --git a/server/views/admin.js b/server/views/admin.js index 3b4b500..fb6efaa 100644 --- a/server/views/admin.js +++ b/server/views/admin.js @@ -82,7 +82,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke ${!generated ? `
${t.stages.includes('pool') ? `` : ''} - ${!t.stages.includes('pool') ? `` : ''} + ${!t.stages.includes('pool') ? `` : ''}

Uses current seeds (${t.activeTeams().length} active teams). Regenerating is allowed until the first score is entered.

` : ''} ${t.pools.length ? `

Pools: ${t.pools.map(p => `${esc(p.id)} (${p.teams.map(id => esc(t.teamName(id))).join(', ')})`).join(' · ')}

` : ''} @@ -102,18 +102,19 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke

Teams (${t.activeTeams().length} active)

- +
SeedTeamCaptainPlayersCode
${teams.map(x => ` - + + `).join('')}
SeedTeamCaptainPlayersAvg ageCode
${generated ? '' : ''}
${esc(x.phone ?? '')} ${esc(x.code)} ${x.status === 'withdrawn' ? `withdrawn (${esc(x.withdrawalMode)})` : generated ? `
` : act('team', 'Remove', 'danger', ``)}
- ${!generated ? `
` : '

Play is generated: use Withdraw for teams that leave. To add a team now, clear generated play first.

'} + ${!generated ? `
` : '

Play is generated: use Withdraw for teams that leave. To add a team now, clear generated play first.

'}

Recent activity

    ${events.slice(0, 40).map(e => `
  • ${new Date(e.ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} ${esc(describe(e, t))}
  • `).join('')}
@@ -125,8 +126,8 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke 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 => `` + teams.map(x => ``).join(''); + const pairs = !t.pools.length ? t.pairsByAge() : t.defaultPairs(seeded); + const opt = sel => `` + teams.map(x => ``).join(''); return `
Build the bracket by hand — choose who plays whom in round 1
@@ -136,7 +137,7 @@ function manualBuilder(t) { ${pairs.map(([a, b], i) => ``).join('')}
MatchSide ASide B
${i + 1}vs
-

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.

+

${!t.pools.length ? (t.hasAges() ? 'Pre-filled by similar average age (youngest at the top; teams without an age placed at random).' : 'Pre-filled at random (no ages were given).') : `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.

`; } diff --git a/server/views/public.js b/server/views/public.js index 87cbc6c..cbc4bb4 100644 --- a/server/views/public.js +++ b/server/views/public.js @@ -27,6 +27,7 @@ export function registerPage(state, { error = null, values = {} } = {}) { +

After you register you'll get a private team link. Keep this page's QR code handy: once play starts it becomes the live board.

diff --git a/test/engine.test.js b/test/engine.test.js index 6e6bc5f..63c88dd 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -242,3 +242,36 @@ test('withdrawal mid-bracket shows as forfeits (not byes) and play continues to 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' }); +});