From 2c96bf193822b9e7f48ab6c756969a949ee2150e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 02:33:45 +0000 Subject: [PATCH 1/3] Fix bye propagation, packaging, and losers bracket routing; add grand final MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Senior review pass over the bracket generator. Four defects, one new feature, and the supporting test and packaging work. Fixes: - Byes only advanced players in the first winners round, so any participant count that is not a power of two left the losers bracket with matches that could never be played — one waiting on a loser that does not exist, or with no entrants at all — stalling everything downstream. Byes are now resolved across the whole bracket in topological order: walkover winners are pre-placed, a walkover no longer reserves a losers bracket slot, and matches that could only ever receive one player are bypassed so the feeding match points at what came after them. Unreachable matches stay in the array with empty slots and no routing so positions remain stable for rendering. - The published package could not be loaded by Node at all. The build emitted ES module syntax with extensionless relative imports into a package with no "type" or "exports" field, so both require() and import failed outside a bundler. The package now ships separate ESM and CommonJS builds behind an exports map, checked on every CI run by scripts/verify-package.mjs. - Losers from winners round 3 onwards kept their bracket position, which let players meet opponents they had already beaten as early as losers round 4. Each wave of losers is now reordered on a rotating cycle. Over 200 random 64-player tournaments this moves the first possible rematch from losers round 4 to losers round 7 and cuts rematches from ~2.8 to ~0.4 per tournament. Brackets of 16 or fewer are unchanged. - Seeds that were not exactly 1..N silently left participants out of the bracket. Participants are now ranked by seed, and duplicate seeds, duplicate registration ids, a repeating idFactory and other invalid options throw. Adds the grandFinal option ('none' by default, 'single', or 'reset'), which drops the winners final loser into the losers final and runs a grand final, optionally with a bracket reset. Test suite grows from 23 to 183 tests, including an invariant suite that plays every bracket size out with random results and checks that nothing stalls, that every player but the champion accumulates two losses, and that rematches stay rare and late. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M6MiM4MbcS4dhfwzbZBi4K --- .github/workflows/ci.yml | 36 ++++ .gitignore | 3 + .npmignore | 46 ----- .prettierrc | 5 + CHANGELOG.md | 63 ++++++- CONTRIBUTING.md | 25 ++- EXAMPLES.md | 140 +++++++++++---- LICENSE | 21 +++ README.md | 229 +++++++++++++++++++----- demo.ts | 113 ++++++------ package-lock.json | 4 +- package.json | 36 +++- scripts/build.mjs | 29 +++ scripts/verify-package.mjs | 26 +++ src/bracketUtils.ts | 101 +++++------ src/createGrandFinal.ts | 47 +++++ src/createLosersBracket.ts | 77 +++----- src/createWinnersBracket.ts | 40 ++--- src/generateDoubleElimination.ts | 213 ++++++++++------------ src/index.ts | 6 +- src/planBracket.ts | 141 +++++++++++++++ src/processByes.ts | 38 ---- src/resolveByes.ts | 205 +++++++++++++++++++++ src/types.ts | 47 ++++- src/wireLoserRouting.ts | 128 ++++++------- tests/byes.test.ts | 102 +++++++++++ tests/generateDoubleElimination.test.ts | 43 ++--- tests/grandFinal.test.ts | 121 +++++++++++++ tests/helpers.ts | 165 +++++++++++++++++ tests/invariants.test.ts | 217 ++++++++++++++++++++++ tests/validation.test.ts | 142 +++++++++++++++ tsconfig.cjs.json | 8 + tsconfig.json | 16 +- tsconfig.typecheck.json | 9 + 34 files changed, 2064 insertions(+), 578 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .npmignore create mode 100644 .prettierrc create mode 100644 LICENSE create mode 100644 scripts/build.mjs create mode 100644 scripts/verify-package.mjs create mode 100644 src/createGrandFinal.ts create mode 100644 src/planBracket.ts delete mode 100644 src/processByes.ts create mode 100644 src/resolveByes.ts create mode 100644 tests/byes.test.ts create mode 100644 tests/grandFinal.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/invariants.test.ts create mode 100644 tests/validation.test.ts create mode 100644 tsconfig.cjs.json create mode 100644 tsconfig.typecheck.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..113b1fe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ['18.x', '20.x', '22.x'] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build + + - name: Verify published entry points + run: npm run test:package diff --git a/.gitignore b/.gitignore index 00e5268..f35dde3 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ coverage/ .env .env.local + +# TypeScript build info +*.tsbuildinfo diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 2b875aa..0000000 --- a/.npmignore +++ /dev/null @@ -1,46 +0,0 @@ -# Source files (only dist/ is published) -src/ -*.ts -!*.d.ts - -# Test files -tests/ -*.test.ts -*.spec.ts -coverage/ - -# Development files -.vscode/ -.idea/ -*.swp -*.swo -*.log -npm-debug.log* - -# Documentation (keep README.md, exclude others) -EXAMPLES.md -CONTRIBUTING.md -CHANGELOG.md - -# Build tools -tsconfig.json -.gitignore - -# OS files -.DS_Store -Thumbs.db - -# Environment -.env -.env.local - -# Demo files -demo.ts - -# CI/CD -.github/ -.git/ - -# Package manager -package-lock.json - diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..36301bc --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "es5" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index ec61c0d..f58ba82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,68 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] - 2026-09-12 + +### Fixed + +- **Byes no longer stall the losers bracket**: byes were only resolved in the + first winners round, so with any participant count that isn't a power of 2 the + losers bracket could contain matches that never became playable — a match + waiting on a loser that does not exist, or a match with no entrants at all — + and everything downstream of them stalled. Byes are now resolved through the + whole bracket: walkover winners are pre-placed, a walkover no longer reserves a + losers bracket slot (`loserTo` is `null`), and matches that could only ever + receive one player are bypassed so the feeding match points at what came next. + Unreachable matches are kept in the returned array with empty slots and no + routing, so round and position numbering stays stable for rendering. +- **Published package now loads in Node**: the build emitted ES module syntax + into a package with no `"type": "module"` and extensionless relative imports, + so both `require('double-elimination')` and `import` failed outside a bundler. + The package now ships separate ESM and CommonJS builds behind an `exports` map, + verified on every CI run. +- **Rematch prevention in large brackets**: losers from winners round 3 onwards + kept their bracket position, which let players meet opponents they had already + beaten as early as losers round 4. Each wave of losers is now reordered on a + rotating cycle (reverse, reverse-and-half-shift, half-shift, unchanged). Over + 200 random 64-player tournaments this moves the first possible rematch from + losers round 4 to losers round 7 and cuts rematches from ~2.8 to ~0.4 per + tournament. Brackets of 16 or fewer are unchanged. +- **Invalid participants are rejected instead of corrupting the bracket**: seeds + that were not exactly `1..N` silently left participants out of the bracket, and + duplicate seeds dropped players without warning. Participants are now ranked by + seed, so any unique ascending seed values work, and duplicate seeds, duplicate + `registrationId`s, non-numeric seeds, an `idFactory` that repeats ids, a + missing `eventId`, and a fractional `losersStartRoundsBeforeFinal` all throw. +- **Exact bracket sizing**: `nextPowerOf2` used `Math.log2`, which is not + guaranteed to be exact for large inputs; sizing is now integer arithmetic. + +### Added + +- **Grand final support** via the `grandFinal` option: + - `'none'` (default) — unchanged behaviour: the winners final decides 1st/2nd + and the losers final decides 3rd/4th + - `'single'` — the winners final loser drops to the losers final and the two + bracket winners meet once for the title + - `'reset'` — as `'single'`, plus a bracket reset match, played only when the + losers bracket representative wins the first grand final + Grand final matches are returned with `bracketType: 'grandFinal'`. +- `BracketType` and `GrandFinalFormat` are exported. +- A `LICENSE` file, a CI workflow running typecheck, tests, build and a + published-entry-point check on Node 18, 20 and 22, and an `npm run demo` + script. + +### Changed + +- `bracketType` is now `'winners' | 'losers' | 'grandFinal'`. TypeScript code + that exhaustively narrows on it will need a `grandFinal` case, even though no + such match is produced unless the option is enabled. +- Brackets of 32 or more participants have a different losers bracket layout + because of the routing fix above. A bracket generated with an earlier version + will not match one regenerated with this version — finish in-flight + tournaments on the version that created them. +- The package is published as ESM with a CommonJS fallback; `src` is published + alongside `dist` so source maps resolve. + ## [1.2.2] - 2024-12-16 ### Changed @@ -87,4 +149,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Winners and losers bracket routing - TypeScript support with full type definitions - Simplified format: WB Finals winner = 1st, loser = 2nd; LB Finals winner = 3rd, loser = 4th - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8797f8c..3bb8433 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,9 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme 2. **Make your changes** following the coding standards below 3. **Add tests** for any new functionality 4. **Update documentation** if needed -5. **Ensure all tests pass** (`npm test`) +5. **Ensure all tests pass** (`npm test`), the package still typechecks + (`npm run typecheck`), and the built entry points load + (`npm run build && npm run test:package`) 6. **Commit your changes** with clear commit messages 7. **Push to your fork** and submit a pull request @@ -126,13 +128,28 @@ are in opposite halves for large brackets. ``` double-elimination/ ├── src/ # Source TypeScript files -├── dist/ # Compiled JavaScript (generated) -├── tests/ # Test files +│ ├── planBracket.ts # Option validation and derived bracket sizes +│ ├── createWinnersBracket.ts # Winners bracket skeleton and wiring +│ ├── createLosersBracket.ts # Losers bracket skeleton and wiring +│ ├── createGrandFinal.ts # Grand final and bracket reset +│ ├── wireLoserRouting.ts # Winners -> losers bracket routing +│ ├── resolveByes.ts # Walkover resolution across the bracket +│ └── generateDoubleElimination.ts +├── tests/ # Test files (helpers.ts holds a bracket simulator) +├── scripts/ # Build and package verification scripts +├── website/ # Interactive demo site +├── dist/ # Compiled JavaScript, ESM and CJS (generated) ├── package.json # Package configuration -├── tsconfig.json # TypeScript configuration +├── tsconfig.json # TypeScript configuration (ESM build) +├── tsconfig.cjs.json # CommonJS build overrides └── README.md # Documentation ``` +Changes to bracket structure should come with a test in +`tests/invariants.test.ts`: it plays every bracket size out with random results +and checks that nothing stalls, that every player but the champion accumulates +two losses, and that rematches stay rare and late. + ## Areas for Contribution - **Bug fixes**: Fix reported issues diff --git a/EXAMPLES.md b/EXAMPLES.md index 98f224b..0923d32 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -11,6 +11,7 @@ This document provides comprehensive examples of using the `double-elimination` - [Single Elimination Mode](#single-elimination-mode) - [Delayed Losers Bracket](#delayed-losers-bracket) - [Handling Odd Participant Counts](#handling-odd-participant-counts) +- [Grand Final](#grand-final) - [Integration with Database](#integration-with-database) - [Visualizing Brackets](#visualizing-brackets) - [Custom ID Generation](#custom-id-generation) @@ -73,7 +74,7 @@ const smallTournament = generateDoubleElimination({ // Get first round matches const round1 = smallTournament.filter( - (m) => m.round === 1 && m.bracketType === 'winners' + (m) => m.round === 1 && m.bracketType === 'winners', ) console.log('Round 1 matchups:') round1.forEach((match) => { @@ -81,7 +82,7 @@ round1.forEach((match) => { }) // Output: // alice vs diana -// charlie vs bob +// bob vs charlie ``` ## Medium Tournament (16-32 Participants) @@ -109,12 +110,15 @@ const esportsBracket = generateDoubleElimination({ }) // Organize matches by round -const matchesByRound = esportsBracket.reduce((acc, match) => { - const key = `${match.bracketType}-round-${match.round}` - if (!acc[key]) acc[key] = [] - acc[key].push(match) - return acc -}, {} as Record) +const matchesByRound = esportsBracket.reduce( + (acc, match) => { + const key = `${match.bracketType}-round-${match.round}` + if (!acc[key]) acc[key] = [] + acc[key].push(match) + return acc + }, + {} as Record, +) console.log('Tournament structure:') Object.keys(matchesByRound).forEach((key) => { @@ -140,7 +144,7 @@ const largeBracket = generateDoubleElimination({ }) console.log(`Total matches: ${largeBracket.length}`) -// Output: Total matches: 62 +// Output: Total matches: 60 (31 winners + 29 losers) ``` ## Large Tournament (64+ Participants) @@ -166,10 +170,10 @@ const stats = { console.log('Tournament Statistics:', stats) // Output: // { -// totalMatches: 126, +// totalMatches: 124, // winnersMatches: 63, -// losersMatches: 63, -// totalRounds: 6 +// losersMatches: 61, +// totalRounds: 9 // the losers bracket runs 9 rounds; the winners bracket 6 // } ``` @@ -227,7 +231,7 @@ const delayedLB = generateDoubleElimination({ // Round 1 losers are eliminated (no LB matches for them) const round1Losers = delayedLB.filter( - (m) => m.round === 1 && m.bracketType === 'winners' + (m) => m.round === 1 && m.bracketType === 'winners', ) console.log(`Round 1 matches: ${round1Losers.length}`) // Round 1 losers don't have corresponding LB matches @@ -237,7 +241,8 @@ console.log(`Round 1 matches: ${round1Losers.length}`) ### Tournament with 7 Participants -Automatic bye handling: +Byes are resolved at generation time, so nothing in the bracket waits on a +player who does not exist: ```typescript const oddCount = generateDoubleElimination({ @@ -246,13 +251,20 @@ const oddCount = generateDoubleElimination({ idFactory: () => crypto.randomUUID(), }) -// Find matches with byes (null registration) -const matchesWithByes = oddCount.filter( - (m) => m.registration1Id === null || m.registration2Id === null +// A bye is a first round match with exactly one participant. +const byes = oddCount.filter( + (m) => + m.bracketType === 'winners' && + m.round === 1 && + (m.registration1Id === null) !== (m.registration2Id === null), ) -console.log(`Matches with byes: ${matchesWithByes.length}`) -// Output: Matches with byes: 1 (seed 1 gets a bye in round 1) +console.log(`Byes: ${byes.length}`) +// Output: Byes: 1 (seed 1 gets a bye in round 1) + +// The bye winner is already placed in the next round, and the match produces +// no loser, so it does not hold a losers bracket slot. +console.log(byes[0].loserTo) // null ``` ### Tournament with 13 Participants @@ -264,13 +276,74 @@ const thirteenPlayers = generateDoubleElimination({ idFactory: () => crypto.randomUUID(), }) -// Count byes -const byeCount = thirteenPlayers.filter( - (m) => m.registration1Id === null || m.registration2Id === null -).length +const byes = thirteenPlayers.filter( + (m) => + m.bracketType === 'winners' && + m.round === 1 && + (m.registration1Id === null) !== (m.registration2Id === null), +) + +console.log(`Byes: ${byes.length}`) +// Output: Byes: 3 (seeds 1, 2, 3 get byes) +``` + +### Telling "waiting" apart from "never happening" + +With byes, some losers bracket matches cannot be reached at all. They stay in +the array so positions remain stable, but they carry no routing: + +```typescript +const fed = new Set( + matches.flatMap((m) => [ + m.winnerTo ? `${m.winnerTo}#${m.winnerToSlot}` : null, + m.loserTo ? `${m.loserTo}#${m.loserToSlot}` : null, + ]), +) + +const isUnused = (match: BracketMatch) => + match.registration1Id === null && + match.registration2Id === null && + !fed.has(`${match.id}#1`) && + !fed.has(`${match.id}#2`) -console.log(`Total byes: ${byeCount}`) -// Output: Total byes: 3 (seeds 1, 2, 3 get byes) +const playable = matches.filter((m) => !isUnused(m)) +``` + +## Grand Final + +### Standard Double Elimination + +```typescript +const matches = generateDoubleElimination({ + eventId: 'fighting-game-major', + participants: createParticipants(16), + idFactory: () => crypto.randomUUID(), + grandFinal: 'reset', +}) + +const [grandFinal, reset] = matches + .filter((m) => m.bracketType === 'grandFinal') + .sort((a, b) => a.round - b.round) + +// Slot 1 is the winners bracket representative, slot 2 the losers bracket one. +console.log(grandFinal.round) // 1 +console.log(reset.round) // 2 +``` + +### Deciding Whether the Reset Is Played + +```typescript +const reportGrandFinal = (winnerId: string) => { + const fromLosersBracket = winnerId === grandFinal.registration2Id + + if (!fromLosersBracket) { + // The winners bracket representative is unbeaten: the reset is not played. + return { champion: winnerId, resetRequired: false } + } + + // Both finalists now have one loss each, so they play again. + return { champion: null, resetRequired: true } +} ``` ## Integration with Database @@ -305,7 +378,7 @@ async function createTournament(eventId: string, participants: Participant[]) { bracketType: match.bracketType, status: 'pending', createdAt: new Date(), - })) + })), ) return matches @@ -399,11 +472,12 @@ function getMatchDependencies(matches: BracketMatch[], matchId: string) { } } -// Example: Find what matches feed into the winners bracket finals -const finalsMatch = matches.find( - (m) => - m.bracketType === 'winners' && - m.round === Math.max(...matches.map((m) => m.round)) +// Example: Find what matches feed into the winners bracket finals. +// Round numbers restart per bracket, so compare within the winners bracket: +// the losers bracket runs more rounds than the winners bracket. +const winners = matches.filter((m) => m.bracketType === 'winners') +const finalsMatch = winners.find( + (m) => m.round === Math.max(...winners.map((w) => w.round)), ) if (finalsMatch) { const deps = getMatchDependencies(matches, finalsMatch.id) @@ -487,14 +561,14 @@ const matches = generateDoubleElimination({ ```typescript function generateMultipleTournaments( - events: Array<{ eventId: string; participants: Participant[] }> + events: Array<{ eventId: string; participants: Participant[] }>, ) { return events.map((event) => generateDoubleElimination({ eventId: event.eventId, participants: event.participants, idFactory: () => crypto.randomUUID(), - }) + }), ) } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..777203c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Nader Safa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index dbb7371..6db0350 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ A TypeScript library for generating double elimination tournament brackets with - [Match Routing](#match-routing) - [Bye Handling](#bye-handling) - [Delayed Losers Bracket and Single Elimination](#delayed-losers-bracket-and-single-elimination) +- [Grand Final](#grand-final) - [Seeding](#seeding) - [Example Output](#example-output) - [Performance](#performance) @@ -37,7 +38,9 @@ Double elimination tournaments are the gold standard for competitive events beca - **Increase engagement**: More matches mean more content and viewer engagement - **Reduce early elimination**: Strong players who face tough early matchups get a second chance -This library implements the standard double elimination format used in major esports tournaments, fighting game competitions, and sports events worldwide. +This library implements the standard double elimination format used in major +esports tournaments, fighting game competitions, and sports events worldwide — +including the grand final and bracket reset when you want them. ## When to Use This Package @@ -60,12 +63,13 @@ Perfect for developers building: ## Features -- ✅ **Complete bracket generation** - Winners and losers bracket structures +- ✅ **Complete bracket generation** - Winners bracket, losers bracket, and optional grand final - ✅ **Standard tournament seeding** - Ensures seeds 1 and 2 can only meet in finals, seeds 1-4 can only meet in semifinals, etc. -- ✅ **Automatic bye handling** - Handles participant counts that aren't powers of 2 -- ✅ **Flexible tournament formats** - Supports double elimination, single elimination, and delayed losers bracket -- ✅ **Rematch prevention** - Intelligent routing prevents early rematches between players -- ✅ **TypeScript support** - Full type definitions included +- ✅ **Automatic bye handling** - Byes are resolved through the whole bracket, so odd participant counts still run to completion +- ✅ **Flexible tournament formats** - Double elimination with or without a grand final and bracket reset, single elimination, and delayed losers bracket +- ✅ **Rematch prevention** - Rotating loser routing keeps players away from opponents they already beat +- ✅ **Validated input** - Duplicate seeds, duplicate ids, and repeated match ids throw instead of corrupting the bracket +- ✅ **TypeScript support** - Full type definitions, ESM and CommonJS builds - ✅ **Zero dependencies** - Lightweight and fast - ✅ **Configurable ID generation** - Use any ID factory function @@ -75,6 +79,9 @@ Perfect for developers building: npm install double-elimination ``` +Ships both ES module and CommonJS builds with TypeScript types, so `import` and +`require` both work on Node 18+ and in any bundler. + ## Quick Start ```typescript @@ -100,12 +107,13 @@ Generates all matches for a double elimination bracket. #### Options -| Property | Type | Description | -| ------------------------------ | --------------- | --------------------------------------------------------- | -| `eventId` | `string` | Identifier for the tournament event | -| `participants` | `Participant[]` | Array of participants with seeds | -| `idFactory` | `() => string` | Function that returns unique IDs for matches | -| `losersStartRoundsBeforeFinal` | `number?` | Rounds before finals where LB begins (min: 0). See below. | +| Property | Type | Description | +| ------------------------------ | ------------------------------- | -------------------------------------------------------------- | +| `eventId` | `string` | Identifier for the tournament event | +| `participants` | `Participant[]` | Array of participants with seeds | +| `idFactory` | `() => string` | Function that returns unique IDs for matches | +| `losersStartRoundsBeforeFinal` | `number?` | Rounds before finals where LB begins (min: 0). See below. | +| `grandFinal` | `'none' \| 'single' \| 'reset'` | Whether to add a grand final. Defaults to `'none'`. See below. | #### Returns @@ -131,75 +139,137 @@ interface BracketMatch { winnerToSlot: number | null loserTo: string | null loserToSlot: number | null - bracketType: 'winners' | 'losers' + bracketType: 'winners' | 'losers' | 'grandFinal' } ``` +Seeds must be unique but need not be `1..N` — participants are ranked by seed, +so `[10, 20, 30]` seeds identically to `[1, 2, 3]`. Duplicate seeds, duplicate +`registrationId`s and a non-unique `idFactory` throw instead of silently +producing a broken bracket. + ## Bracket Structure ### Placements -This library uses a simplified double elimination format without grand finals: +By default there is no grand final: the winners final decides 1st/2nd and the +losers final decides 3rd/4th. | Match | Winner | Loser | | ---------------------- | --------- | --------- | | Winners Bracket Finals | 1st Place | 2nd Place | | Losers Bracket Finals | 3rd Place | 4th Place | +With `grandFinal: 'single'` or `'reset'` the bracket runs as a standard double +elimination instead — the winners final loser drops into the losers final, and +the two bracket winners meet: + +| Match | Winner | Loser | +| ---------------------- | ------------- | --------------- | +| Winners Bracket Finals | → Grand Final | → Losers Finals | +| Losers Bracket Finals | → Grand Final | 3rd Place | +| Grand Final | 1st Place | 2nd Place | + ### Match Counts For `N` participants: - Bracket size = next power of 2 ≥ N - Winners rounds = log₂(bracket_size) -- Losers rounds = (winners_rounds - 1) × 2 - 1 +- Losers rounds = (winners_rounds - 1) × 2 - 1, plus one more with a grand final + +| Participants | Bracket Size | Winners | Losers | Total | Total with `grandFinal: 'single'` | +| ------------ | ------------ | ------- | ------ | ----- | --------------------------------- | +| 4 | 4 | 3 | 1 | 4 | 6 | +| 5-8 | 8 | 7 | 5 | 12 | 14 | +| 9-16 | 16 | 15 | 13 | 28 | 30 | -| Participants | Bracket Size | Winners Matches | Losers Matches | Total | -| ------------ | ------------ | --------------- | -------------- | ----- | -| 4 | 4 | 3 | 1 | 4 | -| 5-8 | 8 | 7 | 5 | 12 | -| 9-16 | 16 | 15 | 13 | 28 | +Matches are returned winners bracket first, then losers bracket, then grand +final, each ordered by round and then by `bracketPosition`. ## Match Routing ### Winners Bracket - **Winner routing**: Position `P` → next round, position `⌊P/2⌋`, slot `(P % 2) + 1` -- **Loser routing**: Drops to losers bracket (except finals loser who gets 2nd place) +- **Loser routing**: Drops to the losers bracket. Without a grand final the + winners final loser is 2nd place; with one they drop to the losers final. A + walkover has no loser, so its `loserTo` is `null`. ### Losers Bracket -- Receives losers from winners bracket rounds 1 and 2 -- Winners from losers bracket finals get 3rd place, loser gets 4th +- Round 1 pairs off the first wave of winners bracket losers +- Even rounds receive a fresh wave of winners bracket losers into slot 2, so + they hold as many matches as the round before them +- Odd rounds after round 1 are played between losers bracket survivors only, + halving the match count +- Without a grand final, the losers final winner is 3rd and the loser is 4th ### Cross-Bracket Matchups -To prevent early rematches, losers from the winners bracket are routed using a specific pattern: +A player dropping into the losers bracket must not immediately run into someone +they already beat, so each wave of winners bracket losers is reordered before it +is dropped in. The ordering rotates from one wave to the next: -- **Round 1 losers**: Grouped by position pairs (floor(position/2)) -- **Round 2 losers**: **REVERSED positions** - players from top half face bottom half LB winners, and vice versa - - WB R2 pos 0 (top half) → LB R2 pos N-1 (faces bottom half LB R1 winner) - - WB R2 pos N-1 (bottom half) → LB R2 pos 0 (faces top half LB R1 winner) -- **Round 3+ losers**: Use **SAME positions** (not mirrored) to continue the crossover pattern +| Losers entering | Ordering applied | +| ----------------------------- | --------------------------------- | +| 1st wave (first feeder round) | Paired up (`⌊position / 2⌋`) | +| 2nd wave | Reversed | +| 3rd wave | Reversed and shifted by half | +| 4th wave | Shifted by half | +| 5th wave | Unchanged, then the cycle repeats | -This ensures players from opposite sides of the winners bracket meet in the losers bracket, preventing rematches until later rounds. +Rotating the ordering matters most in large brackets. Measured over 200 random +64-player tournaments, rotating pushes the first possible rematch from losers +round 4 out to losers round 7, and cuts rematches from ~2.8 per tournament to +~0.4. A rematch in the last losers rounds is unavoidable in any double +elimination bracket. ## Bye Handling -When participant count isn't a power of 2, byes are automatically created and processed: +When the participant count isn't a power of 2, byes are created and resolved at +generation time — through the whole bracket, not just the first round: ```typescript -// 7 participants in bracket of 8 = 1 bye +// 7 participants in a bracket of 8 = 1 bye const matches = generateDoubleElimination({ eventId: 'event-1', participants: createParticipants(7), // Seeds 1-7 idFactory: () => crypto.randomUUID(), }) -// Seed 1 vs Seed 8 (missing) = Seed 1 auto-advances +// Seed 1 vs Seed 8 (missing) = Seed 1 is already placed in round 2 ``` -Byes are pre-resolved at generation time - the advancing player is already placed in the next round. +Three things follow from a bye, and all of them are handled for you: + +- **The advancing player is pre-placed.** Their slot in the next match is filled + before the bracket is returned. +- **A walkover produces no loser.** The match's `loserTo` is `null`, so nothing + waits on a loser that never arrives. +- **Losers bracket matches that would only ever get one player are skipped.** + The feeding match is re-pointed at whatever came after them, so the losers + bracket always runs to completion. + +A match that nobody can reach stays in the returned array with empty slots and +no routing (`winnerTo`, `loserTo`, and both slots are `null`), which keeps +round and position numbering stable for rendering. To tell "waiting for an +opponent" apart from "nobody is coming", check whether anything feeds the slot: + +```typescript +const fed = new Set( + matches.flatMap((m) => [ + m.winnerTo ? `${m.winnerTo}#${m.winnerToSlot}` : null, + m.loserTo ? `${m.loserTo}#${m.loserToSlot}` : null, + ]), +) + +const isUnused = (match: BracketMatch) => + match.registration1Id === null && + match.registration2Id === null && + !fed.has(`${match.id}#1`) && + !fed.has(`${match.id}#2`) +``` ## Delayed Losers Bracket and Single Elimination @@ -255,10 +325,63 @@ For 16 participants (4 WB rounds) with `losersStartRoundsBeforeFinal: 2`: ### Constraints - **Minimum value: 0** - Pure single elimination (no losers bracket) -- **Value: 1** - Single elimination with optional 3rd place match (requires at least 4 participants) +- **Value: 1** - Single elimination with optional 3rd place match (requires at least 3 participants) - **Value: 2+** - Delayed double elimination - **Maximum value: winnersRounds - 1** - Cannot exceed available feeder rounds +## Grand Final + +By default the winners final decides 1st and 2nd place. Pass `grandFinal` to run +a standard double elimination instead, where the winners final loser drops to +the losers final and the two bracket winners meet: + +```typescript +// One grand final match +const matches = generateDoubleElimination({ + eventId: 'event-1', + participants: createParticipants(8), + idFactory: () => crypto.randomUUID(), + grandFinal: 'single', +}) + +// Grand final plus bracket reset +const withReset = generateDoubleElimination({ + eventId: 'event-1', + participants: createParticipants(8), + idFactory: () => crypto.randomUUID(), + grandFinal: 'reset', +}) +``` + +Enabling it adds one losers bracket round (the losers final, where the winners +final loser enters) and the grand final itself, which is returned with +`bracketType: 'grandFinal'`. + +### Bracket Reset + +With `grandFinal: 'reset'` the grand final is two matches, `round: 1` and +`round: 2`. Match 2 is **only played when the losers bracket representative wins +match 1** — otherwise the winners bracket representative is champion with an +unbeaten record and match 2 is dropped. + +Both finalists are routed into the reset match (`winnerTo` into slot 1, +`loserTo` into slot 2) so the usual propagation works unchanged. Your code +decides whether the match happens: + +```typescript +const [grandFinal, reset] = matches + .filter((m) => m.bracketType === 'grandFinal') + .sort((a, b) => a.round - b.round) + +// slot 2 of the grand final is the losers bracket representative +const resetRequired = grandFinalWinnerId === grandFinal.registration2Id +``` + +### Constraints + +`grandFinal` requires a losers bracket, so it cannot be combined with +`losersStartRoundsBeforeFinal: 0`, and needs at least 3 participants. + ## Seeding The library uses standard tournament seeding to ensure fair bracket placement: @@ -275,6 +398,10 @@ For 32 participants: - Seed 2 is in matches 8-15 (bottom half) - Seeds 3-4 are in opposite quarters from seeds 1-2 +Participants are ranked by seed before placement, so the seed values only need +to be unique and correctly ordered — `[10, 20, 30]` and `[1, 2, 3]` produce the +same bracket. Byes go to the strongest seeds. + ## Use Cases ### Esports Tournament Platform @@ -320,21 +447,31 @@ Round 2: [LB R1 winners + WB R2 losers] Round 3: [Finals] → winner=3rd, loser=4th ``` -## Performance +With `grandFinal: 'single'`: -The library is optimized for performance: +``` +WINNERS BRACKET: +Round 3: [Finals] → winner to Grand Final, loser drops to LB R4 -- **Fast generation**: Brackets are generated in O(n) time where n is the number of participants -- **Memory efficient**: Minimal memory footprint with no external dependencies -- **Scalable**: Handles tournaments from 4 to 1000+ participants efficiently -- **Zero runtime dependencies**: No external packages required at runtime +LOSERS BRACKET: +Round 4: [Finals] → winner to Grand Final, loser=3rd + +GRAND FINAL: +Round 1: [WB winner vs LB winner] → winner=1st, loser=2nd +``` + +Run `npm run demo -- [none|single|reset]` to print any bracket. + +## Performance -Benchmark results (typical): +Generation is O(n) in the number of participants, with no runtime dependencies. +Measured on Node 22 (average of 20 runs): -- 8 participants: < 1ms -- 32 participants: < 2ms -- 128 participants: < 5ms -- 512 participants: < 15ms +| Participants | Time per bracket | +| ------------ | ---------------- | +| 128 | 0.7 ms | +| 1024 | 3.1 ms | +| 4096 | 14.3 ms | ## Contributing diff --git a/demo.ts b/demo.ts index f2c953d..35a6931 100644 --- a/demo.ts +++ b/demo.ts @@ -1,75 +1,84 @@ -import { generateDoubleElimination, Participant } from './src' +import { + generateDoubleElimination, + type GrandFinalFormat, +} from './src/index.js' -// Helper to create participants -const createParticipants = (count: number): Participant[] => +const createParticipants = (count: number) => Array.from({ length: count }, (_, i) => ({ registrationId: `player-${i + 1}`, seed: i + 1, })) -// Simple ID factory let idCounter = 0 const idFactory = () => `m${++idCounter}` -// Get participant count from CLI arg or default to 7 -const count = parseInt(process.argv[2] || '7', 10) +// Usage: npm run demo -- [participants] [grandFinal: none|single|reset] +const count = Number.parseInt(process.argv[2] || '7', 10) +const grandFinal = (process.argv[3] as GrandFinalFormat) || 'none' + const participants = createParticipants(count) const matches = generateDoubleElimination({ eventId: 'event-1', participants, idFactory, + grandFinal, }) -console.log(matches) - -// Display results -const winners = matches.filter((m) => m.bracketType === 'winners') -const losers = matches.filter((m) => m.bracketType === 'losers') +const label = (id: string | null) => id ?? '—' -const formatSlot = (id: string | null, round: number) => { - if (id) return id - return round === 1 ? 'BYE' : 'TBD' +// A slot nobody feeds and nobody occupies can never be filled. +const fedSlots = new Set() +for (const m of matches) { + if (m.winnerTo) fedSlots.add(`${m.winnerTo}#${m.winnerToSlot}`) + if (m.loserTo) fedSlots.add(`${m.loserTo}#${m.loserToSlot}`) } +const canFill = (id: string, slot: 1 | 2, occupant: string | null) => + occupant !== null || fedSlots.has(`${id}#${slot}`) -console.log('=== WINNERS BRACKET ===') -for (let r = 1; r <= Math.max(...winners.map((m) => m.round)); r++) { - console.log(`\nRound ${r}:`) - winners - .filter((m) => m.round === r) - .sort((a, b) => a.bracketPosition - b.bracketPosition) - .forEach((m) => { - const p1 = formatSlot(m.registration1Id, r) - const p2 = formatSlot(m.registration2Id, r) - console.log( - ` [pos ${m.bracketPosition}] ${m.id}: ${p1} vs ${p2} → W:${ - m.winnerTo ?? 'GF' - }[${m.winnerToSlot}] L:${m.loserTo ?? 'GF'}[${m.loserToSlot}]` - ) - }) -} +const printBracket = ( + type: 'winners' | 'losers' | 'grandFinal', + title: string +) => { + const bracket = matches.filter((m) => m.bracketType === type) + if (bracket.length === 0) return -console.log('\n=== LOSERS BRACKET ===') -for (let r = 1; r <= Math.max(...losers.map((m) => m.round)); r++) { - console.log(`\nRound ${r}:`) - losers - .filter((m) => m.round === r) - .sort((a, b) => a.bracketPosition - b.bracketPosition) - .forEach((m) => { - const p1 = m.registration1Id ?? 'TBD' - const p2 = m.registration2Id ?? 'TBD' - console.log( - ` [pos ${m.bracketPosition}] ${m.id}: ${p1} vs ${p2} → W:${ - m.winnerTo ?? 'GF' - }[${m.winnerToSlot}]` - ) - }) + console.log(`\n=== ${title} ===`) + const lastRound = Math.max(...bracket.map((m) => m.round)) + + for (let round = 1; round <= lastRound; round++) { + console.log(`\nRound ${round}:`) + bracket + .filter((m) => m.round === round) + .sort((a, b) => a.bracketPosition - b.bracketPosition) + .forEach((m) => { + const slot1 = canFill(m.id, 1, m.registration1Id) + const slot2 = canFill(m.id, 2, m.registration2Id) + const note = + !slot1 && !slot2 + ? ' (unused: byes)' + : slot1 && slot2 + ? '' + : ' (walkover)' + console.log( + ` [${m.bracketPosition}] ${m.id}: ${label(m.registration1Id)} vs ${label( + m.registration2Id + )}${note}` + + ` → W:${m.winnerTo ?? 'done'}${m.winnerToSlot ? `[${m.winnerToSlot}]` : ''}` + + ` L:${m.loserTo ?? 'out'}${m.loserToSlot ? `[${m.loserToSlot}]` : ''}` + ) + }) + } } +printBracket('winners', 'WINNERS BRACKET') +printBracket('losers', 'LOSERS BRACKET') +printBracket('grandFinal', 'GRAND FINAL') + console.log('\n=== SUMMARY ===') -console.log(`Participants: ${participants.length}`) -console.log( - `Bracket size: ${Math.pow(2, Math.ceil(Math.log2(participants.length)))}` -) -console.log(`Winners matches: ${winners.length}`) -console.log(`Losers matches: ${losers.length}`) -console.log(`Total matches: ${matches.length}`) +console.log(`Participants: ${participants.length}`) +console.log(`Grand final: ${grandFinal}`) +for (const type of ['winners', 'losers', 'grandFinal'] as const) { + const count_ = matches.filter((m) => m.bracketType === type).length + if (count_ > 0) console.log(`${type.padEnd(16)} ${count_} matches`) +} +console.log(`Total matches: ${matches.length}`) diff --git a/package-lock.json b/package-lock.json index fe9e105..24524f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "double-elimination", - "version": "1.2.2", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "double-elimination", - "version": "1.2.2", + "version": "1.3.0", "license": "MIT", "devDependencies": { "@types/node": "^20.0.0", diff --git a/package.json b/package.json index 0b6c64f..32b8756 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,23 @@ { "name": "double-elimination", - "version": "1.2.2", + "version": "1.3.0", "description": "Generate double elimination tournament brackets with automatic seeding and bye handling", - "main": "dist/index.js", - "module": "dist/index.js", - "types": "dist/index.d.ts", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", "files": [ - "dist" + "dist", + "src" ], "scripts": { - "build": "tsc", - "prepublishOnly": "npm run build", + "build": "node scripts/build.mjs", + "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", + "prepublishOnly": "npm run build && npm test && npm run test:package", "test": "vitest run", "test:watch": "vitest", + "test:package": "node scripts/verify-package.mjs", + "typecheck": "tsc -p tsconfig.typecheck.json", + "demo": "tsx demo.ts", "website:dev": "cd website && npm run dev", "website:build": "cd website && npm run build", "website:install": "cd website && npm install" @@ -54,5 +59,20 @@ }, "engines": { "node": ">=18" - } + }, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false } diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..4484c75 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,29 @@ +// Builds the package as both ESM and CommonJS. +// +// Node decides the module format of a .js file from the nearest package.json, +// so the CommonJS output gets its own `{"type": "commonjs"}` marker while the +// root package.json declares the package as ESM. +import { execFileSync } from 'node:child_process' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const tsc = process.platform === 'win32' ? 'tsc.cmd' : 'tsc' + +rmSync(join(root, 'dist'), { recursive: true, force: true }) + +for (const project of ['tsconfig.json', 'tsconfig.cjs.json']) { + execFileSync(join(root, 'node_modules', '.bin', tsc), ['-p', project], { + cwd: root, + stdio: 'inherit', + }) +} + +mkdirSync(join(root, 'dist', 'cjs'), { recursive: true }) +writeFileSync( + join(root, 'dist', 'cjs', 'package.json'), + `${JSON.stringify({ type: 'commonjs' }, null, 2)}\n` +) + +console.log('built dist/esm and dist/cjs') diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..19d02c0 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,26 @@ +// Loads the built package the way consumers do, so a broken publish fails CI +// instead of failing in someone else's project. +import { createRequire } from 'node:module' +import assert from 'node:assert/strict' + +const require = createRequire(import.meta.url) + +const participants = [ + { registrationId: 'a', seed: 1 }, + { registrationId: 'b', seed: 2 }, + { registrationId: 'c', seed: 3 }, + { registrationId: 'd', seed: 4 }, +] + +let ids = 0 +const options = { eventId: 'e', participants, idFactory: () => `m${++ids}` } + +const esm = await import('../dist/esm/index.js') +assert.equal(typeof esm.generateDoubleElimination, 'function') +assert.equal(esm.generateDoubleElimination(options).length, 4) + +const cjs = require('../dist/cjs/index.js') +assert.equal(typeof cjs.generateDoubleElimination, 'function') +assert.equal(cjs.generateDoubleElimination(options).length, 4) + +console.log('package loads via import and require') diff --git a/src/bracketUtils.ts b/src/bracketUtils.ts index 72fb215..d5ce6c9 100644 --- a/src/bracketUtils.ts +++ b/src/bracketUtils.ts @@ -1,71 +1,52 @@ -/** Compute smallest power of 2 >= n */ +/** + * Smallest power of two >= n. + * + * Computed with integer doubling rather than `Math.pow(2, Math.ceil(Math.log2(n)))`, + * which can round the wrong way for large inputs (e.g. `Math.log2(2 ** 29)` is + * not guaranteed to be exactly 29 on every engine). + */ export const nextPowerOf2 = (n: number): number => { - return Math.pow(2, Math.ceil(Math.log2(n))) + let size = 1 + while (size < n) size *= 2 + return size } -/** Generate seeding pairs for first round: 1v8, 4v5, 2v7, 3v6 pattern */ -export const generateSeedingPairs = ( - bracketSize: number -): [number, number][] => { - const pairs: [number, number][] = [] - const half = bracketSize / 2 - - const buildPairs = (seeds: number[]): void => { - if (seeds.length === 2) { - pairs.push([seeds[0], seeds[1]]) - return - } - const top: number[] = [] - const bottom: number[] = [] - for (let i = 0; i < seeds.length / 2; i++) { - top.push(seeds[i]) - bottom.push(seeds[seeds.length - 1 - i]) - } - buildPairs(top.map((t, i) => [t, bottom[i]]).flat()) - buildPairs(bottom.map((b, i) => [top[top.length - 1 - i], b]).flat()) +/** log2 of an exact power of two, without floating point. */ +export const log2 = (powerOfTwo: number): number => { + let exponent = 0 + let value = powerOfTwo + while (value > 1) { + value /= 2 + exponent++ } - - // Start with seeds 1..bracketSize - const initialSeeds = Array.from({ length: bracketSize }, (_, i) => i + 1) - buildPairs( - initialSeeds.slice(0, half).concat(initialSeeds.slice(half).reverse()) - ) - - return pairs.slice(0, half) + return exponent } -/** Standard seeding: 1vN, 2v(N-1), etc. reordered for bracket structure */ -export const getStandardSeedingOrder = ( - bracketSize: number -): [number, number][] => { - const matchCount = bracketSize / 2 - const pairs: [number, number][] = [] +/** + * Seed pairings for round 1, in bracket order. + * + * Builds the standard "fold" sequence — `[1, 2] -> [1, 4, 2, 3] -> [1, 8, 4, 5, + * 2, 7, 3, 6] -> ...` — so that the top two seeds can only meet in the final, + * the top four only in the semifinals, and so on. + */ +export const generateSeedPairs = (bracketSize: number): [number, number][] => { + let positions = [1, 2] + + while (positions.length < bracketSize) { + const sum = positions.length * 2 + 1 + const next: number[] = new Array(positions.length * 2) + + for (let i = 0; i < positions.length; i++) { + next[i * 2] = positions[i] + next[i * 2 + 1] = sum - positions[i] + } - const fillBracket = (pos: number, round: number): number => { - if (round === 1) return pos - const prevPos = pos * 2 - return fillBracket(prevPos, round - 1) + positions = next } - const rounds = Math.log2(bracketSize) - for (let m = 0; m < matchCount; m++) { - const seed1 = m + 1 - const seed2 = bracketSize - m - pairs.push([seed1, seed2]) + const pairs: [number, number][] = new Array(bracketSize / 2) + for (let i = 0; i < positions.length; i += 2) { + pairs[i / 2] = [positions[i], positions[i + 1]] } - - // Reorder for proper bracket placement - const ordered: [number, number][] = [] - const placeMatch = (start: number, end: number, depth: number): void => { - if (depth === 0) { - ordered.push(pairs[start]) - return - } - const mid = Math.floor((start + end) / 2) - placeMatch(start, mid, depth - 1) - placeMatch(mid + 1, end, depth - 1) - } - - placeMatch(0, matchCount - 1, rounds - 1) - return ordered.length > 0 ? ordered : pairs + return pairs } diff --git a/src/createGrandFinal.ts b/src/createGrandFinal.ts new file mode 100644 index 0000000..111e014 --- /dev/null +++ b/src/createGrandFinal.ts @@ -0,0 +1,47 @@ +import { BracketMatch, GrandFinalFormat, IdFactory } from './types.js' + +/** + * Creates the grand final (and, for `'reset'`, the bracket reset match). + * + * The reset is wired so that generic winner/loser propagation keeps working: + * both players of grand final 1 carry over into grand final 2. It is only + * *played* when the losers bracket representative wins grand final 1 — if the + * winners bracket representative wins it, they are champion and match 2 is + * dropped. + */ +export const createGrandFinal = ( + eventId: string, + format: GrandFinalFormat, + idFactory: IdFactory +): BracketMatch[] => { + if (format === 'none') return [] + + const rounds = format === 'reset' ? 2 : 1 + const matches: BracketMatch[] = [] + + for (let round = 1; round <= rounds; round++) { + matches.push({ + id: idFactory(), + eventId, + round, + matchNumber: 1, + registration1Id: null, + registration2Id: null, + bracketPosition: 0, + winnerTo: null, + winnerToSlot: null, + loserTo: null, + loserToSlot: null, + bracketType: 'grandFinal', + }) + } + + if (matches.length === 2) { + matches[0].winnerTo = matches[1].id + matches[0].winnerToSlot = 1 + matches[0].loserTo = matches[1].id + matches[0].loserToSlot = 2 + } + + return matches +} diff --git a/src/createLosersBracket.ts b/src/createLosersBracket.ts index 5f4ed9a..ad06208 100644 --- a/src/createLosersBracket.ts +++ b/src/createLosersBracket.ts @@ -1,26 +1,31 @@ -import { BracketMatch, IdFactory } from './types' +import { BracketMatch, IdFactory } from './types.js' +/** + * Builds the losers bracket skeleton and wires winner advancement inside it. + * + * Round 1 pairs off the first wave of winners bracket losers. After that the + * rounds alternate: even rounds take a fresh wave of losers into slot 2 and so + * hold as many matches as the round before them, while odd rounds are played + * between losers bracket survivors only and halve the match count. + */ export const createLosersBracket = ( eventId: string, bracketSize: number, rounds: number, startFromWbRound: number, - idFactory: IdFactory, - totalWbRounds: number + idFactory: IdFactory ): BracketMatch[] => { const matches: BracketMatch[] = [] const matchIdMap = new Map() - // Calculate matches per round - // Odd rounds (crossover): receive fresh losers, fewer matches - // Even rounds (consolidation): no new entries + // Losers from winners rounds before startFromWbRound never enter the losers + // bracket, so it is sized as if the tournament started at that round. + const effectiveBracketSize = bracketSize / Math.pow(2, startFromWbRound - 1) + for (let round = 1; round <= rounds; round++) { - const matchCount = getLosersMatchCount( - bracketSize, - round, - startFromWbRound, - totalWbRounds - ) + // R1,R2 hold effectiveBracketSize/4 matches, R3,R4 half that, and so on. + const matchCount = + effectiveBracketSize / Math.pow(2, Math.ceil(round / 2) + 1) for (let pos = 0; pos < matchCount; pos++) { const matchId = idFactory() @@ -43,34 +48,11 @@ export const createLosersBracket = ( } } - // Wire winner routing within losers bracket wireLosersBracketWinners(matches, matchIdMap, rounds) return matches } -const getLosersMatchCount = ( - bracketSize: number, - round: number, - startFromWbRound: number, - totalWbRounds: number -): number => { - // Special case: losersStartRoundsBeforeFinal=1 means only semifinal losers - // This creates a single 3rd place match (round 1 only) - if (startFromWbRound === totalWbRounds - 1) { - return round === 1 ? 1 : 0 - } - - // For delayed losers bracket, calculate effective bracket size - // based on which WB round starts feeding losers - const effectiveBracketSize = bracketSize / Math.pow(2, startFromWbRound - 1) - - // Pattern: pairs of rounds with same match count, then halves - // R1,R2: effectiveBracketSize/4, R3,R4: effectiveBracketSize/8... - // Formula: effectiveBracketSize / 2^(ceil(round/2) + 1) - return effectiveBracketSize / Math.pow(2, Math.ceil(round / 2) + 1) -} - const wireLosersBracketWinners = ( matches: BracketMatch[], idMap: Map, @@ -79,21 +61,18 @@ const wireLosersBracketWinners = ( for (const match of matches) { if (match.round >= totalRounds) continue - const isOddRound = match.round % 2 === 1 - let nextPos: number - let nextSlot: number + // The next round is even — it takes a fresh wave of winners bracket losers + // — exactly when this round is odd. Those rounds keep the match count, so + // the position carries over and slot 2 is left free for the incoming + // loser. Advancing into an odd round halves the match count instead. + const nextRoundTakesFreshLosers = match.round % 2 === 1 - if (isOddRound) { - // From Crossover Round (odd) → Consolidation Round (even) - // Next round has same match count, so same position, slot 1 - nextPos = match.bracketPosition - nextSlot = 1 - } else { - // From Consolidation Round (even) → Crossover Round (odd) - // Next round has half the matches, so halve position, alternating slots - nextPos = Math.floor(match.bracketPosition / 2) - nextSlot = (match.bracketPosition % 2) + 1 - } + const nextPos = nextRoundTakesFreshLosers + ? match.bracketPosition + : Math.floor(match.bracketPosition / 2) + const nextSlot = nextRoundTakesFreshLosers + ? 1 + : (match.bracketPosition % 2) + 1 const nextMatchId = idMap.get(`${match.round + 1}-${nextPos}`) if (nextMatchId) { diff --git a/src/createWinnersBracket.ts b/src/createWinnersBracket.ts index 0b88332..bcacb16 100644 --- a/src/createWinnersBracket.ts +++ b/src/createWinnersBracket.ts @@ -1,21 +1,21 @@ -import { BracketMatch, IdFactory } from './types'; +import { BracketMatch, IdFactory } from './types.js' +/** Builds the winners bracket skeleton and wires winner advancement. */ export const createWinnersBracket = ( eventId: string, bracketSize: number, rounds: number, idFactory: IdFactory ): BracketMatch[] => { - const matches: BracketMatch[] = []; - const matchIdMap = new Map(); // key: "round-position" -> matchId + const matches: BracketMatch[] = [] + const matchIdMap = new Map() - // Create matches for each round for (let round = 1; round <= rounds; round++) { - const matchCount = bracketSize / Math.pow(2, round); + const matchCount = bracketSize / Math.pow(2, round) for (let pos = 0; pos < matchCount; pos++) { - const matchId = idFactory(); - matchIdMap.set(`${round}-${pos}`, matchId); + const matchId = idFactory() + matchIdMap.set(`${round}-${pos}`, matchId) matches.push({ id: matchId, @@ -30,24 +30,22 @@ export const createWinnersBracket = ( loserTo: null, loserToSlot: null, bracketType: 'winners', - }); + }) } } - // Wire winner routing within winners bracket for (const match of matches) { - if (match.round < rounds) { - const nextPos = Math.floor(match.bracketPosition / 2); - const nextSlot = (match.bracketPosition % 2) + 1; - const nextMatchId = matchIdMap.get(`${match.round + 1}-${nextPos}`); - - if (nextMatchId) { - match.winnerTo = nextMatchId; - match.winnerToSlot = nextSlot; - } + if (match.round >= rounds) continue + + // Two adjacent matches feed the one above them, top match into slot 1. + const nextMatchId = matchIdMap.get( + `${match.round + 1}-${Math.floor(match.bracketPosition / 2)}` + ) + if (nextMatchId) { + match.winnerTo = nextMatchId + match.winnerToSlot = (match.bracketPosition % 2) + 1 } } - return matches; -}; - + return matches +} diff --git a/src/generateDoubleElimination.ts b/src/generateDoubleElimination.ts index 8befece..fbef235 100644 --- a/src/generateDoubleElimination.ts +++ b/src/generateDoubleElimination.ts @@ -1,77 +1,33 @@ -import { BracketMatch, GeneratorOptions, Participant } from './types' -import { nextPowerOf2 } from './bracketUtils' -import { createWinnersBracket } from './createWinnersBracket' -import { createLosersBracket } from './createLosersBracket' -import { wireLoserRouting } from './wireLoserRouting' -import { processByes } from './processByes' +import { BracketMatch, GeneratorOptions, Participant } from './types.js' +import { generateSeedPairs } from './bracketUtils.js' +import { planBracket } from './planBracket.js' +import { createWinnersBracket } from './createWinnersBracket.js' +import { createLosersBracket } from './createLosersBracket.js' +import { createGrandFinal } from './createGrandFinal.js' +import { wireLoserRouting } from './wireLoserRouting.js' +import { resolveByes } from './resolveByes.js' +/** + * Generates every match of a tournament bracket, already wired together. + * + * Matches are returned winners bracket first, then losers bracket, then grand + * final, each ordered by round and then by position. + */ export const generateDoubleElimination = ( options: GeneratorOptions ): BracketMatch[] => { const { eventId, participants, - idFactory, - losersStartRoundsBeforeFinal, - } = options - - if (participants.length < 2) { - throw new Error('At least 2 participants required') - } - - const bracketSize = nextPowerOf2(participants.length) - const winnersRounds = Math.log2(bracketSize) - - // Validate losersStartRoundsBeforeFinal - if (losersStartRoundsBeforeFinal !== undefined) { - if (losersStartRoundsBeforeFinal < 0) { - throw new Error( - 'losersStartRoundsBeforeFinal must be at least 0 (0 = pure single elimination)' - ) - } - // Special case: losersStartRoundsBeforeFinal=1 requires at least 4 participants (semifinals) - // Check this before the >= winnersRounds check - if (losersStartRoundsBeforeFinal === 1 && winnersRounds < 2) { - throw new Error( - 'losersStartRoundsBeforeFinal=1 requires at least 4 participants (semifinals needed)' - ) - } - if (losersStartRoundsBeforeFinal >= winnersRounds) { - throw new Error( - `losersStartRoundsBeforeFinal must be less than winnersRounds (${winnersRounds})` - ) - } - } - - // Calculate which WB round starts feeding into LB - // Default: round 1 (full double elimination) - const startFromWbRound = losersStartRoundsBeforeFinal !== undefined - ? winnersRounds - losersStartRoundsBeforeFinal - : 1 - - // Number of WB rounds that feed losers (excludes finals) - const feederRounds = losersStartRoundsBeforeFinal !== undefined - ? losersStartRoundsBeforeFinal - : winnersRounds - 1 - - // Calculate losers bracket rounds - // For losersStartRoundsBeforeFinal=0: no losers bracket - // For losersStartRoundsBeforeFinal=1: only 1 match (3rd place) - // For losersStartRoundsBeforeFinal>=2: standard double elimination - let losersRounds = 0 - if (losersStartRoundsBeforeFinal === 0) { - losersRounds = 0 // Pure single elimination - } else if (losersStartRoundsBeforeFinal === 1) { - losersRounds = 1 // Single match for 3rd place - } else { - // Standard: LB rounds = feederRounds * 2 - 1 - losersRounds = feederRounds * 2 - 1 - } + bracketSize, + winnersRounds, + losersRounds, + startFromWbRound, + grandFinal, + } = planBracket(options) - // Sort participants by seed - const sorted = [...participants].sort((a, b) => a.seed - b.seed) + const { idFactory } = options - // Create bracket structures const winnersMatches = createWinnersBracket( eventId, bracketSize, @@ -79,84 +35,99 @@ export const generateDoubleElimination = ( idFactory ) - let losersMatches: BracketMatch[] = [] - if (losersRounds > 0) { - losersMatches = createLosersBracket( - eventId, - bracketSize, - losersRounds, - startFromWbRound, - idFactory, - winnersRounds - ) - - // Wire loser routing from winners to losers bracket + const losersMatches = + losersRounds > 0 + ? createLosersBracket( + eventId, + bracketSize, + losersRounds, + startFromWbRound, + idFactory + ) + : [] + + if (losersMatches.length > 0) { wireLoserRouting( winnersMatches, losersMatches, winnersRounds, - startFromWbRound + startFromWbRound, + grandFinal !== 'none' ) } - // Place participants in first round (seeded positions) - placeParticipants(winnersMatches, sorted, bracketSize) + const grandFinalMatches = createGrandFinal(eventId, grandFinal, idFactory) + if (grandFinalMatches.length > 0) { + wireGrandFinal(winnersMatches, losersMatches, grandFinalMatches[0]) + } - const allMatches = [...winnersMatches, ...losersMatches] + placeParticipants(winnersMatches, participants, bracketSize) - // Process byes (auto-advance where opponent is missing) - processByes(allMatches) + const allMatches = [...winnersMatches, ...losersMatches, ...grandFinalMatches] + assertUniqueIds(allMatches) + + resolveByes(allMatches) return allMatches } +/** Sends both bracket winners into the grand final. */ +const wireGrandFinal = ( + winnersMatches: BracketMatch[], + losersMatches: BracketMatch[], + grandFinalMatch: BracketMatch +): void => { + const lastOf = (matches: BracketMatch[]): BracketMatch | undefined => + matches.reduce( + (latest, match) => + !latest || match.round > latest.round ? match : latest, + undefined + ) + + const winnersFinal = lastOf(winnersMatches) + if (winnersFinal) { + winnersFinal.winnerTo = grandFinalMatch.id + winnersFinal.winnerToSlot = 1 + } + + const losersFinal = lastOf(losersMatches) + if (losersFinal) { + losersFinal.winnerTo = grandFinalMatch.id + losersFinal.winnerToSlot = 2 + } +} + const placeParticipants = ( matches: BracketMatch[], participants: Participant[], bracketSize: number ): void => { - const round1 = matches.filter((m) => m.round === 1) - const seedMap = new Map(participants.map((p) => [p.seed, p.registrationId])) - - // Standard seeding: 1vN, 4v(N-3), 2v(N-1), 3v(N-2) pattern - const pairs = generateSeedPairs(bracketSize) + const round1 = matches.filter((match) => match.round === 1) + const seedMap = new Map( + participants.map((participant) => [ + participant.seed, + participant.registrationId, + ]) + ) - pairs.forEach(([seed1, seed2], idx) => { - const match = round1[idx] - if (match) { - match.registration1Id = seedMap.get(seed1) ?? null - match.registration2Id = seedMap.get(seed2) ?? null - } + generateSeedPairs(bracketSize).forEach(([seed1, seed2], index) => { + const match = round1[index] + if (!match) return + match.registration1Id = seedMap.get(seed1) ?? null + match.registration2Id = seedMap.get(seed2) ?? null }) } -/** - * Generate seed pairs using standard tournament seeding algorithm. - * This ensures seeds 1 and 2 can only meet in the finals, - * seeds 1-4 can only meet in semifinals at earliest, etc. - */ -const generateSeedPairs = (size: number): [number, number][] => { - // Build positions array iteratively, doubling each time - // [1, 2] -> [1, 4, 2, 3] -> [1, 8, 4, 5, 2, 7, 3, 6] -> ... - let positions = [1, 2] - - while (positions.length < size) { - const newPositions: number[] = [] - const sum = positions.length * 2 + 1 - - for (const pos of positions) { - newPositions.push(pos) - newPositions.push(sum - pos) +/** A repeating idFactory would silently cross-wire the bracket. */ +const assertUniqueIds = (matches: BracketMatch[]): void => { + const ids = new Set() + for (const match of matches) { + if (typeof match.id !== 'string' || match.id.length === 0) { + throw new Error('idFactory must return non-empty string ids') } - - positions = newPositions - } - - // Convert positions array to match pairs - // Adjacent positions form a match - const pairs: [number, number][] = [] - for (let i = 0; i < positions.length; i += 2) { - pairs.push([positions[i], positions[i + 1]]) + if (ids.has(match.id)) { + throw new Error(`idFactory returned a duplicate id: "${match.id}"`) + } + ids.add(match.id) } - return pairs } diff --git a/src/index.ts b/src/index.ts index 3b54ceb..53ec9e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,9 @@ -export { generateDoubleElimination } from './generateDoubleElimination' +export { generateDoubleElimination } from './generateDoubleElimination.js' export type { Participant, BracketMatch, + BracketType, + GrandFinalFormat, IdFactory, GeneratorOptions, -} from './types' +} from './types.js' diff --git a/src/planBracket.ts b/src/planBracket.ts new file mode 100644 index 0000000..1925b08 --- /dev/null +++ b/src/planBracket.ts @@ -0,0 +1,141 @@ +import { GeneratorOptions, GrandFinalFormat, Participant } from './types.js' +import { log2, nextPowerOf2 } from './bracketUtils.js' + +export interface BracketPlan { + eventId: string + /** Participants sorted by seed, re-ranked to 1..N. */ + participants: Participant[] + bracketSize: number + winnersRounds: number + losersRounds: number + /** First winners bracket round whose losers drop into the losers bracket. */ + startFromWbRound: number + grandFinal: GrandFinalFormat +} + +/** Validates the options and derives every number the generators need. */ +export const planBracket = (options: GeneratorOptions): BracketPlan => { + const { + eventId, + participants, + idFactory, + losersStartRoundsBeforeFinal, + grandFinal = 'none', + } = options + + if (typeof eventId !== 'string' || eventId.length === 0) { + throw new Error('eventId must be a non-empty string') + } + if (typeof idFactory !== 'function') { + throw new Error('idFactory must be a function returning unique ids') + } + if (!Array.isArray(participants) || participants.length < 2) { + throw new Error('At least 2 participants required') + } + if ( + grandFinal !== 'none' && + grandFinal !== 'single' && + grandFinal !== 'reset' + ) { + throw new Error(`grandFinal must be 'none', 'single' or 'reset'`) + } + + const ranked = rankParticipants(participants) + + const bracketSize = nextPowerOf2(ranked.length) + const winnersRounds = log2(bracketSize) + + if (losersStartRoundsBeforeFinal !== undefined) { + if ( + !Number.isInteger(losersStartRoundsBeforeFinal) || + losersStartRoundsBeforeFinal < 0 + ) { + throw new Error( + 'losersStartRoundsBeforeFinal must be a non-negative integer (0 = pure single elimination)' + ) + } + if (losersStartRoundsBeforeFinal === 1 && winnersRounds < 2) { + throw new Error( + 'losersStartRoundsBeforeFinal=1 requires at least 3 participants (there must be a semifinal round)' + ) + } + if (losersStartRoundsBeforeFinal >= winnersRounds) { + throw new Error( + `losersStartRoundsBeforeFinal must be less than winnersRounds (${winnersRounds})` + ) + } + } + + // Winners bracket rounds (excluding the final) whose losers feed the LB. + const feederRounds = losersStartRoundsBeforeFinal ?? winnersRounds - 1 + const startFromWbRound = winnersRounds - feederRounds + + if (grandFinal !== 'none' && feederRounds < 1) { + throw new Error( + 'grandFinal requires a losers bracket: losersStartRoundsBeforeFinal must be at least 1 and there must be at least 3 participants' + ) + } + + // Each feeder round adds a crossover round (fresh winners bracket losers) + // plus a consolidation round, minus the consolidation round that would + // follow the last feeder round. With a grand final the winners final loser + // becomes an extra feeder, which adds the losers final back. + let losersRounds = Math.max(0, feederRounds * 2 - 1) + if (grandFinal !== 'none') losersRounds += 1 + + return { + eventId, + participants: ranked, + bracketSize, + winnersRounds, + losersRounds, + startFromWbRound, + grandFinal, + } +} + +/** + * Sorts by seed and re-ranks to 1..N. + * + * Callers commonly pass sparse or 0-based seeds; ranking makes those behave the + * same as 1..N instead of silently leaving participants out of the bracket. + */ +const rankParticipants = (participants: Participant[]): Participant[] => { + const seenIds = new Set() + const seenSeeds = new Set() + + for (const participant of participants) { + if ( + !participant || + typeof participant.registrationId !== 'string' || + participant.registrationId.length === 0 + ) { + throw new Error('Every participant needs a non-empty registrationId') + } + if ( + typeof participant.seed !== 'number' || + !Number.isFinite(participant.seed) + ) { + throw new Error( + `Participant "${participant.registrationId}" has a non-numeric seed` + ) + } + if (seenIds.has(participant.registrationId)) { + throw new Error( + `Duplicate registrationId: "${participant.registrationId}"` + ) + } + if (seenSeeds.has(participant.seed)) { + throw new Error(`Duplicate seed: ${participant.seed}`) + } + seenIds.add(participant.registrationId) + seenSeeds.add(participant.seed) + } + + return [...participants] + .sort((a, b) => a.seed - b.seed) + .map((participant, index) => ({ + registrationId: participant.registrationId, + seed: index + 1, + })) +} diff --git a/src/processByes.ts b/src/processByes.ts deleted file mode 100644 index e4da3e9..0000000 --- a/src/processByes.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { BracketMatch } from './types' - -export const processByes = (matches: BracketMatch[]): void => { - const matchMap = new Map(matches.map((m) => [m.id, m])) - - // Only process round 1 winners bracket byes (no cascading) - const round1Winners = matches.filter( - (m) => m.bracketType === 'winners' && m.round === 1 - ) - - for (const match of round1Winners) { - processMatchBye(match, matchMap) - } -} - -const processMatchBye = ( - match: BracketMatch, - matchMap: Map -): void => { - const has1 = match.registration1Id !== null - const has2 = match.registration2Id !== null - - if (!has1 && !has2) return - if (has1 && has2) return - - const winnerId = match.registration1Id ?? match.registration2Id - - if (match.winnerTo && match.winnerToSlot && winnerId) { - const targetMatch = matchMap.get(match.winnerTo) - if (targetMatch) { - if (match.winnerToSlot === 1) { - targetMatch.registration1Id = winnerId - } else { - targetMatch.registration2Id = winnerId - } - } - } -} diff --git a/src/resolveByes.ts b/src/resolveByes.ts new file mode 100644 index 0000000..5908990 --- /dev/null +++ b/src/resolveByes.ts @@ -0,0 +1,205 @@ +import { BracketMatch } from './types.js' + +/** + * Resolves every walkover the bracket structure already implies. + * + * When the participant count is not a power of two some matches can never be + * played, and that has to be pushed through the whole bracket, not just the + * first winners round: + * + * - A match with one participant and one permanently empty slot is a walkover: + * the participant is placed in the next match straight away and the match + * produces no loser, so its `loserTo` link is dropped. + * - A match that will only ever receive one player from an earlier match is + * bypassed: the feeder is re-pointed at whatever came after it. Without this + * the losers bracket stalls, because a match sitting on a missing opponent + * never resolves and never advances anyone. + * - A match no one can reach is left in place with empty slots and no routing, + * so bracket positions stay stable for rendering. + */ +export const resolveByes = (matches: BracketMatch[]): void => { + const byId = new Map(matches.map((match) => [match.id, match])) + const order = topologicalOrder(matches, byId) + + const slots = new Map() + for (const match of matches) { + slots.set(match.id, [ + slotStateOf(match.registration1Id), + slotStateOf(match.registration2Id), + ]) + } + + const shapes = new Map() + + const deliver = ( + targetId: string | null, + targetSlot: number | null, + state: SlotState + ): void => { + if (!targetId || !targetSlot) return + const targetSlots = slots.get(targetId) + if (!targetSlots) return + targetSlots[targetSlot - 1] = state + } + + // Forward pass: work out what each match will actually hold. + for (const match of order) { + const [slot1, slot2] = slots.get(match.id)! + const shape = classify(match, slot1, slot2) + shapes.set(match.id, shape) + + if (shape === 'live') { + deliver(match.winnerTo, match.winnerToSlot, PENDING) + deliver(match.loserTo, match.loserToSlot, PENDING) + } else if (shape === 'walkover') { + const entrant = slot1.kind === 'known' ? slot1 : slot2 + deliver(match.winnerTo, match.winnerToSlot, entrant) + } else if (shape === 'bypassed') { + deliver(match.winnerTo, match.winnerToSlot, PENDING) + } + } + + // Resolve routing before mutating anything, since bypassed matches lose their + // own links and can sit anywhere in the iteration order. + const rerouted = new Map() + for (const match of matches) { + const shape = shapes.get(match.id)! + rerouted.set(match.id, { + winner: followBypasses(match.winnerTo, match.winnerToSlot, byId, shapes), + loser: + shape === 'live' + ? followBypasses(match.loserTo, match.loserToSlot, byId, shapes) + : NO_LINK, + }) + } + + for (const match of matches) { + const [slot1, slot2] = slots.get(match.id)! + if (slot1.kind === 'known') match.registration1Id = slot1.playerId + if (slot2.kind === 'known') match.registration2Id = slot2.playerId + + const shape = shapes.get(match.id)! + const links = + shape === 'unused' || shape === 'bypassed' + ? { winner: NO_LINK, loser: NO_LINK } + : rerouted.get(match.id)! + + match.winnerTo = links.winner.id + match.winnerToSlot = links.winner.slot + match.loserTo = links.loser.id + match.loserToSlot = links.loser.slot + } +} + +type SlotState = + | { kind: 'empty' } + | { kind: 'pending' } + | { kind: 'known'; playerId: string } + +/** No player can ever arrive here. */ +const EMPTY: SlotState = { kind: 'empty' } +/** A player will arrive once earlier matches are played. */ +const PENDING: SlotState = { kind: 'pending' } + +const slotStateOf = (registrationId: string | null): SlotState => + registrationId === null ? EMPTY : { kind: 'known', playerId: registrationId } + +type MatchShape = + /** Two entrants (or one entrant and nowhere to advance them to). */ + | 'live' + /** One known entrant against an empty slot: advance them now. */ + | 'walkover' + /** One entrant, not yet known: route the feeder past this match. */ + | 'bypassed' + /** Unreachable. */ + | 'unused' + +const classify = ( + match: BracketMatch, + slot1: SlotState, + slot2: SlotState +): MatchShape => { + const live1 = slot1.kind !== 'empty' + const live2 = slot2.kind !== 'empty' + + if (live1 && live2) return 'live' + if (!live1 && !live2) return 'unused' + + const entrant = live1 ? slot1 : slot2 + if (entrant.kind === 'known') return 'walkover' + + // Nothing to bypass into: the single entrant wins the bracket by walkover, + // so keep the match to record that. + return match.winnerTo ? 'bypassed' : 'live' +} + +interface Link { + id: string | null + slot: number | null +} + +const NO_LINK: Link = { id: null, slot: null } + +/** Follows bypassed matches to the first match that is actually played. */ +const followBypasses = ( + targetId: string | null, + targetSlot: number | null, + byId: Map, + shapes: Map +): Link => { + let id = targetId + let slot = targetSlot + let hops = 0 + + while (id && shapes.get(id) === 'bypassed') { + const bypassed = byId.get(id)! + id = bypassed.winnerTo + slot = bypassed.winnerToSlot + if (++hops > byId.size) { + throw new Error('Cycle detected while resolving byes') + } + } + + return id && slot ? { id, slot } : NO_LINK +} + +/** Orders matches so that every match comes after the matches that feed it. */ +const topologicalOrder = ( + matches: BracketMatch[], + byId: Map +): BracketMatch[] => { + const incoming = new Map() + for (const match of matches) incoming.set(match.id, 0) + + const targetsOf = (match: BracketMatch): string[] => { + const targets: string[] = [] + if (match.winnerTo && byId.has(match.winnerTo)) targets.push(match.winnerTo) + if (match.loserTo && byId.has(match.loserTo)) targets.push(match.loserTo) + return targets + } + + for (const match of matches) { + for (const target of targetsOf(match)) { + incoming.set(target, incoming.get(target)! + 1) + } + } + + const queue = matches.filter((match) => incoming.get(match.id) === 0) + const order: BracketMatch[] = [] + + for (let i = 0; i < queue.length; i++) { + const match = queue[i] + order.push(match) + for (const target of targetsOf(match)) { + const remaining = incoming.get(target)! - 1 + incoming.set(target, remaining) + if (remaining === 0) queue.push(byId.get(target)!) + } + } + + if (order.length !== matches.length) { + throw new Error('Bracket routing contains a cycle') + } + + return order +} diff --git a/src/types.ts b/src/types.ts index 4772c3c..ecba255 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,25 +1,62 @@ +/** A competitor entered into the bracket. */ export interface Participant { + /** Caller-owned identifier written into match slots. Must be unique. */ registrationId: string + /** + * Seeding rank. Lower is stronger. + * + * Seeds must be unique but need not be `1..N`: participants are ranked by + * seed, so `[10, 20, 30]` seeds identically to `[1, 2, 3]`. + */ seed: number } +/** + * Which bracket a match belongs to. + * + * `grandFinal` matches are only produced when the `grandFinal` option is + * enabled. + */ +export type BracketType = 'winners' | 'losers' | 'grandFinal' + export interface BracketMatch { id: string eventId: string + /** 1-based round index, counted within `bracketType`. */ round: number + /** 1-based index of the match within its round (`bracketPosition + 1`). */ matchNumber: number registration1Id: string | null registration2Id: string | null + /** 0-based index of the match within its round, top to bottom. */ bracketPosition: number + /** Match the winner advances to, or `null` if the match ends the bracket. */ winnerTo: string | null + /** Slot (1 or 2) the winner occupies in `winnerTo`. */ winnerToSlot: number | null + /** Match the loser drops to, or `null` if losing eliminates the player. */ loserTo: string | null + /** Slot (1 or 2) the loser occupies in `loserTo`. */ loserToSlot: number | null - bracketType: 'winners' | 'losers' + bracketType: BracketType } export type IdFactory = () => string +/** + * How the winners-bracket winner and losers-bracket winner meet. + * + * - `'none'` (default): no grand final. The winners final decides 1st/2nd and + * the losers final decides 3rd/4th. + * - `'single'`: one grand final match. The winners final loser drops to the + * losers final, and the losers bracket winner plays the winners bracket + * winner once for the title. + * - `'reset'`: as `'single'`, plus a bracket-reset match. The reset is played + * only when the losers bracket representative wins the first grand final, + * so that both finalists have been beaten twice. + */ +export type GrandFinalFormat = 'none' | 'single' | 'reset' + export interface GeneratorOptions { eventId: string participants: Participant[] @@ -39,4 +76,12 @@ export interface GeneratorOptions { * - Round 4 (Finals): Loser = 2nd place */ losersStartRoundsBeforeFinal?: number + /** + * Whether to append a grand final between the winners bracket winner and the + * losers bracket winner. Defaults to `'none'` for backwards compatibility. + * + * Enabling it also adds one losers bracket round, because the winners final + * loser drops into the losers final instead of being eliminated. + */ + grandFinal?: GrandFinalFormat } diff --git a/src/wireLoserRouting.ts b/src/wireLoserRouting.ts index 2150646..5b42d39 100644 --- a/src/wireLoserRouting.ts +++ b/src/wireLoserRouting.ts @@ -1,42 +1,70 @@ -import { BracketMatch } from './types' +import { BracketMatch } from './types.js' +/** + * Reorders a round of winners bracket losers before they are dropped into the + * losers bracket. + * + * `position` is the winners bracket match index, `count` the number of matches + * in that round; the result is the losers bracket match index. + */ +type PositionOrdering = (position: number, count: number) => number + +const natural: PositionOrdering = (position) => position +const reverse: PositionOrdering = (position, count) => count - 1 - position +const halfShift: PositionOrdering = (position, count) => + (position + (count >> 1)) % count +const reverseHalfShift: PositionOrdering = (position, count) => + (count - 1 - position + (count >> 1)) % count + +/** + * Applied to the 2nd, 3rd, 4th, 5th... round of losers entering the bracket + * (the 1st round simply pairs up, so it needs no reordering). + * + * Rotating the ordering keeps a dropped player away from the section of the + * bracket they came from for as long as possible. Repeating a single ordering + * (or using `natural` throughout) lines players back up against opponents they + * already beat: with 64 players, rotating pushes the first possible rematch + * from losers round 4 to losers round 7 and cuts the expected number of + * rematches per tournament from ~2.8 to ~0.4. + */ +const CROSSOVER_ORDERINGS: PositionOrdering[] = [ + reverse, + reverseHalfShift, + halfShift, + natural, +] + +/** Points every winners bracket match at the losers bracket match it feeds. */ export const wireLoserRouting = ( winnersMatches: BracketMatch[], losersMatches: BracketMatch[], winnersRounds: number, - startFromWbRound: number = 1 + startFromWbRound: number, + winnersFinalFeedsLosers: boolean ): void => { - const losersIdMap = buildLosersIdMap(losersMatches) + const losersIdMap = new Map() + for (const match of losersMatches) { + losersIdMap.set(`${match.round}-${match.bracketPosition}`, match.id) + } for (const match of winnersMatches) { - const { round, bracketPosition } = match const routing = getLoserDestination( - round, - bracketPosition, + match.round, + match.bracketPosition, winnersRounds, - startFromWbRound + startFromWbRound, + winnersFinalFeedsLosers ) + if (!routing) continue - if (routing) { - const targetId = losersIdMap.get( - `${routing.lbRound}-${routing.lbPosition}` - ) - if (targetId) { - match.loserTo = targetId - match.loserToSlot = routing.slot - } + const targetId = losersIdMap.get(`${routing.lbRound}-${routing.lbPosition}`) + if (targetId) { + match.loserTo = targetId + match.loserToSlot = routing.slot } } } -const buildLosersIdMap = (matches: BracketMatch[]): Map => { - const map = new Map() - for (const m of matches) { - map.set(`${m.round}-${m.bracketPosition}`, m.id) - } - return map -} - interface LoserDestination { lbRound: number lbPosition: number @@ -47,34 +75,20 @@ const getLoserDestination = ( wbRound: number, wbPosition: number, totalWbRounds: number, - startFromWbRound: number + startFromWbRound: number, + winnersFinalFeedsLosers: boolean ): LoserDestination | null => { - // WB Finals: winner=1st, loser=2nd (no routing to LB) - if (wbRound === totalWbRounds) return null - - // Skip rounds before losers bracket starts (single elimination portion) + // Rounds before the losers bracket opens are single elimination. if (wbRound < startFromWbRound) return null - // Calculate relative round (treating startFromWbRound as "round 1") - const relativeRound = wbRound - startFromWbRound + 1 + // Without a grand final the winners final loser is 2nd place and stops here. + if (wbRound === totalWbRounds && !winnersFinalFeedsLosers) return null - // Special case: losersStartRoundsBeforeFinal=1 means only semifinal losers go to LB - // This creates a single 3rd place match - if (startFromWbRound === totalWbRounds - 1) { - // Only semifinal losers go to LB Round 1 (3rd place match) - if (wbRound === totalWbRounds - 1) { - return { - lbRound: 1, - lbPosition: 0, - slot: wbPosition + 1, // First semifinal loser goes to slot 1, second to slot 2 - } - } - return null - } + // Round index counted from the first round that feeds the losers bracket. + const relativeRound = wbRound - startFromWbRound + 1 if (relativeRound === 1) { - // First feeder round losers → LB Round 1 - // LB position = floor(WB_position / 2), slot = (WB_position % 2) + 1 + // The first wave of losers pairs off against itself. return { lbRound: 1, lbPosition: Math.floor(wbPosition / 2), @@ -82,25 +96,15 @@ const getLoserDestination = ( } } - if (relativeRound === 2) { - // Second feeder round losers → LB Round 2 - // REVERSED positions to prevent early rematches - // Losers from top go to bottom LB, losers from bottom go to top LB - const totalWinnersRoundMatches = Math.pow(2, totalWbRounds - wbRound) - return { - lbRound: 2, - lbPosition: totalWinnersRoundMatches - 1 - wbPosition, - slot: 2, - } - } + // Later waves drop into a crossover round that already holds one player, so + // they always take slot 2. Round r of losers enters losers round 2r - 2. + const matchesInWbRound = Math.pow(2, totalWbRounds - wbRound) + const ordering = + CROSSOVER_ORDERINGS[(relativeRound - 2) % CROSSOVER_ORDERINGS.length] - // Third+ feeder round losers: LB_round = (relativeRound - 2) * 2 + 2 - // Use SAME position (not mirrored) to continue crossover pattern - const lbRound = (relativeRound - 2) * 2 + 2 - return { - lbRound, - lbPosition: wbPosition, // Same position, not mirrored + lbRound: relativeRound * 2 - 2, + lbPosition: ordering(wbPosition, matchesInWbRound), slot: 2, } } diff --git a/tests/byes.test.ts b/tests/byes.test.ts new file mode 100644 index 0000000..c1c8d14 --- /dev/null +++ b/tests/byes.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest' +import { generateDoubleElimination } from '../src' +import { + createIdFactory, + createParticipants, + fedSlots, + roundOf, + seededPicker, + simulate, +} from './helpers' + +const generate = (count: number, overrides = {}) => + generateDoubleElimination({ + eventId: 'event-1', + participants: createParticipants(count), + idFactory: createIdFactory(), + ...overrides, + }) + +describe('bye handling', () => { + it('auto-advances a first round bye', () => { + const matches = generate(7) + const wbR1 = roundOf(matches, 'winners', 1) + const wbR2 = roundOf(matches, 'winners', 2) + + const byeMatch = wbR1.find((m) => m.registration2Id === null)! + expect(byeMatch.registration1Id).toBe('player-1') + + const target = wbR2.find((m) => m.id === byeMatch.winnerTo)! + const slot = + byeMatch.winnerToSlot === 1 + ? target.registration1Id + : target.registration2Id + expect(slot).toBe('player-1') + }) + + it('does not send a loser out of a match nobody played', () => { + const matches = generate(7) + const byeMatch = roundOf(matches, 'winners', 1).find( + (m) => m.registration2Id === null + )! + + // A walkover produces no loser, so it must not reserve a losers bracket slot. + expect(byeMatch.loserTo).toBeNull() + expect(byeMatch.loserToSlot).toBeNull() + }) + + it('routes past losers bracket matches that can only ever have one player', () => { + // 5 of 8: three byes, so the losers bracket receives only three players. + const matches = generate(5) + const wbR1 = roundOf(matches, 'winners', 1) + const lbR1 = roundOf(matches, 'losers', 1) + const lbR2 = roundOf(matches, 'losers', 2) + + const contested = wbR1.find( + (m) => m.registration1Id !== null && m.registration2Id !== null + )! + + // Its loser is the only entrant LB round 1 would get, so it skips ahead. + expect(contested.loserTo).not.toBeNull() + const target = matches.find((m) => m.id === contested.loserTo)! + expect(target.round).toBe(2) + expect(lbR2.map((m) => m.id)).toContain(target.id) + + // The bypassed matches stay in place but lead nowhere. + const unreachable = lbR1.filter((m) => { + const fed = fedSlots(matches) + return !fed.has(`${m.id}#1`) && !fed.has(`${m.id}#2`) + }) + expect(unreachable.length).toBeGreaterThan(0) + for (const match of unreachable) { + expect(match.winnerTo).toBeNull() + expect(match.registration1Id).toBeNull() + expect(match.registration2Id).toBeNull() + } + }) + + it.each([3, 5, 6, 7, 9, 11, 23, 33])( + 'completes the losers bracket with %i participants', + (count) => { + const { stalled, champion } = simulate( + generate(count), + seededPicker(2024) + ) + expect(stalled).toEqual([]) + expect(champion).not.toBeNull() + } + ) + + it('keeps the losers bracket playable with a grand final', () => { + const { stalled, losses, champion } = simulate( + generate(11, { grandFinal: 'reset' }), + seededPicker(5) + ) + + expect(stalled).toEqual([]) + const survivors = createParticipants(11) + .map((p) => p.registrationId) + .filter((id) => (losses.get(id) ?? 0) < 2) + expect(survivors).toEqual([champion]) + }) +}) diff --git a/tests/generateDoubleElimination.test.ts b/tests/generateDoubleElimination.test.ts index a5e7623..5f34990 100644 --- a/tests/generateDoubleElimination.test.ts +++ b/tests/generateDoubleElimination.test.ts @@ -1,16 +1,6 @@ import { describe, it, expect } from 'vitest' -import { generateDoubleElimination, Participant } from '../src' - -const createIdFactory = () => { - let counter = 0 - return () => `match-${++counter}` -} - -const createParticipants = (count: number): Participant[] => - Array.from({ length: count }, (_, i) => ({ - registrationId: `player-${i + 1}`, - seed: i + 1, - })) +import { generateDoubleElimination } from '../src' +import { createIdFactory, createParticipants } from './helpers' describe('generateDoubleElimination', () => { it('throws if fewer than 2 participants', () => { @@ -388,7 +378,7 @@ describe('delayed losers bracket', () => { idFactory: createIdFactory(), losersStartRoundsBeforeFinal: 1, }) - ).toThrow('losersStartRoundsBeforeFinal=1 requires at least 4 participants') + ).toThrow('losersStartRoundsBeforeFinal=1 requires at least 3 participants') }) it('throws error if losersStartRoundsBeforeFinal >= winnersRounds', () => { @@ -401,7 +391,6 @@ describe('delayed losers bracket', () => { }) ).toThrow('losersStartRoundsBeforeFinal must be less than winnersRounds') }) - }) describe('rematch prevention', () => { @@ -460,7 +449,7 @@ describe('rematch prevention', () => { expect(wbR2[midPos]?.loserTo).toBe(lbR2[totalMatches - 1 - midPos]?.id) }) - it('routes WB Round 3+ losers with same positions (not mirrored) for 32 participants', () => { + it('rotates the ordering of WB Round 3+ losers for 32 participants', () => { const matches = generateDoubleElimination({ eventId: 'event-1', participants: createParticipants(32), @@ -477,14 +466,13 @@ describe('rematch prevention', () => { .filter((m) => m.round === 4) .sort((a, b) => a.bracketPosition - b.bracketPosition) - // WB R3 has 4 matches (positions 0-3) - // Should use same positions: WB R3 pos 0 → LB R4 pos 0, etc. - for (let i = 0; i < wbR3.length; i++) { - expect(wbR3[i]?.loserTo).toBe(lbR4[i]?.id) - expect(wbR3[i]?.loserToSlot).toBe(2) - } + // Third wave of losers: reversed and shifted by half (4 matches), + // so 0 -> 1, 1 -> 0, 2 -> 3, 3 -> 2. + expect( + wbR3.map((m) => lbR4.findIndex((lb) => lb.id === m.loserTo)) + ).toEqual([1, 0, 3, 2]) + for (const match of wbR3) expect(match.loserToSlot).toBe(2) - // Verify Round 4 as well const wbR4 = winners .filter((m) => m.round === 4) .sort((a, b) => a.bracketPosition - b.bracketPosition) @@ -492,12 +480,11 @@ describe('rematch prevention', () => { .filter((m) => m.round === 6) .sort((a, b) => a.bracketPosition - b.bracketPosition) - // WB R4 has 2 matches (positions 0-1) - // Should use same positions - for (let i = 0; i < wbR4.length; i++) { - expect(wbR4[i]?.loserTo).toBe(lbR6[i]?.id) - expect(wbR4[i]?.loserToSlot).toBe(2) - } + // Fourth wave: shifted by half (2 matches), so 0 -> 1 and 1 -> 0. + expect( + wbR4.map((m) => lbR6.findIndex((lb) => lb.id === m.loserTo)) + ).toEqual([1, 0]) + for (const match of wbR4) expect(match.loserToSlot).toBe(2) }) it('prevents early rematches by ensuring Round 2 reversal separates bracket halves', () => { diff --git a/tests/grandFinal.test.ts b/tests/grandFinal.test.ts new file mode 100644 index 0000000..4bafa0e --- /dev/null +++ b/tests/grandFinal.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest' +import { generateDoubleElimination } from '../src' +import { + chalk, + createIdFactory, + createParticipants, + roundOf, + seededPicker, + simulate, +} from './helpers' + +const generate = (count: number, overrides = {}) => + generateDoubleElimination({ + eventId: 'event-1', + participants: createParticipants(count), + idFactory: createIdFactory(), + ...overrides, + }) + +describe('grand final', () => { + it('is absent by default', () => { + const matches = generate(8) + expect(matches.filter((m) => m.bracketType === 'grandFinal')).toHaveLength( + 0 + ) + expect(matches.filter((m) => m.bracketType === 'losers')).toHaveLength(5) + }) + + it('adds the losers final when enabled so the winners final loser drops', () => { + const matches = generate(8, { grandFinal: 'single' }) + + const losers = matches.filter((m) => m.bracketType === 'losers') + // 8 players: winners 7 + losers 6 + grand final 1 = 2n - 2 matches. + expect(losers).toHaveLength(6) + expect(matches).toHaveLength(14) + + const winnersFinal = roundOf(matches, 'winners', 3)[0] + const losersFinal = roundOf(matches, 'losers', 4)[0] + + expect(winnersFinal.loserTo).toBe(losersFinal.id) + expect(winnersFinal.loserToSlot).toBe(2) + }) + + it('sends both bracket winners into the grand final', () => { + const matches = generate(8, { grandFinal: 'single' }) + const grandFinal = matches.find((m) => m.bracketType === 'grandFinal')! + const winnersFinal = roundOf(matches, 'winners', 3)[0] + const losersFinal = roundOf(matches, 'losers', 4)[0] + + expect(winnersFinal.winnerTo).toBe(grandFinal.id) + expect(winnersFinal.winnerToSlot).toBe(1) + expect(losersFinal.winnerTo).toBe(grandFinal.id) + expect(losersFinal.winnerToSlot).toBe(2) + expect(grandFinal.winnerTo).toBeNull() + }) + + it('carries both finalists into the bracket reset match', () => { + const matches = generate(8, { grandFinal: 'reset' }) + const grandFinals = matches + .filter((m) => m.bracketType === 'grandFinal') + .sort((a, b) => a.round - b.round) + + expect(grandFinals).toHaveLength(2) + expect(grandFinals[0].winnerTo).toBe(grandFinals[1].id) + expect(grandFinals[0].winnerToSlot).toBe(1) + expect(grandFinals[0].loserTo).toBe(grandFinals[1].id) + expect(grandFinals[0].loserToSlot).toBe(2) + expect(grandFinals[1].winnerTo).toBeNull() + }) + + it('lets the winners bracket winner take the title in one match', () => { + const matches = generate(8, { grandFinal: 'reset' }) + const { played, champion, losses } = simulate(matches, chalk) + + const reset = played.find( + (p) => p.match.bracketType === 'grandFinal' && p.match.round === 2 + ) + + // The top seed never loses, so the reset match is not needed. + expect(champion).toBe('player-1') + expect(reset).toBeUndefined() + expect(losses.get('player-1') ?? 0).toBe(0) + }) + + it('plays the reset when the losers bracket representative wins', () => { + // Search for an outcome where the losers bracket side wins the first final. + let resetPlayed = false + + for (let seed = 1; seed <= 50 && !resetPlayed; seed++) { + const { played, losses, champion } = simulate( + generate(8, { grandFinal: 'reset' }), + seededPicker(seed * 31) + ) + const reset = played.find( + (p) => p.match.bracketType === 'grandFinal' && p.match.round === 2 + ) + if (!reset) continue + + resetPlayed = true + // Both finalists entered the reset with one loss each. + expect(losses.get(champion!)).toBeLessThanOrEqual(1) + expect(losses.get(reset.loser)).toBe(2) + } + + expect(resetPlayed).toBe(true) + }) + + it('works with a delayed losers bracket', () => { + const matches = generate(16, { + losersStartRoundsBeforeFinal: 2, + grandFinal: 'single', + }) + + const losers = matches.filter((m) => m.bracketType === 'losers') + expect(losers).toHaveLength(6) // 2 + 2 + 1 + 1 + + const { stalled, champion } = simulate(matches, seededPicker(11)) + expect(stalled).toEqual([]) + expect(champion).not.toBeNull() + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..50e542b --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,165 @@ +import type { BracketMatch, Participant } from '../src' + +export const createIdFactory = () => { + let counter = 0 + return () => `match-${++counter}` +} + +export const createParticipants = (count: number): Participant[] => + Array.from({ length: count }, (_, i) => ({ + registrationId: `player-${i + 1}`, + seed: i + 1, + })) + +export const bySortedPosition = (a: BracketMatch, b: BracketMatch): number => + a.round - b.round || a.bracketPosition - b.bracketPosition + +export const roundOf = ( + matches: BracketMatch[], + bracketType: BracketMatch['bracketType'], + round: number +): BracketMatch[] => + matches + .filter((m) => m.bracketType === bracketType && m.round === round) + .sort(bySortedPosition) + +/** Every slot that some other match feeds into, as `matchId#slot`. */ +export const fedSlots = (matches: BracketMatch[]): Set => { + const fed = new Set() + for (const match of matches) { + if (match.winnerTo) fed.add(`${match.winnerTo}#${match.winnerToSlot}`) + if (match.loserTo) fed.add(`${match.loserTo}#${match.loserToSlot}`) + } + return fed +} + +export interface SimulationResult { + /** Losses per registrationId. */ + losses: Map + /** Matches that were actually contested, in play order. */ + played: { match: BracketMatch; winner: string; loser: string }[] + /** Matches holding a player who never got an opponent. */ + stalled: BracketMatch[] + /** Pairings that happened more than once, with the round they repeated in. */ + rematches: { + bracketType: string + round: number + players: [string, string] + }[] + champion: string | null +} + +/** + * Plays a whole bracket with the supplied outcome picker. + * + * Mirrors what a consumer has to do: propagate `winnerTo` / `loserTo`, treat a + * match whose empty slot has no feeder as a walkover, and skip the bracket + * reset unless the losers bracket representative won the first grand final. + */ +export const simulate = ( + matches: BracketMatch[], + pickWinner: (a: string, b: string) => string +): SimulationResult => { + const sim = new Map(matches.map((m) => [m.id, { ...m }])) + const fed = fedSlots(matches) + const losses = new Map() + const played: SimulationResult['played'] = [] + const rematches: SimulationResult['rematches'] = [] + const seenPairings = new Set() + const done = new Set() + + const canStillFill = (match: BracketMatch, slot: 1 | 2): boolean => { + const occupied = slot === 1 ? match.registration1Id : match.registration2Id + return occupied !== null || fed.has(`${match.id}#${slot}`) + } + + const place = (targetId: string | null, slot: number | null, who: string) => { + if (!targetId || !slot) return + const target = sim.get(targetId) + if (!target) return + if (slot === 1) target.registration1Id = who + else target.registration2Id = who + } + + let progressed = true + while (progressed) { + progressed = false + + for (const match of sim.values()) { + if (done.has(match.id)) continue + + // The bracket reset is only played when the losers bracket side (slot 2) + // won the first grand final. + if (match.bracketType === 'grandFinal' && match.round === 2) { + const first = [...sim.values()].find( + (m) => m.bracketType === 'grandFinal' && m.round === 1 + ) + const firstResult = played.find((p) => p.match.id === first?.id) + if (!firstResult) continue + if (firstResult.winner !== first?.registration2Id) { + done.add(match.id) + progressed = true + continue + } + } + + const a = match.registration1Id + const b = match.registration2Id + + if (!a || !b) { + // Walkover: one slot can never be filled, so nothing is contested here. + if (!canStillFill(match, 1) || !canStillFill(match, 2)) { + done.add(match.id) + progressed = true + } + continue + } + + const pairing = a < b ? `${a}|${b}` : `${b}|${a}` + if (seenPairings.has(pairing)) { + rematches.push({ + bracketType: match.bracketType, + round: match.round, + players: [a, b], + }) + } + seenPairings.add(pairing) + + const winner = pickWinner(a, b) + const loser = winner === a ? b : a + losses.set(loser, (losses.get(loser) ?? 0) + 1) + played.push({ match, winner, loser }) + + place(match.winnerTo, match.winnerToSlot, winner) + place(match.loserTo, match.loserToSlot, loser) + + done.add(match.id) + progressed = true + } + } + + const stalled = [...sim.values()].filter( + (m) => + !done.has(m.id) && + (m.registration1Id !== null || m.registration2Id !== null) + ) + + const last = played[played.length - 1] + + return { losses, played, stalled, rematches, champion: last?.winner ?? null } +} + +/** Deterministic outcome picker: the better seed always wins. */ +export const chalk = (a: string, b: string): string => { + const seedOf = (id: string) => Number(id.replace('player-', '')) + return seedOf(a) <= seedOf(b) ? a : b +} + +/** Deterministic pseudo-random outcome picker. */ +export const seededPicker = (seed: number) => { + let state = seed + return (a: string, b: string): string => { + state = (state * 1103515245 + 12345) & 0x7fffffff + return state / 0x80000000 < 0.5 ? a : b + } +} diff --git a/tests/invariants.test.ts b/tests/invariants.test.ts new file mode 100644 index 0000000..d7fbb03 --- /dev/null +++ b/tests/invariants.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest' +import { generateDoubleElimination, type BracketMatch } from '../src' +import { + chalk, + createIdFactory, + createParticipants, + fedSlots, + seededPicker, + simulate, +} from './helpers' + +const SIZES = [2, 3, 4, 5, 6, 7, 8, 9, 11, 13, 16, 17, 23, 32, 33, 48, 64] + +const generate = (count: number, overrides = {}) => + generateDoubleElimination({ + eventId: 'event-1', + participants: createParticipants(count), + idFactory: createIdFactory(), + ...overrides, + }) + +/** Structural checks that must hold for every bracket the library produces. */ +const expectWellFormed = (matches: BracketMatch[]) => { + const byId = new Map(matches.map((m) => [m.id, m])) + expect(byId.size).toBe(matches.length) + + const feederCount = new Map() + + for (const match of matches) { + const links: [string | null, number | null][] = [ + [match.winnerTo, match.winnerToSlot], + [match.loserTo, match.loserToSlot], + ] + + for (const [target, slot] of links) { + if (target === null) { + expect(slot).toBeNull() + continue + } + expect(byId.has(target)).toBe(true) + expect([1, 2]).toContain(slot) + // A match never routes backwards into an already-decided round. + const targetMatch = byId.get(target)! + if (targetMatch.bracketType === match.bracketType) { + expect(targetMatch.round).toBeGreaterThan(match.round) + } + const key = `${target}#${slot}` + feederCount.set(key, (feederCount.get(key) ?? 0) + 1) + } + } + + // Two matches feeding one slot would silently overwrite a player. + for (const [slot, count] of feederCount) { + expect(`${slot} has ${count} feeder(s)`).toBe(`${slot} has 1 feeder(s)`) + } +} + +describe.each(SIZES)('bracket invariants for %i participants', (count) => { + it('is structurally well formed', () => { + expectWellFormed(generate(count)) + if (count >= 3) expectWellFormed(generate(count, { grandFinal: 'reset' })) + }) + + it('places every participant exactly once', () => { + const matches = generate(count) + const placed = matches + .filter((m) => m.bracketType === 'winners' && m.round === 1) + .flatMap((m) => [m.registration1Id, m.registration2Id]) + .filter((id): id is string => id !== null) + + expect(new Set(placed).size).toBe(count) + expect(placed).toHaveLength(count) + }) + + it('runs to completion without stalling on a missing opponent', () => { + for (const seed of [1, 7, 99, 12345]) { + const matches = generate(count) + const result = simulate(matches, seededPicker(seed)) + expect(result.stalled).toEqual([]) + expect(result.champion).not.toBeNull() + } + }) + + it('runs to completion with a grand final and bracket reset', () => { + if (count < 3) return + for (const seed of [1, 7, 99]) { + const matches = generate(count, { grandFinal: 'reset' }) + const result = simulate(matches, seededPicker(seed)) + expect(result.stalled).toEqual([]) + expect(result.champion).not.toBeNull() + } + }) + + it('eliminates everyone but the champion after exactly two losses', () => { + if (count < 3) return + for (const seed of [1, 7, 99, 12345]) { + const matches = generate(count, { grandFinal: 'reset' }) + const { losses, champion } = simulate(matches, seededPicker(seed)) + + const survivors = createParticipants(count) + .map((p) => p.registrationId) + .filter((id) => (losses.get(id) ?? 0) < 2) + + expect(survivors).toEqual([champion]) + for (const [, count_] of losses) expect(count_).toBeLessThanOrEqual(2) + } + }) + + it('leaves exactly three players standing without a grand final', () => { + if (count < 4) return + const matches = generate(count) + const { losses } = simulate(matches, seededPicker(31)) + + // Champion (0 losses), runner-up and losers bracket winner (1 loss each). + const survivors = createParticipants(count) + .map((p) => p.registrationId) + .filter((id) => (losses.get(id) ?? 0) < 2) + + expect(survivors).toHaveLength(3) + }) + + it('never routes a walkover winner into a match that cannot be reached', () => { + const matches = generate(count) + const fed = fedSlots(matches) + + for (const match of matches) { + const abandoned = + match.registration1Id === null && + match.registration2Id === null && + !fed.has(`${match.id}#1`) && + !fed.has(`${match.id}#2`) + + // An unreachable match must not claim to send anyone anywhere. + if (abandoned) { + expect(match.winnerTo).toBeNull() + expect(match.loserTo).toBeNull() + } + } + }) +}) + +describe('seeding', () => { + it.each([4, 8, 16, 32, 64])( + 'sends the top two seeds to the winners final for %i participants', + (count) => { + const matches = generate(count) + const { played } = simulate(matches, chalk) + + const winnersFinal = played.find( + (p) => + p.match.bracketType === 'winners' && + p.match.round === Math.log2(count) + ) + + expect(winnersFinal).toBeDefined() + expect([winnersFinal!.winner, winnersFinal!.loser].sort()).toEqual([ + 'player-1', + 'player-2', + ]) + } + ) + + it('gives the top seeds the byes', () => { + const matches = generate(5) + const byes = matches.filter( + (m) => + m.bracketType === 'winners' && + m.round === 1 && + (m.registration1Id === null) !== (m.registration2Id === null) + ) + + const advanced = byes.map((m) => m.registration1Id ?? m.registration2Id) + expect(advanced.sort()).toEqual(['player-1', 'player-2', 'player-3']) + }) +}) + +describe('rematch prevention', () => { + // Rematches cannot be eliminated entirely, but they must stay rare and late. + it.each([ + [16, 4], + [32, 5], + [64, 7], + ])( + 'keeps rematches out of the early losers rounds for %i participants', + (count, earliestAllowedRound) => { + let total = 0 + let earliest = Number.POSITIVE_INFINITY + + for (let seed = 1; seed <= 200; seed++) { + const { rematches } = simulate( + generate(count), + seededPicker(seed * 7919) + ) + total += rematches.length + for (const rematch of rematches) { + earliest = Math.min(earliest, rematch.round) + } + } + + expect(earliest).toBeGreaterThanOrEqual(earliestAllowedRound) + expect(total / 200).toBeLessThan(1) + } + ) +}) + +describe('performance', () => { + it('generates an 8192 player bracket without quadratic blowup', () => { + const start = performance.now() + generate(8192) + const elapsed = performance.now() - start + + // ~70ms on a laptop. The bound is deliberately loose so a shared CI runner + // does not flake, while still catching an accidental O(n^2) rewrite, which + // costs seconds at this size. + expect(elapsed).toBeLessThan(3000) + }) +}) diff --git a/tests/validation.test.ts b/tests/validation.test.ts new file mode 100644 index 0000000..9dbcdc4 --- /dev/null +++ b/tests/validation.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest' +import { generateDoubleElimination } from '../src' +import { createIdFactory, createParticipants, roundOf } from './helpers' + +const base = { + eventId: 'event-1', + participants: createParticipants(8), + idFactory: createIdFactory(), +} + +describe('option validation', () => { + it('rejects fewer than 2 participants', () => { + expect(() => + generateDoubleElimination({ + ...base, + participants: createParticipants(1), + }) + ).toThrow('At least 2 participants required') + }) + + it('rejects a missing eventId', () => { + expect(() => generateDoubleElimination({ ...base, eventId: '' })).toThrow( + 'eventId must be a non-empty string' + ) + }) + + it('rejects an idFactory that is not a function', () => { + expect(() => + // @ts-expect-error deliberately wrong type + generateDoubleElimination({ ...base, idFactory: 'nope' }) + ).toThrow('idFactory must be a function') + }) + + it('rejects an idFactory that repeats ids', () => { + expect(() => + generateDoubleElimination({ ...base, idFactory: () => 'same-id' }) + ).toThrow('duplicate id') + }) + + it('rejects duplicate seeds', () => { + expect(() => + generateDoubleElimination({ + ...base, + participants: [ + { registrationId: 'a', seed: 1 }, + { registrationId: 'b', seed: 1 }, + ], + }) + ).toThrow('Duplicate seed: 1') + }) + + it('rejects duplicate registration ids', () => { + expect(() => + generateDoubleElimination({ + ...base, + participants: [ + { registrationId: 'a', seed: 1 }, + { registrationId: 'a', seed: 2 }, + ], + }) + ).toThrow('Duplicate registrationId: "a"') + }) + + it('rejects non-numeric seeds', () => { + expect(() => + generateDoubleElimination({ + ...base, + participants: [ + { registrationId: 'a', seed: 1 }, + // @ts-expect-error deliberately wrong type + { registrationId: 'b', seed: 'two' }, + ], + }) + ).toThrow('non-numeric seed') + }) + + it('rejects a fractional losersStartRoundsBeforeFinal', () => { + expect(() => + generateDoubleElimination({ ...base, losersStartRoundsBeforeFinal: 1.5 }) + ).toThrow('non-negative integer') + }) + + it('rejects an unknown grandFinal format', () => { + expect(() => + // @ts-expect-error deliberately wrong type + generateDoubleElimination({ ...base, grandFinal: 'bracket-reset' }) + ).toThrow("grandFinal must be 'none', 'single' or 'reset'") + }) + + it('rejects a grand final without a losers bracket', () => { + expect(() => + generateDoubleElimination({ + ...base, + losersStartRoundsBeforeFinal: 0, + grandFinal: 'single', + }) + ).toThrow('grandFinal requires a losers bracket') + }) +}) + +describe('seed normalization', () => { + it('ranks arbitrary ascending seeds instead of dropping participants', () => { + const matches = generateDoubleElimination({ + ...base, + idFactory: createIdFactory(), + participants: [ + { registrationId: 'a', seed: 10 }, + { registrationId: 'b', seed: 20 }, + { registrationId: 'c', seed: 30 }, + { registrationId: 'd', seed: 40 }, + ], + }) + + const placed = roundOf(matches, 'winners', 1).flatMap((m) => [ + m.registration1Id, + m.registration2Id, + ]) + + expect(placed).toEqual(['a', 'd', 'b', 'c']) + }) + + it('seeds identically whatever order participants arrive in', () => { + const ordered = generateDoubleElimination({ + ...base, + idFactory: createIdFactory(), + participants: createParticipants(8), + }) + const shuffled = generateDoubleElimination({ + ...base, + idFactory: createIdFactory(), + participants: [...createParticipants(8)].reverse(), + }) + + const layout = (matches: typeof ordered) => + roundOf(matches, 'winners', 1).map((m) => [ + m.registration1Id, + m.registration2Id, + ]) + + expect(layout(shuffled)).toEqual(layout(ordered)) + }) +}) diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..eb461ef --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node10", + "outDir": "dist/cjs" + } +} diff --git a/tsconfig.json b/tsconfig.json index 3ae1def..cb9d063 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,24 @@ { "compilerOptions": { "target": "ES2020", + "lib": ["ES2020"], "module": "ESNext", "moduleResolution": "bundler", "declaration": true, - "outDir": "dist", + "declarationMap": true, + "sourceMap": true, + "outDir": "dist/esm", "rootDir": "src", "strict": true, + "noUncheckedIndexedAccess": false, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "esModuleInterop": true, - "skipLibCheck": true + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "verbatimModuleSyntax": false }, "include": ["src"], - "exclude": ["node_modules", "dist", "tests"] + "exclude": ["node_modules", "dist", "tests", "website"] } - diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..bd59f51 --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["node"] + }, + "include": ["src", "tests", "scripts", "demo.ts"] +} From ea22d8a2d9b1724ed1b267efaafda81f66cfc40e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 14:09:09 +0000 Subject: [PATCH 2/3] Add round robin and single elimination, release 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package generated one format; it now generates three, all returning the same match objects so one renderer, one table and one results screen serve all of them. Round robin (`generateRoundRobin`): - Circle-method fixtures, seeded so the top two seeds meet in the final round and round 1 opens with the widest mismatch - Sides split as evenly as the round count allows — exactly even when everyone plays an even number of games, off by one otherwise - Odd fields rest one participant per round rather than producing empty matches - `legs` replays the fixtures with the sides swapped, for home-and-away seasons - `groupCount` splits the field into snake-seeded groups, each playing its own round robin, with group sizes within one of each other Standings (`calculateStandings`): - Points, wins, draws, losses, scores and ranks from whatever results exist so far; fixtures without a result count as unplayed - Results may carry scores or just a winner (`winnerId: null` records a draw) - Configurable points table, and tiebreakers applied in order — each only to the rows the previous one left level, as a real rulebook works - Head-to-head, score difference, score for, wins and seed; participants nothing separates share a rank Single elimination (`generateSingleElimination`) with an optional `thirdPlaceMatch`, previously reachable only through `losersStartRoundsBeforeFinal: 0`, and `generateTournament({ format, ... })` for applications that store the format as data. Breaking changes, all covered in the migration notes: - `bracketType` gains `'grandFinal'` and `'roundRobin'` - every match carries `group` and `leg` - `BracketMatch` → `TournamentMatch`, `BracketType` → `MatchType`, `GeneratorOptions` → `DoubleEliminationOptions`, old names kept as aliases Also here: the unreleased bye, packaging, routing and validation fixes now ship as part of 2.0.0, the README and EXAMPLES are rewritten around the three formats, the demo prints any of them, and the website demos them all with a live standings table. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M6MiM4MbcS4dhfwzbZBi4K --- CHANGELOG.md | 72 +- CONTRIBUTING.md | 12 +- EXAMPLES.md | 830 +++++++++-------- README.md | 831 +++++++++++------- demo.ts | 174 +++- package-lock.json | 4 +- package.json | 37 +- src/calculateStandings.ts | 365 ++++++++ src/createGrandFinal.ts | 27 +- src/createLosersBracket.ts | 32 +- src/createWinnersBracket.ts | 30 +- src/generateDoubleElimination.ts | 40 +- src/generateRoundRobin.ts | 84 ++ src/generateSingleElimination.ts | 26 + src/generateTournament.ts | 40 + src/index.ts | 26 +- src/participants.ts | 123 +++ src/planBracket.ts | 74 +- src/resolveByes.ts | 18 +- src/roundRobinSchedule.ts | 84 ++ src/types.ts | 152 +++- src/wireLoserRouting.ts | 6 +- tests/byes.test.ts | 18 + tests/formats.test.ts | 217 +++++ tests/roundRobin.test.ts | 302 +++++++ tests/standings.test.ts | 371 ++++++++ website/src/components/BracketDemo.tsx | 333 +++++-- .../src/components/BracketVisualization.tsx | 85 +- website/src/components/CodeExample.tsx | 78 +- website/src/components/Features.tsx | 40 +- website/src/components/Hero.tsx | 11 +- .../components/RoundRobinVisualization.tsx | 230 +++++ 32 files changed, 3649 insertions(+), 1123 deletions(-) create mode 100644 src/calculateStandings.ts create mode 100644 src/generateRoundRobin.ts create mode 100644 src/generateSingleElimination.ts create mode 100644 src/generateTournament.ts create mode 100644 src/participants.ts create mode 100644 src/roundRobinSchedule.ts create mode 100644 tests/formats.test.ts create mode 100644 tests/roundRobin.test.ts create mode 100644 tests/standings.test.ts create mode 100644 website/src/components/RoundRobinVisualization.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f58ba82..4b146b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.3.0] - 2026-09-12 +## [2.0.0] - 2026-09-12 + +Three formats instead of one. `double-elimination` now generates single +elimination and round robin tournaments as well, all returning the same match +shape, plus a standings calculator for league and group play. + +### Added + +- **Round robin** via `generateRoundRobin`: circle-method fixtures seeded so the + top two seeds meet in the final round, sides balanced as evenly as the round + count allows, rests instead of empty matches for odd fields, multi-leg seasons + (`legs`) that replay fixtures with the sides swapped, and snake-seeded group + stages (`groupCount`). +- **Standings** via `calculateStandings`: points, wins, draws, losses, scores + and ranks from whatever results exist so far, with a configurable points table + and tiebreakers (`headToHead`, `scoreDifference`, `scoreFor`, `wins`, `seed`) + applied in order, each only to the rows the previous one left level. + Participants nothing separates share a rank. +- **Single elimination** via `generateSingleElimination`, with an optional + `thirdPlaceMatch`. Previously this was only reachable through + `losersStartRoundsBeforeFinal: 0`. +- **`generateTournament({ format, ... })`**, a discriminated union over all three + formats, for applications that store the format as data. +- **Grand final support** for double elimination via the `grandFinal` option: + - `'none'` (default) — the winners final decides 1st/2nd and the losers final + decides 3rd/4th, as in 1.x + - `'single'` — the winners final loser drops to the losers final and the two + bracket winners meet once for the title + - `'reset'` — as `'single'`, plus a bracket reset match, played only when the + losers bracket representative wins the first grand final +- A `LICENSE` file, a CI workflow running typecheck, tests, build and a + published-entry-point check on Node 18, 20 and 22, and an `npm run demo` + script that prints any tournament the package can generate. ### Fixed @@ -19,7 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 receive one player are bypassed so the feeding match points at what came next. Unreachable matches are kept in the returned array with empty slots and no routing, so round and position numbering stays stable for rendering. -- **Published package now loads in Node**: the build emitted ES module syntax +- **The published package now loads in Node**: the build emitted ES module syntax into a package with no `"type": "module"` and extensionless relative imports, so both `require('double-elimination')` and `import` failed outside a bundler. The package now ships separate ESM and CommonJS builds behind an `exports` map, @@ -40,32 +72,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Exact bracket sizing**: `nextPowerOf2` used `Math.log2`, which is not guaranteed to be exact for large inputs; sizing is now integer arithmetic. -### Added - -- **Grand final support** via the `grandFinal` option: - - `'none'` (default) — unchanged behaviour: the winners final decides 1st/2nd - and the losers final decides 3rd/4th - - `'single'` — the winners final loser drops to the losers final and the two - bracket winners meet once for the title - - `'reset'` — as `'single'`, plus a bracket reset match, played only when the - losers bracket representative wins the first grand final - Grand final matches are returned with `bracketType: 'grandFinal'`. -- `BracketType` and `GrandFinalFormat` are exported. -- A `LICENSE` file, a CI workflow running typecheck, tests, build and a - published-entry-point check on Node 18, 20 and 22, and an `npm run demo` - script. - ### Changed -- `bracketType` is now `'winners' | 'losers' | 'grandFinal'`. TypeScript code - that exhaustively narrows on it will need a `grandFinal` case, even though no - such match is produced unless the option is enabled. +- **`bracketType` is now `'winners' | 'losers' | 'grandFinal' | 'roundRobin'`.** + TypeScript code that exhaustively narrows on it needs the new cases, even + though neither value appears unless you ask for it. +- **Every match carries `group` and `leg`**, `null` and `1` respectively outside + a round robin. Persisting matches with a strict schema means adding the + columns or dropping the fields. +- **Types renamed**, with the old names kept as deprecated aliases: + `BracketMatch` → `TournamentMatch`, `BracketType` → `MatchType`, + `GeneratorOptions` → `DoubleEliminationOptions`. - Brackets of 32 or more participants have a different losers bracket layout because of the routing fix above. A bracket generated with an earlier version will not match one regenerated with this version — finish in-flight tournaments on the version that created them. - The package is published as ESM with a CommonJS fallback; `src` is published alongside `dist` so source maps resolve. +- The package description and keywords now cover all three formats. The npm + name stays `double-elimination`. + +### Migration + +`generateDoubleElimination` takes the same options and produces the same +brackets for 16 participants or fewer, so most upgrades are just the install. +See [Migrating from 1.x](README.md#migrating-from-1x) for the four things to +check. ## [1.2.2] - 2024-12-16 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bb8433..9df3534 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,12 +128,19 @@ are in opposite halves for large brackets. ``` double-elimination/ ├── src/ # Source TypeScript files +│ ├── types.ts # The shared match shape and every option type +│ ├── participants.ts # Shared validation, seeding ranks, match factory +│ ├── generateTournament.ts # Format dispatcher │ ├── planBracket.ts # Option validation and derived bracket sizes │ ├── createWinnersBracket.ts # Winners bracket skeleton and wiring │ ├── createLosersBracket.ts # Losers bracket skeleton and wiring │ ├── createGrandFinal.ts # Grand final and bracket reset │ ├── wireLoserRouting.ts # Winners -> losers bracket routing │ ├── resolveByes.ts # Walkover resolution across the bracket +│ ├── roundRobinSchedule.ts # Circle method and snake seeding +│ ├── generateRoundRobin.ts # Round robin fixtures, legs and groups +│ ├── calculateStandings.ts # League tables and tiebreakers +│ ├── generateSingleElimination.ts │ └── generateDoubleElimination.ts ├── tests/ # Test files (helpers.ts holds a bracket simulator) ├── scripts/ # Build and package verification scripts @@ -148,7 +155,10 @@ double-elimination/ Changes to bracket structure should come with a test in `tests/invariants.test.ts`: it plays every bracket size out with random results and checks that nothing stalls, that every player but the champion accumulates -two losses, and that rematches stay rare and late. +two losses, and that rematches stay rare and late. Round robin scheduling has +the equivalent in `tests/roundRobin.test.ts`, which checks across field sizes +that every pair meets exactly once per leg, nobody is scheduled twice in a +round, and the sides stay balanced. ## Areas for Contribution diff --git a/EXAMPLES.md b/EXAMPLES.md index 0923d32..94c19e1 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1,585 +1,543 @@ # Examples -This document provides comprehensive examples of using the `double-elimination` package for various tournament scenarios. +Practical recipes for `double-elimination`. For the full API see the +[README](README.md). ## Table of Contents -- [Basic Tournament Setup](#basic-tournament-setup) -- [Small Tournament (4-8 Participants)](#small-tournament-4-8-participants) -- [Medium Tournament (16-32 Participants)](#medium-tournament-16-32-participants) -- [Large Tournament (64+ Participants)](#large-tournament-64-participants) -- [Single Elimination Mode](#single-elimination-mode) -- [Delayed Losers Bracket](#delayed-losers-bracket) -- [Handling Odd Participant Counts](#handling-odd-participant-counts) -- [Grand Final](#grand-final) -- [Integration with Database](#integration-with-database) -- [Visualizing Brackets](#visualizing-brackets) +- [Setup](#setup) +- [Single Elimination](#single-elimination) +- [Double Elimination](#double-elimination) +- [Round Robin](#round-robin) +- [Standings](#standings) +- [Group Stage into a Playoff Bracket](#group-stage-into-a-playoff-bracket) +- [Picking a Format at Runtime](#picking-a-format-at-runtime) +- [Odd Participant Counts](#odd-participant-counts) +- [Recording Results](#recording-results) +- [Saving to a Database](#saving-to-a-database) +- [Rendering a Bracket](#rendering-a-bracket) - [Custom ID Generation](#custom-id-generation) -## Basic Tournament Setup +## Setup -### Simple 8-Player Tournament +Every example uses these helpers: ```typescript -import { generateDoubleElimination } from 'double-elimination' - -const participants = [ - { registrationId: 'player-1', seed: 1 }, - { registrationId: 'player-2', seed: 2 }, - { registrationId: 'player-3', seed: 3 }, - { registrationId: 'player-4', seed: 4 }, - { registrationId: 'player-5', seed: 5 }, - { registrationId: 'player-6', seed: 6 }, - { registrationId: 'player-7', seed: 7 }, - { registrationId: 'player-8', seed: 8 }, -] - -const matches = generateDoubleElimination({ - eventId: 'tournament-2024-01', - participants, - idFactory: () => crypto.randomUUID(), -}) - -console.log(`Generated ${matches.length} matches`) -// Output: Generated 12 matches - -// Filter winners bracket matches -const winnersMatches = matches.filter((m) => m.bracketType === 'winners') -console.log(`Winners bracket: ${winnersMatches.length} matches`) -// Output: Winners bracket: 7 matches +import { + calculateStandings, + generateDoubleElimination, + generateRoundRobin, + generateSingleElimination, + generateTournament, + type TournamentMatch, +} from 'double-elimination' + +const createParticipants = (count: number) => + Array.from({ length: count }, (_, i) => ({ + registrationId: `player-${i + 1}`, + seed: i + 1, + })) -// Filter losers bracket matches -const losersMatches = matches.filter((m) => m.bracketType === 'losers') -console.log(`Losers bracket: ${losersMatches.length} matches`) -// Output: Losers bracket: 5 matches +const idFactory = () => crypto.randomUUID() ``` -## Small Tournament (4-8 Participants) - -### 4-Player Tournament +## Single Elimination -Perfect for small local tournaments or testing: +### A straight knockout ```typescript -const smallTournament = generateDoubleElimination({ - eventId: 'local-tournament', - participants: [ - { registrationId: 'alice', seed: 1 }, - { registrationId: 'bob', seed: 2 }, - { registrationId: 'charlie', seed: 3 }, - { registrationId: 'diana', seed: 4 }, - ], - idFactory: () => crypto.randomUUID(), +const cup = generateSingleElimination({ + eventId: 'friday-night-cup', + participants: createParticipants(16), + idFactory, }) -// Get first round matches -const round1 = smallTournament.filter( - (m) => m.round === 1 && m.bracketType === 'winners', -) -console.log('Round 1 matchups:') -round1.forEach((match) => { - console.log(` ${match.registration1Id} vs ${match.registration2Id}`) -}) -// Output: -// alice vs diana -// bob vs charlie +console.log(cup.length) // 15 — every match eliminates exactly one player +console.log(new Set(cup.map((m) => m.round)).size) // 4 rounds ``` -## Medium Tournament (16-32 Participants) - -### 16-Player Esports Tournament +### With a third place match ```typescript -// Generate participants from team data -const teams = [ - { id: 'team-alpha', rank: 1 }, - { id: 'team-beta', rank: 2 }, - { id: 'team-gamma', rank: 3 }, - // ... 13 more teams -] - -const participants = teams.map((team) => ({ - registrationId: team.id, - seed: team.rank, -})) - -const esportsBracket = generateDoubleElimination({ - eventId: 'esports-championship-2024', - participants, - idFactory: () => crypto.randomUUID(), -}) - -// Organize matches by round -const matchesByRound = esportsBracket.reduce( - (acc, match) => { - const key = `${match.bracketType}-round-${match.round}` - if (!acc[key]) acc[key] = [] - acc[key].push(match) - return acc - }, - {} as Record, -) - -console.log('Tournament structure:') -Object.keys(matchesByRound).forEach((key) => { - console.log(` ${key}: ${matchesByRound[key].length} matches`) +const cup = generateSingleElimination({ + eventId: 'friday-night-cup', + participants: createParticipants(8), + idFactory, + thirdPlaceMatch: true, }) -``` -### 32-Player Tournament +// The third place match is the only non-winners match in the array +const thirdPlace = cup.find((m) => m.bracketType === 'losers')! -```typescript -// Create 32 participants -const createParticipants = (count: number) => { - return Array.from({ length: count }, (_, i) => ({ - registrationId: `player-${i + 1}`, - seed: i + 1, - })) -} - -const largeBracket = generateDoubleElimination({ - eventId: 'mega-tournament', - participants: createParticipants(32), - idFactory: () => crypto.randomUUID(), -}) - -console.log(`Total matches: ${largeBracket.length}`) -// Output: Total matches: 60 (31 winners + 29 losers) +// Both semifinals feed it +const semifinals = cup.filter( + (m) => m.bracketType === 'winners' && m.round === 2 +) +console.log(semifinals.every((m) => m.loserTo === thirdPlace.id)) // true ``` -## Large Tournament (64+ Participants) +## Double Elimination -### 64-Player Championship +### Standard, with a grand final and bracket reset ```typescript -const championship = generateDoubleElimination({ - eventId: 'world-championship-2024', - participants: createParticipants(64), - idFactory: () => crypto.randomUUID(), +const major = generateDoubleElimination({ + eventId: 'spring-major', + participants: createParticipants(16), + idFactory, + grandFinal: 'reset', }) -// Calculate tournament statistics -const stats = { - totalMatches: championship.length, - winnersMatches: championship.filter((m) => m.bracketType === 'winners') - .length, - losersMatches: championship.filter((m) => m.bracketType === 'losers').length, - totalRounds: Math.max(...championship.map((m) => m.round)), +const counts = { + winners: major.filter((m) => m.bracketType === 'winners').length, // 15 + losers: major.filter((m) => m.bracketType === 'losers').length, // 14 + grandFinal: major.filter((m) => m.bracketType === 'grandFinal').length, // 2 } - -console.log('Tournament Statistics:', stats) -// Output: -// { -// totalMatches: 124, -// winnersMatches: 63, -// losersMatches: 61, -// totalRounds: 9 // the losers bracket runs 9 rounds; the winners bracket 6 -// } ``` -## Single Elimination Mode +### Deciding whether the reset is played -### Pure Single Elimination +```typescript +const [grandFinal, reset] = major + .filter((m) => m.bracketType === 'grandFinal') + .sort((a, b) => a.round - b.round) -No losers bracket - one loss and you're out: +const reportGrandFinal = (winnerId: string) => { + // Slot 2 is the losers bracket representative. + const fromLosersBracket = winnerId === grandFinal.registration2Id -```typescript -const singleElimination = generateDoubleElimination({ - eventId: 'single-elim-tournament', - participants: createParticipants(16), - idFactory: () => crypto.randomUUID(), - losersStartRoundsBeforeFinal: 0, // No losers bracket -}) + if (!fromLosersBracket) { + // The winners bracket representative is unbeaten: skip the reset. + return { champion: winnerId, resetMatch: null } + } -// Only winners bracket matches exist -const allWinners = singleElimination.every((m) => m.bracketType === 'winners') -console.log(`All matches are winners bracket: ${allWinners}`) -// Output: All matches are winners bracket: true + // Both finalists now have one loss each, so they play again. + return { champion: null, resetMatch: reset } +} ``` -### Single Elimination with 3rd Place Match - -Only semifinal losers compete for 3rd place: +### Without a grand final (the 1.x default) ```typescript -const withThirdPlace = generateDoubleElimination({ - eventId: 'tournament-with-third', +// Winners final decides 1st/2nd, losers final decides 3rd/4th +const bracket = generateDoubleElimination({ + eventId: 'club-night', participants: createParticipants(8), - idFactory: () => crypto.randomUUID(), - losersStartRoundsBeforeFinal: 1, // Only semifinal losers + idFactory, }) -// Check losers bracket exists -const hasLosersBracket = withThirdPlace.some((m) => m.bracketType === 'losers') -console.log(`Has losers bracket: ${hasLosersBracket}`) -// Output: Has losers bracket: true +console.log(bracket.length) // 12 ``` -## Delayed Losers Bracket +### Delayed losers bracket -### 16-Player Tournament with Delayed LB - -Losers bracket starts at quarterfinals: +Early rounds are single elimination; only the later losers get a second life. ```typescript -const delayedLB = generateDoubleElimination({ - eventId: 'delayed-lb-tournament', +const compressed = generateDoubleElimination({ + eventId: 'one-day-event', participants: createParticipants(16), - idFactory: () => crypto.randomUUID(), - losersStartRoundsBeforeFinal: 2, // QF and SF losers go to LB + idFactory, + losersStartRoundsBeforeFinal: 2, // QF and SF losers drop }) -// Round 1 losers are eliminated (no LB matches for them) -const round1Losers = delayedLB.filter( - (m) => m.round === 1 && m.bracketType === 'winners', +const ro16 = compressed.filter( + (m) => m.bracketType === 'winners' && m.round === 1 ) -console.log(`Round 1 matches: ${round1Losers.length}`) -// Round 1 losers don't have corresponding LB matches +console.log(ro16.every((m) => m.loserTo === null)) // true — lose and you are out ``` -## Handling Odd Participant Counts - -### Tournament with 7 Participants +## Round Robin -Byes are resolved at generation time, so nothing in the bracket waits on a -player who does not exist: +### A league season, home and away ```typescript -const oddCount = generateDoubleElimination({ - eventId: 'odd-participants', - participants: createParticipants(7), // 7 players, bracket size = 8 - idFactory: () => crypto.randomUUID(), +const season = generateRoundRobin({ + eventId: 'league-2026', + participants: createParticipants(10), + idFactory, + legs: 2, }) -// A bye is a first round match with exactly one participant. -const byes = oddCount.filter( - (m) => - m.bracketType === 'winners' && - m.round === 1 && - (m.registration1Id === null) !== (m.registration2Id === null), -) +console.log(season.length) // 90 matches +console.log(Math.max(...season.map((m) => m.round))) // 18 rounds + +// Round 1's fixtures, ready to publish +const opening = season + .filter((m) => m.round === 1) + .sort((a, b) => a.bracketPosition - b.bracketPosition) + .map((m) => `${m.registration1Id} v ${m.registration2Id}`) +``` + +### Group stage + +```typescript +const groups = generateRoundRobin({ + eventId: 'world-cup', + participants: createParticipants(32), + idFactory, + groupCount: 8, // 8 groups of 4, snake seeded +}) -console.log(`Byes: ${byes.length}`) -// Output: Byes: 1 (seed 1 gets a bye in round 1) +const groupA = groups.filter((m) => m.group === 0) +console.log(groupA.length) // 6 matches -// The bye winner is already placed in the next round, and the match produces -// no loser, so it does not hold a losers bracket slot. -console.log(byes[0].loserTo) // null +// Who is in each group +const membersOf = (group: number) => [ + ...new Set( + groups + .filter((m) => m.group === group) + .flatMap((m) => [m.registration1Id!, m.registration2Id!]) + ), +] ``` -### Tournament with 13 Participants +### An odd field ```typescript -const thirteenPlayers = generateDoubleElimination({ - eventId: 'thirteen-players', - participants: createParticipants(13), // 13 players, bracket size = 16 - idFactory: () => crypto.randomUUID(), +const nine = generateRoundRobin({ + eventId: 'club-ladder', + participants: createParticipants(9), + idFactory, }) -const byes = thirteenPlayers.filter( - (m) => - m.bracketType === 'winners' && - m.round === 1 && - (m.registration1Id === null) !== (m.registration2Id === null), -) +console.log(nine.length) // 36 matches over 9 rounds -console.log(`Byes: ${byes.length}`) -// Output: Byes: 3 (seeds 1, 2, 3 get byes) +// Whoever has no fixture this round is resting +const resting = (round: number) => { + const playing = new Set( + nine + .filter((m) => m.round === round) + .flatMap((m) => [m.registration1Id!, m.registration2Id!]) + ) + return createParticipants(9) + .map((p) => p.registrationId) + .filter((id) => !playing.has(id)) +} ``` -### Telling "waiting" apart from "never happening" +## Standings -With byes, some losers bracket matches cannot be reached at all. They stay in -the array so positions remain stable, but they carry no routing: +### A league table ```typescript -const fed = new Set( - matches.flatMap((m) => [ - m.winnerTo ? `${m.winnerTo}#${m.winnerToSlot}` : null, - m.loserTo ? `${m.loserTo}#${m.loserToSlot}` : null, - ]), -) +const results = [ + { matchId: season[0].id, score1: 2, score2: 1 }, + { matchId: season[1].id, score1: 0, score2: 0 }, +] -const isUnused = (match: BracketMatch) => - match.registration1Id === null && - match.registration2Id === null && - !fed.has(`${match.id}#1`) && - !fed.has(`${match.id}#2`) +const table = calculateStandings({ matches: season, results }) -const playable = matches.filter((m) => !isUnused(m)) +for (const row of table) { + console.log( + `${row.rank}. ${row.registrationId} ` + + `${row.played} ${row.won} ${row.drawn} ${row.lost} ` + + `${row.scoreFor}:${row.scoreAgainst} ${row.points}pts` + ) +} ``` -## Grand Final +Matches without a result simply count as unplayed, so the table is correct at +any point in the season. -### Standard Double Elimination +### Results without scores ```typescript -const matches = generateDoubleElimination({ - eventId: 'fighting-game-major', - participants: createParticipants(16), - idFactory: () => crypto.randomUUID(), - grandFinal: 'reset', +const table = calculateStandings({ + matches: season, + results: [ + { matchId: season[0].id, winnerId: 'player-3' }, + { matchId: season[1].id, winnerId: null }, // a draw + ], }) +``` -const [grandFinal, reset] = matches - .filter((m) => m.bracketType === 'grandFinal') - .sort((a, b) => a.round - b.round) +### Custom points and tiebreakers + +```typescript +// Three points for a win, and goal difference outranks head-to-head +const table = calculateStandings({ + matches: season, + results, + points: { win: 3, draw: 1, loss: 0 }, + tiebreakers: ['scoreDifference', 'scoreFor', 'headToHead'], +}) -// Slot 1 is the winners bracket representative, slot 2 the losers bracket one. -console.log(grandFinal.round) // 1 -console.log(reset.round) // 2 +// A format where a loss costs you +const table2 = calculateStandings({ + matches: season, + results, + points: { win: 2, draw: 0, loss: -1 }, +}) ``` -### Deciding Whether the Reset Is Played +### Qualifiers from a group stage ```typescript -const reportGrandFinal = (winnerId: string) => { - const fromLosersBracket = winnerId === grandFinal.registration2Id +const table = calculateStandings({ matches: groups, results }) - if (!fromLosersBracket) { - // The winners bracket representative is unbeaten: the reset is not played. - return { champion: winnerId, resetRequired: false } - } +// Top two from every group +const qualifiers = table.filter((row) => row.rank <= 2) - // Both finalists now have one loss each, so they play again. - return { champion: null, resetRequired: true } -} +// Best third-placed teams across groups +const thirds = table + .filter((row) => row.rank === 3) + .sort((a, b) => b.points - a.points || b.scoreDifference - a.scoreDifference) + .slice(0, 4) ``` -## Integration with Database +## Group Stage into a Playoff Bracket -### Saving Matches to Database +The common real-world shape: pools first, then a knockout bracket seeded by how +teams finished. ```typescript -import { generateDoubleElimination, BracketMatch } from 'double-elimination' - -async function createTournament(eventId: string, participants: Participant[]) { - // Generate bracket - const matches = generateDoubleElimination({ - eventId, - participants, - idFactory: () => crypto.randomUUID(), - }) - - // Save to database - await db.matches.insertMany( - matches.map((match) => ({ - id: match.id, - eventId: match.eventId, - round: match.round, - matchNumber: match.matchNumber, - player1Id: match.registration1Id, - player2Id: match.registration2Id, - bracketPosition: match.bracketPosition, - winnerToMatchId: match.winnerTo, - winnerToSlot: match.winnerToSlot, - loserToMatchId: match.loserTo, - loserToSlot: match.loserToSlot, - bracketType: match.bracketType, - status: 'pending', - createdAt: new Date(), - })), - ) +const groupStage = generateRoundRobin({ + eventId: 'champions-league-2026', + participants: createParticipants(32), + idFactory, + groupCount: 8, +}) - return matches -} -``` +// ...play the group stage, collecting results... -### Loading Participants from Database +const table = calculateStandings({ matches: groupStage, results }) -```typescript -async function generateBracketFromDatabase(eventId: string) { - // Load participants from database - const registrations = await db.registrations - .find({ - eventId, - status: 'confirmed', - }) - .sort({ seed: 1 }) - - const participants = registrations.map((reg) => ({ - registrationId: reg.id, - seed: reg.seed, +// Group winners are seeded above runners-up, then by points +const qualifiers = table + .filter((row) => row.rank <= 2) + .sort( + (a, b) => + a.rank - b.rank || + b.points - a.points || + b.scoreDifference - a.scoreDifference + ) + .map((row, index) => ({ + registrationId: row.registrationId, + seed: index + 1, })) - // Generate bracket - return generateDoubleElimination({ - eventId, - participants, - idFactory: () => crypto.randomUUID(), - }) -} +const playoffs = generateDoubleElimination({ + eventId: 'champions-league-2026-playoffs', + participants: qualifiers, + idFactory, + grandFinal: 'single', +}) ``` -## Visualizing Brackets +Store the two stages as separate tournaments sharing an `eventId` prefix; each +is a self-contained set of matches. + +## Picking a Format at Runtime -### Group Matches by Round and Type +When the format comes from a form or a database column, use `generateTournament` +and keep the branching in one place. ```typescript -function organizeBracket(matches: BracketMatch[]) { - const organized: Record = {} - - matches.forEach((match) => { - const key = `${match.bracketType}-round-${match.round}` - if (!organized[key]) { - organized[key] = [] - } - organized[key].push(match) - }) - - // Sort matches within each round by bracket position - Object.keys(organized).forEach((key) => { - organized[key].sort((a, b) => a.bracketPosition - b.bracketPosition) - }) - - return organized +type Event = { + id: string + format: 'single-elimination' | 'double-elimination' | 'round-robin' + entrants: { registrationId: string; seed: number }[] } -const organized = organizeBracket(matches) - -// Display bracket structure -Object.keys(organized) - .sort() - .forEach((key) => { - console.log(`\n${key.toUpperCase()}:`) - organized[key].forEach((match) => { - const p1 = match.registration1Id || 'BYE' - const p2 = match.registration2Id || 'BYE' - console.log(` Match ${match.matchNumber}: ${p1} vs ${p2}`) - }) - }) +const buildTournament = (event: Event) => + generateTournament({ + format: event.format, + eventId: event.id, + participants: event.entrants, + idFactory, + // Format-specific options are type-checked against the chosen format + ...(event.format === 'round-robin' + ? { legs: 2 } + : { grandFinal: 'single' }), + } as Parameters[0]) ``` -### Find Match Dependencies +## Odd Participant Counts + +Byes are resolved at generation time, so nothing waits on a player who does not +exist. ```typescript -function getMatchDependencies(matches: BracketMatch[], matchId: string) { - const match = matches.find((m) => m.id === matchId) - if (!match) return null +const bracket = generateDoubleElimination({ + eventId: 'odd-field', + participants: createParticipants(7), // bracket size 8 + idFactory, +}) - const dependencies: BracketMatch[] = [] +// A bye is a first round match with exactly one participant +const byes = bracket.filter( + (m) => + m.bracketType === 'winners' && + m.round === 1 && + (m.registration1Id === null) !== (m.registration2Id === null) +) - // Find matches that feed into this match - matches.forEach((m) => { - if (m.winnerTo === matchId || m.loserTo === matchId) { - dependencies.push(m) - } - }) +console.log(byes.length) // 1 — seed 1 gets it +console.log(byes[0].loserTo) // null — a walkover has no loser +``` - return { - match, - dependencies, - } -} +### Telling "waiting" apart from "never happening" -// Example: Find what matches feed into the winners bracket finals. -// Round numbers restart per bracket, so compare within the winners bracket: -// the losers bracket runs more rounds than the winners bracket. -const winners = matches.filter((m) => m.bracketType === 'winners') -const finalsMatch = winners.find( - (m) => m.round === Math.max(...winners.map((w) => w.round)), +With byes, some matches cannot be reached at all, and some will only ever see +one player. Both come down to whether an empty slot will ever be filled: + +```typescript +const fed = new Set( + bracket.flatMap((m) => [ + m.winnerTo ? `${m.winnerTo}#${m.winnerToSlot}` : null, + m.loserTo ? `${m.loserTo}#${m.loserToSlot}` : null, + ]) ) -if (finalsMatch) { - const deps = getMatchDependencies(matches, finalsMatch.id) - console.log('Finals dependencies:', deps?.dependencies.length) + +const willFill = (match: TournamentMatch, slot: 1 | 2) => + (slot === 1 ? match.registration1Id : match.registration2Id) !== null || + fed.has(`${match.id}#${slot}`) + +const statusOf = (match: TournamentMatch) => { + const [one, two] = [willFill(match, 1), willFill(match, 2)] + if (!one && !two) return 'unused' // nobody can reach it + if (!one || !two) return 'walkover' // one entrant, no opponent coming + return 'playable' } + +const schedule = bracket.filter((m) => statusOf(m) === 'playable') ``` -## Custom ID Generation +Walkovers are resolved for you wherever the advancing player is already known — +their slot in the next match is filled before the bracket is returned. The +exception is a walkover whose entrant is still unknown, which only happens in +brackets small enough for a bye to reach the last match (the third place match +in a 3-participant tournament, for instance). -### Using Database IDs +## Recording Results + +Brackets carry their own wiring, so one helper covers every bracket format. ```typescript -let matchCounter = 0 +const applyResult = ( + matches: TournamentMatch[], + matchId: string, + winnerId: string +) => { + const match = matches.find((m) => m.id === matchId) + if (!match) throw new Error(`unknown match: ${matchId}`) + + const loserId = + winnerId === match.registration1Id + ? match.registration2Id + : match.registration1Id + + const place = ( + targetId: string | null, + slot: number | null, + who: string | null + ) => { + if (!targetId || !slot || !who) return + const target = matches.find((m) => m.id === targetId)! + if (slot === 1) target.registration1Id = who + else target.registration2Id = who + } -const matches = generateDoubleElimination({ - eventId: 'tournament-1', - participants: createParticipants(8), - idFactory: () => { - matchCounter++ - return `match-${Date.now()}-${matchCounter}` - }, -}) + place(match.winnerTo, match.winnerToSlot, winnerId) + place(match.loserTo, match.loserToSlot, loserId) + + return matches +} ``` -### Using UUID Library +Round robin matches have no routing, so recording a result there means storing +it and recalculating the standings. -```typescript -import { v4 as uuidv4 } from 'uuid' +## Saving to a Database -const matches = generateDoubleElimination({ - eventId: 'tournament-1', - participants: createParticipants(8), - idFactory: () => uuidv4(), +The match shape maps directly onto a table, whatever the format. + +```typescript +await db.matches.createMany({ + data: matches.map((match) => ({ + id: match.id, + eventId: match.eventId, + round: match.round, + matchNumber: match.matchNumber, + bracketPosition: match.bracketPosition, + bracketType: match.bracketType, + groupIndex: match.group, + leg: match.leg, + registration1Id: match.registration1Id, + registration2Id: match.registration2Id, + winnerTo: match.winnerTo, + winnerToSlot: match.winnerToSlot, + loserTo: match.loserTo, + loserToSlot: match.loserToSlot, + })), }) ``` -### Using Sequential IDs +Because `idFactory` is yours, you can hand out ids your database already +understands: ```typescript -function createSequentialIdFactory(prefix: string) { - let counter = 0 - return () => `${prefix}-${++counter}` -} - const matches = generateDoubleElimination({ - eventId: 'tournament-1', - participants: createParticipants(8), - idFactory: createSequentialIdFactory('MATCH'), + eventId: event.id, + participants, + idFactory: () => cuid(), }) ``` -## Advanced Use Cases +## Rendering a Bracket -### Tournament with Custom Seeding +### Group matches by bracket and round ```typescript -// Custom seeding based on rating -const players = [ - { id: 'player-a', rating: 2500 }, - { id: 'player-b', rating: 2400 }, - { id: 'player-c', rating: 2300 }, - // ... more players -] +const organize = (matches: TournamentMatch[]) => { + const columns = new Map() + + for (const match of matches) { + const key = + match.group === null + ? `${match.bracketType}-${match.round}` + : `group-${match.group}-${match.round}` + columns.set(key, [...(columns.get(key) ?? []), match]) + } -// Sort by rating and assign seeds -const participants = players - .sort((a, b) => b.rating - a.rating) - .map((player, index) => ({ - registrationId: player.id, - seed: index + 1, - })) + for (const [, column] of columns) { + column.sort((a, b) => a.bracketPosition - b.bracketPosition) + } -const matches = generateDoubleElimination({ - eventId: 'rated-tournament', - participants, - idFactory: () => crypto.randomUUID(), -}) + return columns +} ``` -### Multiple Tournaments +### Find what feeds a match ```typescript -function generateMultipleTournaments( - events: Array<{ eventId: string; participants: Participant[] }>, -) { - return events.map((event) => - generateDoubleElimination({ - eventId: event.eventId, - participants: event.participants, - idFactory: () => crypto.randomUUID(), - }), - ) -} +const feedersOf = (matches: TournamentMatch[], matchId: string) => + matches.filter((m) => m.winnerTo === matchId || m.loserTo === matchId) -const tournaments = generateMultipleTournaments([ - { - eventId: 'tournament-1', - participants: createParticipants(8), - }, - { - eventId: 'tournament-2', - participants: createParticipants(16), - }, -]) +// Round numbers restart per bracket, so compare within one bracket: the losers +// bracket runs more rounds than the winners bracket. +const winners = matches.filter((m) => m.bracketType === 'winners') +const final = winners.find( + (m) => m.round === Math.max(...winners.map((w) => w.round)) +)! + +console.log(feedersOf(matches, final.id).length) // 2 +``` + +## Custom ID Generation + +```typescript +// Sequential, for readable test fixtures +let counter = 0 +const sequential = () => `match-${++counter}` + +// UUIDs +const uuid = () => crypto.randomUUID() + +// Your ORM's id generator +const ormIds = () => cuid() ``` + +Any factory works as long as it never repeats — a repeat throws rather than +cross-wiring the bracket. diff --git a/README.md b/README.md index 6db0350..490d4c7 100644 --- a/README.md +++ b/README.md @@ -7,475 +7,678 @@ [![GitHub stars](https://img.shields.io/github/stars/nadersafa1/double-elimination.svg?style=social)](https://github.com/nadersafa1/double-elimination) [![Live Demo](https://img.shields.io/badge/Live_Demo-Try_it!-6366f1.svg)](https://nadersafa1.github.io/double-elimination/) -A TypeScript library for generating double elimination tournament brackets with automatic seeding and bye handling. Perfect for esports tournaments, sports competitions, and any competitive event management system. +Generate a whole tournament from a list of participants — **double elimination**, +**single elimination** or **round robin** — with standard seeding, byes handled +end to end, optional grand finals, group stages and league standings. + +Every format returns the same match objects, so one renderer, one database table +and one results screen serve all three. + +```typescript +import { generateTournament } from 'double-elimination' + +const matches = generateTournament({ + format: 'double-elimination', + eventId: 'spring-major', + participants, + idFactory: () => crypto.randomUUID(), + grandFinal: 'reset', +}) +``` + +> **Why the name?** The package started as a double elimination generator and +> keeps the name on npm. It now covers three formats — see +> [Choosing a format](#choosing-a-format). ## Table of Contents -- [Why Double Elimination?](#why-double-elimination) -- [When to Use This Package](#when-to-use-this-package) - [Features](#features) - [Installation](#installation) - [Quick Start](#quick-start) -- [Use Cases](#use-cases) +- [Choosing a Format](#choosing-a-format) +- [The Match Shape](#the-match-shape) - [API](#api) -- [Bracket Structure](#bracket-structure) -- [Match Routing](#match-routing) -- [Bye Handling](#bye-handling) -- [Delayed Losers Bracket and Single Elimination](#delayed-losers-bracket-and-single-elimination) -- [Grand Final](#grand-final) + - [generateTournament](#generatetournamentoptions) + - [generateSingleElimination](#generatesingleeliminationoptions) + - [generateDoubleElimination](#generatedoubleeliminationoptions) + - [generateRoundRobin](#generateroundrobinoptions) + - [calculateStandings](#calculatestandingsoptions) +- [Double Elimination](#double-elimination) +- [Round Robin](#round-robin) - [Seeding](#seeding) -- [Example Output](#example-output) +- [Bye Handling](#bye-handling) - [Performance](#performance) +- [Migrating from 1.x](#migrating-from-1x) - [Contributing](#contributing) - [License](#license) -## Why Double Elimination? +## Features -Double elimination tournaments are the gold standard for competitive events because they: +- ✅ **Three formats, one shape** — double elimination, single elimination and + round robin all return the same `TournamentMatch[]` +- ✅ **Standard seeding** — seeds 1 and 2 can only meet in the final, seeds 1–4 + only in the semifinals, and so on +- ✅ **Byes handled end to end** — odd participant counts run to completion + instead of stalling on an opponent who never arrives +- ✅ **Grand final and bracket reset** — optional, so the losers bracket winner + gets a real shot at the title +- ✅ **Rematch prevention** — rotating loser routing keeps players away from + opponents they already beat +- ✅ **Round robin done properly** — seeded fixtures, balanced sides, multi-leg + seasons, snake-seeded group stages +- ✅ **Standings with tiebreakers** — points, head-to-head, score difference, + score for, wins, seed — in the order your rules say +- ✅ **Validated input** — duplicate seeds, duplicate ids and repeated match ids + throw instead of corrupting the tournament +- ✅ **Zero dependencies**, ESM and CommonJS builds, full TypeScript types -- **Ensure fairness**: Players must lose twice to be eliminated, reducing the impact of bad luck or upsets -- **Provide accurate rankings**: Top 4 placements are determined through structured competition -- **Increase engagement**: More matches mean more content and viewer engagement -- **Reduce early elimination**: Strong players who face tough early matchups get a second chance +## Installation -This library implements the standard double elimination format used in major -esports tournaments, fighting game competitions, and sports events worldwide — -including the grand final and bracket reset when you want them. +```bash +npm install double-elimination +``` -## When to Use This Package +Works on Node 18+ and in any bundler; `import` and `require` both resolve. -Use `double-elimination` when you need to: +## Quick Start -- **Build tournament management systems** for esports, sports, or gaming platforms -- **Generate bracket structures** programmatically with proper seeding -- **Handle variable participant counts** with automatic bye management -- **Support multiple tournament formats** including single elimination and delayed double elimination -- **Ensure fair matchups** using standard tournament seeding algorithms -- **Integrate tournament brackets** into existing applications or websites +Every generator takes the same three things: an `eventId` copied onto each +match, your `participants`, and an `idFactory` that returns unique match ids. -Perfect for developers building: +```typescript +import { + generateSingleElimination, + generateDoubleElimination, + generateRoundRobin, + calculateStandings, +} from 'double-elimination' + +const participants = [ + { registrationId: 'player-1', seed: 1 }, + { registrationId: 'player-2', seed: 2 }, + { registrationId: 'player-3', seed: 3 }, + { registrationId: 'player-4', seed: 4 }, +] + +const options = { + eventId: 'tournament-1', + participants, + idFactory: () => crypto.randomUUID(), +} -- Esports tournament platforms -- Sports competition management systems -- Gaming tournament organizers -- Bracket visualization tools -- Tournament scheduling applications +// One loss and you are out +const cup = generateSingleElimination({ ...options, thirdPlaceMatch: true }) -## Features +// Two losses to go out, with a grand final and bracket reset +const major = generateDoubleElimination({ ...options, grandFinal: 'reset' }) -- ✅ **Complete bracket generation** - Winners bracket, losers bracket, and optional grand final -- ✅ **Standard tournament seeding** - Ensures seeds 1 and 2 can only meet in finals, seeds 1-4 can only meet in semifinals, etc. -- ✅ **Automatic bye handling** - Byes are resolved through the whole bracket, so odd participant counts still run to completion -- ✅ **Flexible tournament formats** - Double elimination with or without a grand final and bracket reset, single elimination, and delayed losers bracket -- ✅ **Rematch prevention** - Rotating loser routing keeps players away from opponents they already beat -- ✅ **Validated input** - Duplicate seeds, duplicate ids, and repeated match ids throw instead of corrupting the bracket -- ✅ **TypeScript support** - Full type definitions, ESM and CommonJS builds -- ✅ **Zero dependencies** - Lightweight and fast -- ✅ **Configurable ID generation** - Use any ID factory function +// Everyone plays everyone, home and away +const league = generateRoundRobin({ ...options, legs: 2 }) -## Installation +// ...and once results come in +const table = calculateStandings({ + matches: league, + results: [{ matchId: league[0].id, score1: 2, score2: 1 }], +}) +``` -```bash -npm install double-elimination +## Choosing a Format + +| | **Single elimination** | **Double elimination** | **Round robin** | +| ---------------------- | -------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------- | +| Losses to go out | 1 | 2 | — everyone plays everyone | +| Matches for 16 players | 15 | 28, or 30 with a grand final | 120 (240 over two legs) | +| Rounds for 16 players | 4 | 4 winners + 5 losers | 15 | +| Ranks produced | 1st, 2nd (+3rd with a third place match) | 1st – 4th | a full table | +| Good for | tight schedules, large fields, knockout days | fair results without doubling the field's time | leagues, group stages, small fields who came to play | +| Bad luck early | ends your day | costs you the winners bracket | costs you three points | + +Mixing formats is common and fully supported: run `generateRoundRobin` with +`groupCount` for the group stage, then feed the qualifiers into +`generateDoubleElimination` as a new tournament with fresh seeds. + +## The Match Shape + +Every generator returns `TournamentMatch[]`: + +```typescript +interface TournamentMatch { + id: string + eventId: string + round: number // 1-based, counted within bracketType (and group) + matchNumber: number // 1-based within the round + bracketPosition: number // 0-based within the round, top to bottom + registration1Id: string | null + registration2Id: string | null + winnerTo: string | null // match the winner advances to + winnerToSlot: number | null // slot (1 or 2) they take there + loserTo: string | null // match the loser drops to + loserToSlot: number | null + bracketType: 'winners' | 'losers' | 'grandFinal' | 'roundRobin' + group: number | null // 0-based group index in a group stage + leg: number // 1-based; above 1 only in multi-leg round robins +} ``` -Ships both ES module and CommonJS builds with TypeScript types, so `import` and -`require` both work on Node 18+ and in any bundler. +| Field | Brackets | Round robin | +| ------------------------------------- | ------------------------------ | ---------------------------------------- | +| `registration1Id` / `registration2Id` | filled in as rounds are played | both known up front | +| `winnerTo` / `loserTo` | where the players go next | always `null` | +| `group` | `null` | group index, or `null` for a single pool | +| `leg` | `1` | which time round the field this is | -## Quick Start +### Applying a result + +Brackets carry their own wiring, so recording a result is the same three lines +whatever the format: ```typescript -import { generateDoubleElimination } from 'double-elimination' +const applyResult = ( + matches: TournamentMatch[], + matchId: string, + winnerId: string +) => { + const match = matches.find((m) => m.id === matchId)! + const loserId = + winnerId === match.registration1Id + ? match.registration2Id + : match.registration1Id + + const place = ( + targetId: string | null, + slot: number | null, + who: string | null + ) => { + if (!targetId || !slot || !who) return + const target = matches.find((m) => m.id === targetId)! + if (slot === 1) target.registration1Id = who + else target.registration2Id = who + } + + place(match.winnerTo, match.winnerToSlot, winnerId) + place(match.loserTo, match.loserToSlot, loserId) +} +``` -const matches = generateDoubleElimination({ - eventId: 'tournament-1', - participants: [ - { registrationId: 'player-1', seed: 1 }, - { registrationId: 'player-2', seed: 2 }, - { registrationId: 'player-3', seed: 3 }, - { registrationId: 'player-4', seed: 4 }, - ], +Round robin matches have no routing — rank them with +[`calculateStandings`](#calculatestandingsoptions) instead. + +## API + +### `generateTournament(options)` + +Generates whichever format `options.format` names. Use it when the format is +data — a column in your database, a value from a form — rather than something +known when the code is written. + +```typescript +generateTournament({ + format: 'round-robin', // 'single-elimination' | 'double-elimination' | 'round-robin' + eventId: 'league-2026', + participants, idFactory: () => crypto.randomUUID(), + legs: 2, // format-specific options come along too }) ``` -## API +The options are a discriminated union, so TypeScript offers exactly the options +that format accepts and rejects the others. + +### Shared options + +| Option | Type | Description | +| -------------- | --------------- | ------------------------------------------------------ | +| `eventId` | `string` | Identifier for the tournament, copied onto every match | +| `participants` | `Participant[]` | `{ registrationId, seed }`, at least 2 | +| `idFactory` | `() => string` | Returns a unique id per match | + +Seeds must be unique but need not be `1..N` — participants are ranked by seed, +so `[10, 20, 30]` seeds identically to `[1, 2, 3]`. Duplicate seeds, duplicate +`registrationId`s and a repeating `idFactory` all throw rather than quietly +producing a broken tournament. + +### `generateSingleElimination(options)` + +| Option | Type | Default | Description | +| ----------------- | --------- | ------- | ------------------------------------------------------------------------------------ | +| `thirdPlaceMatch` | `boolean` | `false` | Adds a match between the two semifinal losers, returned with `bracketType: 'losers'` | + +Produces `bracketSize - 1` matches — one more with `thirdPlaceMatch` — where +the bracket size is the next power of two at or above the participant count. ### `generateDoubleElimination(options)` -Generates all matches for a double elimination bracket. +| Option | Type | Default | Description | +| ------------------------------ | ------------------------------- | ---------- | ------------------------------------------------------------------------ | +| `grandFinal` | `'none' \| 'single' \| 'reset'` | `'none'` | Whether the bracket winners meet, and whether a bracket reset can follow | +| `losersStartRoundsBeforeFinal` | `number` | all rounds | Start the losers bracket later, eliminating early losers outright | -#### Options +See [Double Elimination](#double-elimination) for the structure it produces. -| Property | Type | Description | -| ------------------------------ | ------------------------------- | -------------------------------------------------------------- | -| `eventId` | `string` | Identifier for the tournament event | -| `participants` | `Participant[]` | Array of participants with seeds | -| `idFactory` | `() => string` | Function that returns unique IDs for matches | -| `losersStartRoundsBeforeFinal` | `number?` | Rounds before finals where LB begins (min: 0). See below. | -| `grandFinal` | `'none' \| 'single' \| 'reset'` | Whether to add a grand final. Defaults to `'none'`. See below. | +### `generateRoundRobin(options)` -#### Returns +| Option | Type | Default | Description | +| ------------ | -------- | ------- | -------------------------------------------------------------------------- | +| `legs` | `number` | `1` | How many times everyone plays everyone; later legs swap sides | +| `groupCount` | `number` | `1` | Split the field into snake-seeded groups, each playing its own round robin | -`BracketMatch[]` - Array of all matches in the bracket +See [Round Robin](#round-robin) for scheduling details. -### Types +### `calculateStandings(options)` + +Builds a standings table from whatever results exist so far. ```typescript -interface Participant { +const table = calculateStandings({ + matches, // the fixtures to rank + results, // [{ matchId, score1, score2 }] or [{ matchId, winnerId }] + participants, // optional: enables the 'seed' tiebreaker + points: { win: 3, draw: 1, loss: 0 }, + tiebreakers: ['headToHead', 'scoreDifference', 'scoreFor', 'wins'], +}) +``` + +| Option | Type | Default | Description | +| -------------- | ----------------------- | ------------------------------------------------------- | ------------------------------------------------------------- | +| `matches` | `TournamentMatch[]` | — | The fixtures to rank. Group stages are ranked per group | +| `results` | `MatchResult[]` | — | Results so far; fixtures without one count as unplayed | +| `participants` | `Participant[]` | `[]` | Needed only for the `seed` tiebreaker and to order level rows | +| `points` | `Partial` | `{ win: 3, draw: 1, loss: 0 }` | Points per outcome | +| `tiebreakers` | `Tiebreaker[]` | `['headToHead', 'scoreDifference', 'scoreFor', 'wins']` | Applied in order to participants level on points | + +Returns one `Standing` per participant per group, ordered by group and then by +rank: + +```typescript +interface Standing { registrationId: string - seed: number + group: number | null + rank: number // 1-based; level participants share a rank + played: number + won: number + drawn: number + lost: number + scoreFor: number + scoreAgainst: number + scoreDifference: number + points: number } +``` -interface BracketMatch { - id: string - eventId: string - round: number - matchNumber: number - registration1Id: string | null - registration2Id: string | null - bracketPosition: number - winnerTo: string | null - winnerToSlot: number | null - loserTo: string | null - loserToSlot: number | null - bracketType: 'winners' | 'losers' | 'grandFinal' -} +Results come in two shapes, and you can mix them: + +```typescript +{ matchId, score1: 3, score2: 1 } // scores: higher wins, equal is a draw +{ matchId, winnerId: 'player-7' } // outcome only +{ matchId, winnerId: null } // a draw with no score recorded ``` -Seeds must be unique but need not be `1..N` — participants are ranked by seed, -so `[10, 20, 30]` seeds identically to `[1, 2, 3]`. Duplicate seeds, duplicate -`registrationId`s and a non-unique `idFactory` throw instead of silently -producing a broken bracket. +A result naming an unknown match, a duplicate result, half a scoreline, or a +winner who did not play in that match all throw. -## Bracket Structure +## Double Elimination ### Placements By default there is no grand final: the winners final decides 1st/2nd and the losers final decides 3rd/4th. -| Match | Winner | Loser | -| ---------------------- | --------- | --------- | -| Winners Bracket Finals | 1st Place | 2nd Place | -| Losers Bracket Finals | 3rd Place | 4th Place | +| Match | Winner | Loser | +| --------------------- | --------- | --------- | +| Winners bracket final | 1st place | 2nd place | +| Losers bracket final | 3rd place | 4th place | -With `grandFinal: 'single'` or `'reset'` the bracket runs as a standard double +With `grandFinal: 'single'` or `'reset'` it runs as a standard double elimination instead — the winners final loser drops into the losers final, and the two bracket winners meet: -| Match | Winner | Loser | -| ---------------------- | ------------- | --------------- | -| Winners Bracket Finals | → Grand Final | → Losers Finals | -| Losers Bracket Finals | → Grand Final | 3rd Place | -| Grand Final | 1st Place | 2nd Place | +| Match | Winner | Loser | +| --------------------- | ------------- | -------------- | +| Winners bracket final | → grand final | → losers final | +| Losers bracket final | → grand final | 3rd place | +| Grand final | 1st place | 2nd place | -### Match Counts +### Match counts -For `N` participants: +For a bracket size `B` (the next power of two at or above the participant +count): -- Bracket size = next power of 2 ≥ N -- Winners rounds = log₂(bracket_size) -- Losers rounds = (winners_rounds - 1) × 2 - 1, plus one more with a grand final +| | Winners | Losers | Grand final | Total | +| ---------------------- | ------- | ------- | ------------------- | -------- | +| `grandFinal: 'none'` | `B - 1` | `B - 3` | — | `2B - 4` | +| `grandFinal: 'single'` | `B - 1` | `B - 2` | 1 | `2B - 2` | +| `grandFinal: 'reset'` | `B - 1` | `B - 2` | 2 (one conditional) | `2B - 1` | -| Participants | Bracket Size | Winners | Losers | Total | Total with `grandFinal: 'single'` | -| ------------ | ------------ | ------- | ------ | ----- | --------------------------------- | -| 4 | 4 | 3 | 1 | 4 | 6 | -| 5-8 | 8 | 7 | 5 | 12 | 14 | -| 9-16 | 16 | 15 | 13 | 28 | 30 | +| Participants | Bracket size | No grand final | With `'single'` | +| ------------ | ------------ | -------------- | --------------- | +| 4 | 4 | 4 | 6 | +| 5–8 | 8 | 12 | 14 | +| 9–16 | 16 | 28 | 30 | +| 17–32 | 32 | 60 | 62 | Matches are returned winners bracket first, then losers bracket, then grand final, each ordered by round and then by `bracketPosition`. -## Match Routing - -### Winners Bracket +### Match routing -- **Winner routing**: Position `P` → next round, position `⌊P/2⌋`, slot `(P % 2) + 1` -- **Loser routing**: Drops to the losers bracket. Without a grand final the - winners final loser is 2nd place; with one they drop to the losers final. A - walkover has no loser, so its `loserTo` is `null`. +**Winners bracket** — winner of position `P` goes to the next round at position +`⌊P/2⌋`, slot `(P % 2) + 1`. The loser drops to the losers bracket; without a +grand final the winners final loser is 2nd place, and a walkover has no loser +at all, so its `loserTo` is `null`. -### Losers Bracket +**Losers bracket** — round 1 pairs off the first wave of losers. After that, +even rounds take a fresh wave of winners bracket losers into slot 2 and hold as +many matches as the round before them; odd rounds are played between losers +bracket survivors only, halving the match count. -- Round 1 pairs off the first wave of winners bracket losers -- Even rounds receive a fresh wave of winners bracket losers into slot 2, so - they hold as many matches as the round before them -- Odd rounds after round 1 are played between losers bracket survivors only, - halving the match count -- Without a grand final, the losers final winner is 3rd and the loser is 4th +**Crossover ordering** — a player dropping into the losers bracket must not +immediately run into someone they already beat, so each wave of losers is +reordered before it drops in, and the ordering rotates from wave to wave: -### Cross-Bracket Matchups +| Wave of losers | Ordering | +| -------------- | --------------------------------- | +| 1st | paired up (`⌊position / 2⌋`) | +| 2nd | reversed | +| 3rd | reversed and shifted by half | +| 4th | shifted by half | +| 5th | unchanged, then the cycle repeats | -A player dropping into the losers bracket must not immediately run into someone -they already beat, so each wave of winners bracket losers is reordered before it -is dropped in. The ordering rotates from one wave to the next: +Rotating matters most in large brackets. Measured over 200 random 64-player +tournaments, it pushes the first possible rematch from losers round 4 out to +losers round 7 and cuts rematches from ~2.8 per tournament to ~0.4. A rematch +in the last losers rounds is unavoidable in any double elimination bracket. -| Losers entering | Ordering applied | -| ----------------------------- | --------------------------------- | -| 1st wave (first feeder round) | Paired up (`⌊position / 2⌋`) | -| 2nd wave | Reversed | -| 3rd wave | Reversed and shifted by half | -| 4th wave | Shifted by half | -| 5th wave | Unchanged, then the cycle repeats | - -Rotating the ordering matters most in large brackets. Measured over 200 random -64-player tournaments, rotating pushes the first possible rematch from losers -round 4 out to losers round 7, and cuts rematches from ~2.8 per tournament to -~0.4. A rematch in the last losers rounds is unavoidable in any double -elimination bracket. - -## Bye Handling - -When the participant count isn't a power of 2, byes are created and resolved at -generation time — through the whole bracket, not just the first round: +### Grand final and bracket reset ```typescript -// 7 participants in a bracket of 8 = 1 bye const matches = generateDoubleElimination({ eventId: 'event-1', - participants: createParticipants(7), // Seeds 1-7 + participants, idFactory: () => crypto.randomUUID(), + grandFinal: 'reset', }) - -// Seed 1 vs Seed 8 (missing) = Seed 1 is already placed in round 2 ``` -Three things follow from a bye, and all of them are handled for you: - -- **The advancing player is pre-placed.** Their slot in the next match is filled - before the bracket is returned. -- **A walkover produces no loser.** The match's `loserTo` is `null`, so nothing - waits on a loser that never arrives. -- **Losers bracket matches that would only ever get one player are skipped.** - The feeding match is re-pointed at whatever came after them, so the losers - bracket always runs to completion. +Enabling a grand final adds one losers bracket round — the losers final, where +the winners final loser enters — plus the grand final itself, returned with +`bracketType: 'grandFinal'`. -A match that nobody can reach stays in the returned array with empty slots and -no routing (`winnerTo`, `loserTo`, and both slots are `null`), which keeps -round and position numbering stable for rendering. To tell "waiting for an -opponent" apart from "nobody is coming", check whether anything feeds the slot: +With `'reset'` the grand final is two matches, `round: 1` and `round: 2`. Match +2 is **only played when the losers bracket representative wins match 1**; +otherwise the winners bracket representative is champion with an unbeaten +record and match 2 is dropped. Both finalists are routed into it (`winnerTo` +into slot 1, `loserTo` into slot 2) so the usual propagation works unchanged — +your code decides whether it happens: ```typescript -const fed = new Set( - matches.flatMap((m) => [ - m.winnerTo ? `${m.winnerTo}#${m.winnerToSlot}` : null, - m.loserTo ? `${m.loserTo}#${m.loserToSlot}` : null, - ]), -) +const [grandFinal, reset] = matches + .filter((m) => m.bracketType === 'grandFinal') + .sort((a, b) => a.round - b.round) -const isUnused = (match: BracketMatch) => - match.registration1Id === null && - match.registration2Id === null && - !fed.has(`${match.id}#1`) && - !fed.has(`${match.id}#2`) +// slot 2 of the grand final is the losers bracket representative +const resetRequired = grandFinalWinnerId === grandFinal.registration2Id ``` -## Delayed Losers Bracket and Single Elimination - -By default, all losers (except finals) drop to the losers bracket. Use `losersStartRoundsBeforeFinal` to start the losers bracket later - early round losers are permanently eliminated. +A grand final needs a losers bracket, so it cannot be combined with +`losersStartRoundsBeforeFinal: 0`, and needs at least 3 participants. -### Single Elimination Modes +### Delayed losers bracket -You can create pure single elimination or single elimination with a 3rd place match: +By default every loser except the finalist drops to the losers bracket. Use +`losersStartRoundsBeforeFinal` to open it later, so early losers are eliminated +outright — useful when a full double elimination would not fit the schedule. ```typescript -// Pure single elimination (no losers bracket) +// 16 players: Ro16 is single elimination, QF and SF losers get a second life const matches = generateDoubleElimination({ eventId: 'event-1', - participants: createParticipants(8), + participants, idFactory: () => crypto.randomUUID(), - losersStartRoundsBeforeFinal: 0, // No losers bracket -}) - -// Single elimination with 3rd place match (only semifinal losers) -const matches = generateDoubleElimination({ - eventId: 'event-1', - participants: createParticipants(8), - idFactory: () => crypto.randomUUID(), - losersStartRoundsBeforeFinal: 1, // Only semifinal losers go to LB + losersStartRoundsBeforeFinal: 2, }) ``` -### Delayed Double Elimination +| WB round | Name | Loser's fate | +| -------- | ----- | ----------------------- | +| Round 1 | Ro16 | eliminated | +| Round 2 | QF | drops to losers round 1 | +| Round 3 | SF | drops to losers round 2 | +| Round 4 | Final | 2nd place | -For delayed double elimination, use values >= 2: +| Value | Result | +| ------------------- | ------------------------------------------------------------------------------------------------- | +| `0` | Pure single elimination — prefer [`generateSingleElimination`](#generatesingleeliminationoptions) | +| `1` | Single elimination with a third place match (needs at least 3 participants) | +| `2+` | Delayed double elimination | +| `winnersRounds - 1` | The default: full double elimination | + +## Round Robin + +Everyone plays everyone. Both participants are known when the fixture is +generated, so there is no routing to follow — record results and rank them with +[`calculateStandings`](#calculatestandingsoptions). ```typescript -// 16 players with losers bracket starting at Quarter-Finals -const matches = generateDoubleElimination({ - eventId: 'event-1', - participants: createParticipants(16), +const fixtures = generateRoundRobin({ + eventId: 'league-2026', + participants, // 10 clubs idFactory: () => crypto.randomUUID(), - losersStartRoundsBeforeFinal: 2, // QF and SF losers go to LB + legs: 2, // home and away }) +// 90 matches across 18 rounds ``` -### How It Works +### Scheduling -For 16 participants (4 WB rounds) with `losersStartRoundsBeforeFinal: 2`: +Fixtures are built with the circle method: one entrant stays put while the rest +rotate around them, which pairs everyone exactly once in the fewest rounds +possible. -| WB Round | Name | Loser Fate | -| -------- | ------ | ---------------------------- | -| Round 1 | Ro16 | **Eliminated** (single elim) | -| Round 2 | QF | Drops to LB R1 | -| Round 3 | SF | Drops to LB R2 | -| Round 4 | Finals | 2nd Place | +- **Rounds per leg** — `n - 1` for an even field, `n` for an odd one +- **Matches per round** — `⌊n / 2⌋` +- **Odd fields** — each participant sits out exactly one round; a rest is simply + the absence of a fixture, never a match with an empty slot +- **Seeded** — entrants enter the circle in seed order, so the top two seeds + meet in the final round and round 1 opens with the widest mismatch +- **Balanced sides** — `registration1Id` is the "home" side, and the schedule + splits sides as evenly as the round count allows: exactly even when everyone + plays an even number of games, off by one otherwise -### Constraints +### Legs -- **Minimum value: 0** - Pure single elimination (no losers bracket) -- **Value: 1** - Single elimination with optional 3rd place match (requires at least 3 participants) -- **Value: 2+** - Delayed double elimination -- **Maximum value: winnersRounds - 1** - Cannot exceed available feeder rounds +`legs: 2` replays every fixture with the sides swapped, which is how a +home-and-away season is built and leaves every participant with a perfectly even +split of sides. Round numbering continues across legs (a 6-team, 2-leg season +runs rounds 1–10), and each match carries the `leg` it belongs to. -## Grand Final +### Group stages -By default the winners final decides 1st and 2nd place. Pass `grandFinal` to run -a standard double elimination instead, where the winners final loser drops to -the losers final and the two bracket winners meet: +`groupCount` splits the field into groups that each play their own round robin. +Participants are distributed by snake seeding, so group strength stays even: + +``` +Seeds 1 2 3 4 → groups A B C D +Seeds 5 6 7 8 → groups D C B A +``` ```typescript -// One grand final match -const matches = generateDoubleElimination({ - eventId: 'event-1', - participants: createParticipants(8), +const groupStage = generateRoundRobin({ + eventId: 'world-cup', + participants, // 32 teams idFactory: () => crypto.randomUUID(), - grandFinal: 'single', + groupCount: 8, // 8 groups of 4 }) -// Grand final plus bracket reset -const withReset = generateDoubleElimination({ - eventId: 'event-1', - participants: createParticipants(8), - idFactory: () => crypto.randomUUID(), - grandFinal: 'reset', -}) +const groupA = groupStage.filter((m) => m.group === 0) ``` -Enabling it adds one losers bracket round (the losers final, where the winners -final loser enters) and the grand final itself, which is returned with -`bracketType: 'grandFinal'`. +Group sizes stay within one participant of each other, rounds are numbered from +1 within each group, and no fixture ever crosses groups. `group` is `null` when +there is only one pool. -### Bracket Reset - -With `grandFinal: 'reset'` the grand final is two matches, `round: 1` and -`round: 2`. Match 2 is **only played when the losers bracket representative wins -match 1** — otherwise the winners bracket representative is champion with an -unbeaten record and match 2 is dropped. - -Both finalists are routed into the reset match (`winnerTo` into slot 1, -`loserTo` into slot 2) so the usual propagation works unchanged. Your code -decides whether the match happens: +### Standings and tiebreakers ```typescript -const [grandFinal, reset] = matches - .filter((m) => m.bracketType === 'grandFinal') - .sort((a, b) => a.round - b.round) +const table = calculateStandings({ matches: groupStage, results }) -// slot 2 of the grand final is the losers bracket representative -const resetRequired = grandFinalWinnerId === grandFinal.registration2Id +const qualifiers = table.filter((row) => row.rank <= 2) ``` -### Constraints - -`grandFinal` requires a losers bracket, so it cannot be combined with -`losersStartRoundsBeforeFinal: 0`, and needs at least 3 participants. +Rows are ranked on points first, then by each tiebreaker in turn — and each +tiebreaker only applies to the rows the previous one left level, exactly as a +real competition rulebook works: -## Seeding +| Tiebreaker | Compares | +| ----------------- | --------------------------------------------------------------------------------------------- | +| `headToHead` | points, then score difference, in the matches the tied participants played against each other | +| `scoreDifference` | `scoreFor - scoreAgainst` | +| `scoreFor` | total scored | +| `wins` | number of wins | +| `seed` | the stronger seed ranks higher — a deterministic last resort | -The library uses standard tournament seeding to ensure fair bracket placement: +Participants nothing can separate share a rank (`1, 2, 2, 4`) and are listed +strongest seed first. To follow a rulebook that puts goal difference before +head-to-head, just say so: -- **Seeds 1 and 2** can only meet in the Finals -- **Seeds 1-4** can only meet in Semifinals or later -- **Seeds 1-8** can only meet in Quarterfinals or later +```typescript +calculateStandings({ + matches, + results, + tiebreakers: ['scoreDifference', 'scoreFor', 'headToHead'], +}) +``` -For 8 participants, Round 1 matchups are: `1v8, 4v5, 2v7, 3v6` +## Seeding -For 32 participants: +Brackets use standard tournament seeding, so the strongest seeds are kept apart +for as long as possible: -- Seed 1 is in matches 0-7 (top half) -- Seed 2 is in matches 8-15 (bottom half) -- Seeds 3-4 are in opposite quarters from seeds 1-2 +- **Seeds 1 and 2** can only meet in the final +- **Seeds 1–4** can only meet in the semifinals or later +- **Seeds 1–8** can only meet in the quarterfinals or later -Participants are ranked by seed before placement, so the seed values only need -to be unique and correctly ordered — `[10, 20, 30]` and `[1, 2, 3]` produce the -same bracket. Byes go to the strongest seeds. +For 8 participants, round 1 is `1v8, 4v5, 2v7, 3v6`. For 32, seed 1 leads the +top half, seed 2 the bottom half, and seeds 3–4 sit in the opposite quarters +from them. -## Use Cases +Participants are ranked by seed before placement, so seed values only need to be +unique and correctly ordered — `[10, 20, 30]` and `[1, 2, 3]` produce the same +tournament. Byes go to the strongest seeds. -### Esports Tournament Platform +## Bye Handling -Generate brackets for competitive gaming tournaments with proper seeding and fair matchups. +When the participant count isn't a power of 2, byes are created and resolved at +generation time — through the whole bracket, not just the first round: ```typescript -const esportsBracket = generateDoubleElimination({ - eventId: 'valorant-championship-2024', - participants: teams.map((team, index) => ({ - registrationId: team.id, - seed: team.rank, - })), +// 7 participants in a bracket of 8 = 1 bye +const matches = generateDoubleElimination({ + eventId: 'event-1', + participants, // seeds 1-7 idFactory: () => crypto.randomUUID(), }) -``` - -### Sports Competition Management - -Create tournament brackets for sports leagues, ensuring top seeds don't meet until later rounds. -### Gaming Tournament Organizer - -Run local or online gaming tournaments with automatic bracket generation and bye handling. - -### Bracket Visualization +// Seed 1 vs seed 8 (missing) = seed 1 is already placed in round 2 +``` -Generate bracket data for visualization libraries like D3.js, React components, or custom renderers. +Three things follow from a bye, and all of them are handled for you: -## Example Output +- **The advancing player is pre-placed.** Their slot in the next match is filled + before the bracket is returned. +- **A walkover produces no loser.** The match's `loserTo` is `null`, so nothing + waits on a loser that never arrives. +- **Losers bracket matches that would only ever get one player are skipped.** + The feeding match is re-pointed at whatever came after them, so the losers + bracket always runs to completion. -For 8 participants: +A match that nobody can reach stays in the returned array with empty slots and +no routing, which keeps round and position numbering stable for rendering. The +question your UI actually needs answered is whether an empty slot will ever be +filled — a slot nobody feeds and nobody occupies never will: -``` -WINNERS BRACKET: -Round 1: [1v8, 4v5, 2v7, 3v6] → losers drop to LB R1 -Round 2: [R1 winners] → losers drop to LB R2 -Round 3: [Finals] → winner=1st, loser=2nd - -LOSERS BRACKET: -Round 1: [WB R1 losers pair up] -Round 2: [LB R1 winners + WB R2 losers] -Round 3: [Finals] → winner=3rd, loser=4th -``` +```typescript +const fed = new Set( + matches.flatMap((m) => [ + m.winnerTo ? `${m.winnerTo}#${m.winnerToSlot}` : null, + m.loserTo ? `${m.loserTo}#${m.loserToSlot}` : null, + ]) +) -With `grandFinal: 'single'`: +const willFill = (match: TournamentMatch, slot: 1 | 2) => + (slot === 1 ? match.registration1Id : match.registration2Id) !== null || + fed.has(`${match.id}#${slot}`) +const statusOf = (match: TournamentMatch) => { + const [one, two] = [willFill(match, 1), willFill(match, 2)] + if (!one && !two) return 'unused' // byes emptied it out; skip it + if (!one || !two) return 'walkover' // whoever arrives advances unopposed + return 'playable' +} ``` -WINNERS BRACKET: -Round 3: [Finals] → winner to Grand Final, loser drops to LB R4 -LOSERS BRACKET: -Round 4: [Finals] → winner to Grand Final, loser=3rd +Most walkovers are resolved for you, with the advancing player already placed in +the next match. The one that cannot be is a walkover whose entrant is still +unknown — a bracket so small that the bye reaches the last match, such as the +third place match in a 3-participant tournament. Treat it as a walkover for +whoever turns up. -GRAND FINAL: -Round 1: [WB winner vs LB winner] → winner=1st, loser=2nd -``` - -Run `npm run demo -- [none|single|reset]` to print any bracket. +Round robins need none of this: an odd field simply means each participant rests +for one round, and a rest is the absence of a fixture rather than an empty match. ## Performance -Generation is O(n) in the number of participants, with no runtime dependencies. -Measured on Node 22 (average of 20 runs): +Generation is linear in the number of matches produced, with no runtime +dependencies. Measured on Node 22, best of 15 runs: + +| Participants | Single elimination | Double elimination | Round robin | +| ------------ | ------------------ | ------------------ | ------------------------ | +| 32 | 0.1 ms | 0.2 ms | 0.5 ms (496 matches) | +| 128 | 0.4 ms | 0.6 ms | 2.8 ms (8,128 matches) | +| 512 | 0.9 ms | 1.4 ms | 113 ms (130,816 matches) | +| 4096 | 5.7 ms | 15.2 ms | — | + +A round robin is quadratic by nature — 512 participants really is 130,816 +fixtures — which is exactly why large fields use `groupCount`. Building +standings from 32,640 played fixtures takes about 36 ms. + +## Migrating from 1.x + +`generateDoubleElimination` still takes the same options and produces the same +brackets, so most upgrades are just `npm install double-elimination@2`. Four +things changed: + +1. **`bracketType` gained values.** It is now + `'winners' | 'losers' | 'grandFinal' | 'roundRobin'`. TypeScript code that + exhaustively narrows on it needs the new cases, even if you never enable them. +2. **Every match carries `group` and `leg`.** They are `null` and `1` in + brackets. If you persist matches with a strict schema, add the columns or + drop the fields. +3. **Types were renamed**, with the old names kept as deprecated aliases: + `BracketMatch` → `TournamentMatch`, `BracketType` → `MatchType`, + `GeneratorOptions` → `DoubleEliminationOptions`. +4. **The 1.x losers bracket layout changed for 32+ participants** as part of the + rematch fix, and byes now resolve across the whole bracket. Finish in-flight + tournaments on the version that created them. + +Single elimination used to be spelled `losersStartRoundsBeforeFinal: 0`. That +still works, but `generateSingleElimination` says what it means: -| Participants | Time per bracket | -| ------------ | ---------------- | -| 128 | 0.7 ms | -| 1024 | 3.1 ms | -| 4096 | 14.3 ms | +```typescript +// 1.x +generateDoubleElimination({ ...options, losersStartRoundsBeforeFinal: 1 }) + +// 2.x +generateSingleElimination({ ...options, thirdPlaceMatch: true }) +``` ## Contributing -Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests. +Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) +for details on our code of conduct and the process for submitting pull requests. + +More worked examples live in [EXAMPLES.md](EXAMPLES.md), and +`npm run demo -- round-robin 10 groups=2` prints any tournament this package can +generate. ### Ways to Contribute diff --git a/demo.ts b/demo.ts index 35a6931..a4ebb9b 100644 --- a/demo.ts +++ b/demo.ts @@ -1,6 +1,8 @@ import { - generateDoubleElimination, - type GrandFinalFormat, + calculateStandings, + generateTournament, + type TournamentFormat, + type TournamentMatch, } from './src/index.js' const createParticipants = (count: number) => @@ -12,73 +14,149 @@ const createParticipants = (count: number) => let idCounter = 0 const idFactory = () => `m${++idCounter}` -// Usage: npm run demo -- [participants] [grandFinal: none|single|reset] -const count = Number.parseInt(process.argv[2] || '7', 10) -const grandFinal = (process.argv[3] as GrandFinalFormat) || 'none' +// Usage: npm run demo -- [format] [participants] [extra] +// npm run demo -- double-elimination 8 reset +// npm run demo -- single-elimination 16 third-place +// npm run demo -- round-robin 10 groups=2 +const format = (process.argv[2] as TournamentFormat) || 'double-elimination' +const count = Number.parseInt(process.argv[3] || '8', 10) +const extra = process.argv[4] ?? '' const participants = createParticipants(count) -const matches = generateDoubleElimination({ - eventId: 'event-1', - participants, - idFactory, - grandFinal, -}) +const base = { eventId: 'event-1', participants, idFactory } + +const matches = generateTournament( + format === 'round-robin' + ? { + format, + ...base, + legs: extra.startsWith('legs=') ? Number(extra.slice(5)) : 1, + groupCount: extra.startsWith('groups=') ? Number(extra.slice(7)) : 1, + } + : format === 'single-elimination' + ? { format, ...base, thirdPlaceMatch: extra === 'third-place' } + : { + format, + ...base, + grandFinal: extra === 'reset' || extra === 'single' ? extra : 'none', + } +) const label = (id: string | null) => id ?? '—' // A slot nobody feeds and nobody occupies can never be filled. const fedSlots = new Set() -for (const m of matches) { - if (m.winnerTo) fedSlots.add(`${m.winnerTo}#${m.winnerToSlot}`) - if (m.loserTo) fedSlots.add(`${m.loserTo}#${m.loserToSlot}`) +for (const match of matches) { + if (match.winnerTo) fedSlots.add(`${match.winnerTo}#${match.winnerToSlot}`) + if (match.loserTo) fedSlots.add(`${match.loserTo}#${match.loserToSlot}`) } const canFill = (id: string, slot: 1 | 2, occupant: string | null) => occupant !== null || fedSlots.has(`${id}#${slot}`) -const printBracket = ( - type: 'winners' | 'losers' | 'grandFinal', - title: string -) => { - const bracket = matches.filter((m) => m.bracketType === type) - if (bracket.length === 0) return +const printSection = (title: string, section: TournamentMatch[]) => { + if (section.length === 0) return console.log(`\n=== ${title} ===`) - const lastRound = Math.max(...bracket.map((m) => m.round)) + const lastRound = Math.max(...section.map((m) => m.round)) for (let round = 1; round <= lastRound; round++) { - console.log(`\nRound ${round}:`) - bracket + const inRound = section .filter((m) => m.round === round) .sort((a, b) => a.bracketPosition - b.bracketPosition) - .forEach((m) => { - const slot1 = canFill(m.id, 1, m.registration1Id) - const slot2 = canFill(m.id, 2, m.registration2Id) - const note = - !slot1 && !slot2 - ? ' (unused: byes)' - : slot1 && slot2 - ? '' - : ' (walkover)' - console.log( - ` [${m.bracketPosition}] ${m.id}: ${label(m.registration1Id)} vs ${label( - m.registration2Id - )}${note}` + - ` → W:${m.winnerTo ?? 'done'}${m.winnerToSlot ? `[${m.winnerToSlot}]` : ''}` + - ` L:${m.loserTo ?? 'out'}${m.loserToSlot ? `[${m.loserToSlot}]` : ''}` - ) - }) + if (inRound.length === 0) continue + + console.log(`\nRound ${round}:`) + for (const match of inRound) { + const slot1 = canFill(match.id, 1, match.registration1Id) + const slot2 = canFill(match.id, 2, match.registration2Id) + const note = + !slot1 && !slot2 + ? ' (unused: byes)' + : slot1 && slot2 + ? '' + : ' (walkover)' + const routing = + match.bracketType === 'roundRobin' + ? '' + : ` → W:${match.winnerTo ?? 'done'}${ + match.winnerToSlot ? `[${match.winnerToSlot}]` : '' + } L:${match.loserTo ?? 'out'}${ + match.loserToSlot ? `[${match.loserToSlot}]` : '' + }` + + console.log( + ` [${match.bracketPosition}] ${match.id}: ${label( + match.registration1Id + )} vs ${label(match.registration2Id)}${note}${routing}` + ) + } } } -printBracket('winners', 'WINNERS BRACKET') -printBracket('losers', 'LOSERS BRACKET') -printBracket('grandFinal', 'GRAND FINAL') +if (format === 'round-robin') { + const groups = [...new Set(matches.map((m) => m.group))] + for (const group of groups) { + const inGroup = matches.filter((m) => m.group === group) + printSection(group === null ? 'FIXTURES' : `GROUP ${group + 1}`, inGroup) + } + + // Play it out so the standings table has something to show. + const results = matches.map((match) => ({ + matchId: match.id, + score1: (match.round * 7 + match.bracketPosition * 3) % 4, + score2: (match.round * 5 + match.bracketPosition * 2) % 4, + })) + + console.log('\n=== STANDINGS (with made-up results) ===') + const standings = calculateStandings({ matches, results, participants }) + let shownGroup: number | null | undefined + for (const row of standings) { + if (row.group !== shownGroup) { + shownGroup = row.group + console.log(row.group === null ? '' : `\nGroup ${row.group + 1}:`) + console.log( + ['#', 'participant', 'P', 'W', 'D', 'L', 'SF', 'SA', 'SD', 'Pts'] + .map((header, i) => + i === 1 ? header.padEnd(12) : header.padStart(4) + ) + .join('') + ) + } + console.log( + [ + String(row.rank), + row.registrationId, + row.played, + row.won, + row.drawn, + row.lost, + row.scoreFor, + row.scoreAgainst, + row.scoreDifference, + row.points, + ] + .map((cell, i) => + i === 1 ? String(cell).padEnd(12) : String(cell).padStart(4) + ) + .join('') + ) + } +} else { + printSection( + 'WINNERS BRACKET', + matches.filter((m) => m.bracketType === 'winners') + ) + printSection( + format === 'single-elimination' ? 'THIRD PLACE' : 'LOSERS BRACKET', + matches.filter((m) => m.bracketType === 'losers') + ) + printSection( + 'GRAND FINAL', + matches.filter((m) => m.bracketType === 'grandFinal') + ) +} console.log('\n=== SUMMARY ===') +console.log(`Format: ${format}`) console.log(`Participants: ${participants.length}`) -console.log(`Grand final: ${grandFinal}`) -for (const type of ['winners', 'losers', 'grandFinal'] as const) { - const count_ = matches.filter((m) => m.bracketType === type).length - if (count_ > 0) console.log(`${type.padEnd(16)} ${count_} matches`) -} console.log(`Total matches: ${matches.length}`) diff --git a/package-lock.json b/package-lock.json index 24524f1..35a4b01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "double-elimination", - "version": "1.3.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "double-elimination", - "version": "1.3.0", + "version": "2.0.0", "license": "MIT", "devDependencies": { "@types/node": "^20.0.0", diff --git a/package.json b/package.json index 32b8756..9a01445 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "double-elimination", - "version": "1.3.0", - "description": "Generate double elimination tournament brackets with automatic seeding and bye handling", + "version": "2.0.0", + "description": "Tournament generator for double elimination, single elimination and round robin — seeding, byes, grand finals, group stages and standings", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts", @@ -25,21 +25,32 @@ "keywords": [ "tournament", "bracket", - "double-elimination", - "esports", - "competition", - "seeding", - "sports", - "tournament-bracket", "bracket-generator", - "tournament-management", - "esports-tournament", - "competition-bracket", + "tournament-bracket", + "tournament-generator", + "double-elimination", "single-elimination", + "round-robin", + "group-stage", + "league", + "fixtures", + "schedule", + "match-scheduling", + "standings", + "league-table", + "tiebreakers", + "seeding", "tournament-seeding", "bracket-algorithm", - "tournament-software", - "match-scheduling" + "grand-final", + "bracket-reset", + "esports", + "esports-tournament", + "sports", + "competition", + "competition-bracket", + "tournament-management", + "tournament-software" ], "author": "Nader Safa", "license": "MIT", diff --git a/src/calculateStandings.ts b/src/calculateStandings.ts new file mode 100644 index 0000000..5a30590 --- /dev/null +++ b/src/calculateStandings.ts @@ -0,0 +1,365 @@ +import { + MatchResult, + PointsConfig, + Standing, + StandingsOptions, + Tiebreaker, + TournamentMatch, +} from './types.js' + +const DEFAULT_POINTS: PointsConfig = { win: 3, draw: 1, loss: 0 } + +const TIEBREAKERS: Tiebreaker[] = [ + 'headToHead', + 'scoreDifference', + 'scoreFor', + 'wins', + 'seed', +] + +const DEFAULT_TIEBREAKERS: Tiebreaker[] = [ + 'headToHead', + 'scoreDifference', + 'scoreFor', + 'wins', +] + +/** + * Builds a standings table from recorded results. + * + * Pass the matches you want ranked — typically one round robin, group stage + * included — along with whatever results exist so far; matches without a + * result simply count as unplayed. Rows are returned ordered by group, then by + * rank. Participants the tiebreakers cannot separate share a rank, and are + * listed strongest seed first. + */ +export const calculateStandings = (options: StandingsOptions): Standing[] => { + const { + matches, + results, + participants = [], + points: pointsOverride, + tiebreakers = DEFAULT_TIEBREAKERS, + } = options + + if (!Array.isArray(matches)) throw new Error('matches must be an array') + if (!Array.isArray(results)) throw new Error('results must be an array') + + const points: PointsConfig = { ...DEFAULT_POINTS, ...pointsOverride } + for (const tiebreaker of tiebreakers) { + if (!TIEBREAKERS.includes(tiebreaker)) { + throw new Error( + `Unknown tiebreaker: "${tiebreaker}". Expected one of ${TIEBREAKERS.join(', ')}` + ) + } + } + + const seedOf = new Map( + participants.map((participant) => [ + participant.registrationId, + participant.seed, + ]) + ) + const matchById = new Map(matches.map((match) => [match.id, match])) + const outcomes = readResults(results, matchById) + + // Head-to-head only ever looks at the games a tied participant played, so + // index those once instead of rescanning every fixture for every row. + const playedBy = new Map() + const recordPlayed = (game: PlayedMatch) => { + const existing = playedBy.get(game.registrationId) + if (existing) existing.push(game) + else playedBy.set(game.registrationId, [game]) + } + + // One row per participant per group, created from the fixtures so that + // everyone appears even before a ball is kicked. + const rows = new Map() + const keyOf = (registrationId: string, group: number | null) => + `${group ?? 'all'}::${registrationId}` + + const rowFor = (registrationId: string, group: number | null): Standing => { + const key = keyOf(registrationId, group) + const existing = rows.get(key) + if (existing) return existing + + const created: Standing = { + registrationId, + group, + rank: 0, + played: 0, + won: 0, + drawn: 0, + lost: 0, + scoreFor: 0, + scoreAgainst: 0, + scoreDifference: 0, + points: 0, + } + rows.set(key, created) + return created + } + + for (const match of matches) { + const { registration1Id, registration2Id } = match + if (!registration1Id || !registration2Id) continue + + const first = rowFor(registration1Id, match.group) + const second = rowFor(registration2Id, match.group) + + const outcome = outcomes.get(match.id) + if (!outcome) continue + + applyOutcome(first, outcome.score1, outcome.score2, points) + applyOutcome(second, outcome.score2, outcome.score1, points) + + recordPlayed({ + registrationId: registration1Id, + opponentId: registration2Id, + group: match.group, + scoreFor: outcome.score1, + scoreAgainst: outcome.score2, + }) + recordPlayed({ + registrationId: registration2Id, + opponentId: registration1Id, + group: match.group, + scoreFor: outcome.score2, + scoreAgainst: outcome.score1, + }) + } + + const byGroup = new Map() + for (const row of rows.values()) { + const group = byGroup.get(row.group) + if (group) group.push(row) + else byGroup.set(row.group, [row]) + } + + const ranked: Standing[] = [] + const groupKeys = [...byGroup.keys()].sort((a, b) => (a ?? -1) - (b ?? -1)) + + for (const group of groupKeys) { + const groupRows = byGroup.get(group)! + const blocks = separate(groupRows, ['points', ...tiebreakers], { + playedBy, + points, + seedOf, + }) + + let position = 1 + for (const block of blocks) { + const ordered = [...block].sort( + (a, b) => + (seedOf.get(a.registrationId) ?? Number.MAX_SAFE_INTEGER) - + (seedOf.get(b.registrationId) ?? Number.MAX_SAFE_INTEGER) + ) + for (const row of ordered) { + row.rank = position + ranked.push(row) + } + position += block.length + } + } + + return ranked +} + +interface Outcome { + score1: number + score2: number +} + +/** Turns reported results into comparable scores, rejecting unusable input. */ +const readResults = ( + results: MatchResult[], + matchById: Map +): Map => { + const outcomes = new Map() + + for (const result of results) { + const match = matchById.get(result.matchId) + if (!match) { + throw new Error(`Result references unknown match: "${result.matchId}"`) + } + if (outcomes.has(result.matchId)) { + throw new Error(`Duplicate result for match: "${result.matchId}"`) + } + if (!match.registration1Id || !match.registration2Id) { + throw new Error( + `Match "${result.matchId}" has no participants recorded, so it cannot have a result` + ) + } + + const hasScores = + typeof result.score1 === 'number' || typeof result.score2 === 'number' + + if (hasScores) { + if (!Number.isFinite(result.score1) || !Number.isFinite(result.score2)) { + throw new Error( + `Match "${result.matchId}" needs both score1 and score2, or neither` + ) + } + outcomes.set(result.matchId, { + score1: result.score1 as number, + score2: result.score2 as number, + }) + continue + } + + if (!('winnerId' in result)) { + throw new Error( + `Result for match "${result.matchId}" needs score1 and score2, or a winnerId` + ) + } + + // No scores recorded: stand in a 1-0 win or a 0-0 draw so that wins and + // losses still count. Score columns stay meaningless either way. + if (result.winnerId === null) { + outcomes.set(result.matchId, { score1: 0, score2: 0 }) + } else if (result.winnerId === match.registration1Id) { + outcomes.set(result.matchId, { score1: 1, score2: 0 }) + } else if (result.winnerId === match.registration2Id) { + outcomes.set(result.matchId, { score1: 0, score2: 1 }) + } else { + throw new Error( + `winnerId "${result.winnerId}" did not play in match "${result.matchId}"` + ) + } + } + + return outcomes +} + +const applyOutcome = ( + row: Standing, + scoreFor: number, + scoreAgainst: number, + points: PointsConfig +): void => { + row.played += 1 + row.scoreFor += scoreFor + row.scoreAgainst += scoreAgainst + row.scoreDifference = row.scoreFor - row.scoreAgainst + + if (scoreFor > scoreAgainst) { + row.won += 1 + row.points += points.win + } else if (scoreFor === scoreAgainst) { + row.drawn += 1 + row.points += points.draw + } else { + row.lost += 1 + row.points += points.loss + } +} + +/** One side's view of a game that has a result. */ +interface PlayedMatch { + registrationId: string + opponentId: string + group: number | null + scoreFor: number + scoreAgainst: number +} + +interface RankingContext { + playedBy: Map + points: PointsConfig + seedOf: Map +} + +/** Sort keys, compared highest first. */ +type SortKey = ( + row: Standing, + block: Standing[], + context: RankingContext +) => number[] + +const TIEBREAKER_KEYS: Record = { + points: (row) => [row.points], + scoreDifference: (row) => [row.scoreDifference], + scoreFor: (row) => [row.scoreFor], + wins: (row) => [row.won], + seed: (row, _block, context) => [ + -(context.seedOf.get(row.registrationId) ?? Number.MAX_SAFE_INTEGER), + ], + // A mini-league over the matches the tied participants played each other. + headToHead: (row, block, context) => { + if (block.length < 2) return [0, 0, 0] + const tied = new Set(block.map((entry) => entry.registrationId)) + + let points = 0 + let scoreFor = 0 + let scoreAgainst = 0 + + for (const game of context.playedBy.get(row.registrationId) ?? []) { + if (game.group !== row.group) continue + if (!tied.has(game.opponentId)) continue + + scoreFor += game.scoreFor + scoreAgainst += game.scoreAgainst + if (game.scoreFor > game.scoreAgainst) points += context.points.win + else if (game.scoreFor === game.scoreAgainst) + points += context.points.draw + else points += context.points.loss + } + + return [points, scoreFor - scoreAgainst, scoreFor] + }, +} + +const compareKeys = (a: number[], b: number[]): number => { + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const difference = (b[i] ?? 0) - (a[i] ?? 0) + if (difference !== 0) return difference + } + return 0 +} + +/** + * Orders rows, applying each comparison only to rows the previous ones left + * level. Returns blocks of rows nothing could separate — each block shares a + * rank. + */ +const separate = ( + rows: Standing[], + comparisons: (Tiebreaker | 'points')[], + context: RankingContext +): Standing[][] => { + if (rows.length < 2 || comparisons.length === 0) return [rows] + + const [comparison, ...remaining] = comparisons + const key = TIEBREAKER_KEYS[comparison] + + const keyed = rows.map((row) => ({ row, key: key(row, rows, context) })) + keyed.sort((a, b) => compareKeys(a.key, b.key)) + + const blocks: Standing[][] = [] + let current: typeof keyed = [] + + for (const entry of keyed) { + if (current.length > 0 && compareKeys(current[0].key, entry.key) !== 0) { + blocks.push( + ...separate( + current.map((e) => e.row), + remaining, + context + ) + ) + current = [] + } + current.push(entry) + } + if (current.length > 0) { + blocks.push( + ...separate( + current.map((e) => e.row), + remaining, + context + ) + ) + } + + return blocks +} diff --git a/src/createGrandFinal.ts b/src/createGrandFinal.ts index 111e014..728717b 100644 --- a/src/createGrandFinal.ts +++ b/src/createGrandFinal.ts @@ -1,4 +1,5 @@ -import { BracketMatch, GrandFinalFormat, IdFactory } from './types.js' +import { GrandFinalFormat, IdFactory, TournamentMatch } from './types.js' +import { createMatch } from './participants.js' /** * Creates the grand final (and, for `'reset'`, the bracket reset match). @@ -13,27 +14,19 @@ export const createGrandFinal = ( eventId: string, format: GrandFinalFormat, idFactory: IdFactory -): BracketMatch[] => { +): TournamentMatch[] => { if (format === 'none') return [] const rounds = format === 'reset' ? 2 : 1 - const matches: BracketMatch[] = [] + const matches: TournamentMatch[] = [] for (let round = 1; round <= rounds; round++) { - matches.push({ - id: idFactory(), - eventId, - round, - matchNumber: 1, - registration1Id: null, - registration2Id: null, - bracketPosition: 0, - winnerTo: null, - winnerToSlot: null, - loserTo: null, - loserToSlot: null, - bracketType: 'grandFinal', - }) + matches.push( + createMatch( + { eventId, round, bracketPosition: 0, bracketType: 'grandFinal' }, + idFactory + ) + ) } if (matches.length === 2) { diff --git a/src/createLosersBracket.ts b/src/createLosersBracket.ts index ad06208..c8b8d33 100644 --- a/src/createLosersBracket.ts +++ b/src/createLosersBracket.ts @@ -1,4 +1,5 @@ -import { BracketMatch, IdFactory } from './types.js' +import { IdFactory, TournamentMatch } from './types.js' +import { createMatch } from './participants.js' /** * Builds the losers bracket skeleton and wires winner advancement inside it. @@ -14,8 +15,8 @@ export const createLosersBracket = ( rounds: number, startFromWbRound: number, idFactory: IdFactory -): BracketMatch[] => { - const matches: BracketMatch[] = [] +): TournamentMatch[] => { + const matches: TournamentMatch[] = [] const matchIdMap = new Map() // Losers from winners rounds before startFromWbRound never enter the losers @@ -28,23 +29,12 @@ export const createLosersBracket = ( effectiveBracketSize / Math.pow(2, Math.ceil(round / 2) + 1) for (let pos = 0; pos < matchCount; pos++) { - const matchId = idFactory() - matchIdMap.set(`${round}-${pos}`, matchId) - - matches.push({ - id: matchId, - eventId, - round, - matchNumber: pos + 1, - registration1Id: null, - registration2Id: null, - bracketPosition: pos, - winnerTo: null, - winnerToSlot: null, - loserTo: null, - loserToSlot: null, - bracketType: 'losers', - }) + const match = createMatch( + { eventId, round, bracketPosition: pos, bracketType: 'losers' }, + idFactory + ) + matchIdMap.set(`${round}-${pos}`, match.id) + matches.push(match) } } @@ -54,7 +44,7 @@ export const createLosersBracket = ( } const wireLosersBracketWinners = ( - matches: BracketMatch[], + matches: TournamentMatch[], idMap: Map, totalRounds: number ): void => { diff --git a/src/createWinnersBracket.ts b/src/createWinnersBracket.ts index bcacb16..a629d57 100644 --- a/src/createWinnersBracket.ts +++ b/src/createWinnersBracket.ts @@ -1,4 +1,5 @@ -import { BracketMatch, IdFactory } from './types.js' +import { IdFactory, TournamentMatch } from './types.js' +import { createMatch } from './participants.js' /** Builds the winners bracket skeleton and wires winner advancement. */ export const createWinnersBracket = ( @@ -6,31 +7,20 @@ export const createWinnersBracket = ( bracketSize: number, rounds: number, idFactory: IdFactory -): BracketMatch[] => { - const matches: BracketMatch[] = [] +): TournamentMatch[] => { + const matches: TournamentMatch[] = [] const matchIdMap = new Map() for (let round = 1; round <= rounds; round++) { const matchCount = bracketSize / Math.pow(2, round) for (let pos = 0; pos < matchCount; pos++) { - const matchId = idFactory() - matchIdMap.set(`${round}-${pos}`, matchId) - - matches.push({ - id: matchId, - eventId, - round, - matchNumber: pos + 1, - registration1Id: null, - registration2Id: null, - bracketPosition: pos, - winnerTo: null, - winnerToSlot: null, - loserTo: null, - loserToSlot: null, - bracketType: 'winners', - }) + const match = createMatch( + { eventId, round, bracketPosition: pos, bracketType: 'winners' }, + idFactory + ) + matchIdMap.set(`${round}-${pos}`, match.id) + matches.push(match) } } diff --git a/src/generateDoubleElimination.ts b/src/generateDoubleElimination.ts index fbef235..4029100 100644 --- a/src/generateDoubleElimination.ts +++ b/src/generateDoubleElimination.ts @@ -1,6 +1,11 @@ -import { BracketMatch, GeneratorOptions, Participant } from './types.js' +import { + DoubleEliminationOptions, + Participant, + TournamentMatch, +} from './types.js' import { generateSeedPairs } from './bracketUtils.js' import { planBracket } from './planBracket.js' +import { assertUniqueIds } from './participants.js' import { createWinnersBracket } from './createWinnersBracket.js' import { createLosersBracket } from './createLosersBracket.js' import { createGrandFinal } from './createGrandFinal.js' @@ -8,14 +13,15 @@ import { wireLoserRouting } from './wireLoserRouting.js' import { resolveByes } from './resolveByes.js' /** - * Generates every match of a tournament bracket, already wired together. + * Generates a double elimination tournament: every match, already wired + * together, so a loss only eliminates a player the second time. * * Matches are returned winners bracket first, then losers bracket, then grand * final, each ordered by round and then by position. */ export const generateDoubleElimination = ( - options: GeneratorOptions -): BracketMatch[] => { + options: DoubleEliminationOptions +): TournamentMatch[] => { const { eventId, participants, @@ -73,12 +79,12 @@ export const generateDoubleElimination = ( /** Sends both bracket winners into the grand final. */ const wireGrandFinal = ( - winnersMatches: BracketMatch[], - losersMatches: BracketMatch[], - grandFinalMatch: BracketMatch + winnersMatches: TournamentMatch[], + losersMatches: TournamentMatch[], + grandFinalMatch: TournamentMatch ): void => { - const lastOf = (matches: BracketMatch[]): BracketMatch | undefined => - matches.reduce( + const lastOf = (matches: TournamentMatch[]): TournamentMatch | undefined => + matches.reduce( (latest, match) => !latest || match.round > latest.round ? match : latest, undefined @@ -98,7 +104,7 @@ const wireGrandFinal = ( } const placeParticipants = ( - matches: BracketMatch[], + matches: TournamentMatch[], participants: Participant[], bracketSize: number ): void => { @@ -117,17 +123,3 @@ const placeParticipants = ( match.registration2Id = seedMap.get(seed2) ?? null }) } - -/** A repeating idFactory would silently cross-wire the bracket. */ -const assertUniqueIds = (matches: BracketMatch[]): void => { - const ids = new Set() - for (const match of matches) { - if (typeof match.id !== 'string' || match.id.length === 0) { - throw new Error('idFactory must return non-empty string ids') - } - if (ids.has(match.id)) { - throw new Error(`idFactory returned a duplicate id: "${match.id}"`) - } - ids.add(match.id) - } -} diff --git a/src/generateRoundRobin.ts b/src/generateRoundRobin.ts new file mode 100644 index 0000000..b925782 --- /dev/null +++ b/src/generateRoundRobin.ts @@ -0,0 +1,84 @@ +import { RoundRobinOptions, TournamentMatch } from './types.js' +import { + assertUniqueIds, + createMatch, + rankParticipants, + readInteger, +} from './participants.js' +import { + buildRoundRobinFixtures, + snakeIntoGroups, +} from './roundRobinSchedule.js' + +/** + * Generates a round robin tournament: everyone plays everyone. + * + * Both participants are known up front, so unlike a bracket there is no + * routing — `winnerTo` and `loserTo` are always `null`, and results are ranked + * with {@link calculateStandings} instead. + * + * Matches are returned grouped by `group`, then `leg`, then `round`. + */ +export const generateRoundRobin = ( + options: RoundRobinOptions +): TournamentMatch[] => { + const { eventId, idFactory } = options + + const ranked = rankParticipants(options) + const legs = readInteger(options.legs, 1, 1, 'legs') + const groupCount = readInteger(options.groupCount, 1, 1, 'groupCount') + + if (groupCount > Math.floor(ranked.length / 2)) { + throw new Error( + `groupCount ${groupCount} leaves a group with fewer than 2 participants (${ranked.length} participants allow at most ${Math.floor(ranked.length / 2)} groups)` + ) + } + + const matches: TournamentMatch[] = [] + const groups = snakeIntoGroups(ranked, groupCount) + + groups.forEach((entrants, groupIndex) => { + const fixtures = buildRoundRobinFixtures(entrants.length) + const roundsPerLeg = fixtures.reduce( + (highest, fixture) => Math.max(highest, fixture.round + 1), + 0 + ) + + for (let leg = 1; leg <= legs; leg++) { + // Later legs replay the same fixtures with the sides swapped, so a + // two-leg round robin gives everyone an equal split of each side. + const swapSides = leg % 2 === 0 + const positionsInRound = new Map() + + for (const fixture of fixtures) { + const round = (leg - 1) * roundsPerLeg + fixture.round + 1 + const position = positionsInRound.get(round) ?? 0 + positionsInRound.set(round, position + 1) + + const [first, second] = fixture.pair + const home = swapSides ? second : first + const away = swapSides ? first : second + + matches.push( + createMatch( + { + eventId, + round, + bracketPosition: position, + bracketType: 'roundRobin', + registration1Id: entrants[home].registrationId, + registration2Id: entrants[away].registrationId, + group: groupCount > 1 ? groupIndex : null, + leg, + }, + idFactory + ) + ) + } + } + }) + + assertUniqueIds(matches) + + return matches +} diff --git a/src/generateSingleElimination.ts b/src/generateSingleElimination.ts new file mode 100644 index 0000000..854a560 --- /dev/null +++ b/src/generateSingleElimination.ts @@ -0,0 +1,26 @@ +import { SingleEliminationOptions, TournamentMatch } from './types.js' +import { generateDoubleElimination } from './generateDoubleElimination.js' + +/** + * Generates a single elimination tournament: one loss and you are out. + * + * With `thirdPlaceMatch: true` the two semifinal losers meet once more, and + * that match is returned with `bracketType: 'losers'`. + */ +export const generateSingleElimination = ( + options: SingleEliminationOptions +): TournamentMatch[] => { + const { thirdPlaceMatch = false, ...rest } = options + + if (typeof thirdPlaceMatch !== 'boolean') { + throw new Error('thirdPlaceMatch must be a boolean') + } + + // A single elimination bracket is a double elimination bracket whose losers + // bracket never opens; the third place match is the one round of it that + // only semifinal losers reach. + return generateDoubleElimination({ + ...rest, + losersStartRoundsBeforeFinal: thirdPlaceMatch ? 1 : 0, + }) +} diff --git a/src/generateTournament.ts b/src/generateTournament.ts new file mode 100644 index 0000000..54e74c2 --- /dev/null +++ b/src/generateTournament.ts @@ -0,0 +1,40 @@ +import { TournamentMatch, TournamentOptions } from './types.js' +import { generateSingleElimination } from './generateSingleElimination.js' +import { generateDoubleElimination } from './generateDoubleElimination.js' +import { generateRoundRobin } from './generateRoundRobin.js' + +/** + * Generates a tournament in whichever format `options.format` names. + * + * Useful when the format is data — a column in your database, a value from a + * form — rather than something known when the code is written. Each format + * accepts its own options, and all of them return the same match shape. + * + * ```typescript + * const matches = generateTournament({ + * format: 'round-robin', + * eventId: 'league-2026', + * participants, + * idFactory: () => crypto.randomUUID(), + * legs: 2, + * }) + * ``` + */ +export const generateTournament = ( + options: TournamentOptions +): TournamentMatch[] => { + switch (options.format) { + case 'single-elimination': + return generateSingleElimination(options) + case 'double-elimination': + return generateDoubleElimination(options) + case 'round-robin': + return generateRoundRobin(options) + default: { + const { format } = options as { format: string } + throw new Error( + `Unknown format: "${format}". Expected 'single-elimination', 'double-elimination' or 'round-robin'` + ) + } + } +} diff --git a/src/index.ts b/src/index.ts index 53ec9e2..16647cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,31 @@ +export { generateTournament } from './generateTournament.js' +export { generateSingleElimination } from './generateSingleElimination.js' export { generateDoubleElimination } from './generateDoubleElimination.js' +export { generateRoundRobin } from './generateRoundRobin.js' +export { calculateStandings } from './calculateStandings.js' + export type { + // Core Participant, + TournamentMatch, + MatchType, + IdFactory, + // Options + BaseOptions, + TournamentOptions, + TournamentFormat, + SingleEliminationOptions, + DoubleEliminationOptions, + RoundRobinOptions, + GrandFinalFormat, + // Standings + MatchResult, + PointsConfig, + Tiebreaker, + StandingsOptions, + Standing, + // Deprecated aliases, kept so existing imports keep working BracketMatch, BracketType, - GrandFinalFormat, - IdFactory, GeneratorOptions, } from './types.js' diff --git a/src/participants.ts b/src/participants.ts new file mode 100644 index 0000000..cb692fd --- /dev/null +++ b/src/participants.ts @@ -0,0 +1,123 @@ +import { + BaseOptions, + IdFactory, + Participant, + TournamentMatch, +} from './types.js' + +/** + * Validates the options every format shares and re-ranks participants to 1..N. + * + * Callers commonly pass sparse or 0-based seeds; ranking makes those behave the + * same as 1..N instead of silently leaving participants out of the tournament. + */ +export const rankParticipants = (options: BaseOptions): Participant[] => { + const { eventId, participants, idFactory } = options + + if (typeof eventId !== 'string' || eventId.length === 0) { + throw new Error('eventId must be a non-empty string') + } + if (typeof idFactory !== 'function') { + throw new Error('idFactory must be a function returning unique ids') + } + if (!Array.isArray(participants) || participants.length < 2) { + throw new Error('At least 2 participants required') + } + + const seenIds = new Set() + const seenSeeds = new Set() + + for (const participant of participants) { + if ( + !participant || + typeof participant.registrationId !== 'string' || + participant.registrationId.length === 0 + ) { + throw new Error('Every participant needs a non-empty registrationId') + } + if ( + typeof participant.seed !== 'number' || + !Number.isFinite(participant.seed) + ) { + throw new Error( + `Participant "${participant.registrationId}" has a non-numeric seed` + ) + } + if (seenIds.has(participant.registrationId)) { + throw new Error( + `Duplicate registrationId: "${participant.registrationId}"` + ) + } + if (seenSeeds.has(participant.seed)) { + throw new Error(`Duplicate seed: ${participant.seed}`) + } + seenIds.add(participant.registrationId) + seenSeeds.add(participant.seed) + } + + return [...participants] + .sort((a, b) => a.seed - b.seed) + .map((participant, index) => ({ + registrationId: participant.registrationId, + seed: index + 1, + })) +} + +/** Reads an option that must be a whole number at or above `minimum`. */ +export const readInteger = ( + value: number | undefined, + fallback: number, + minimum: number, + name: string +): number => { + if (value === undefined) return fallback + if (!Number.isInteger(value) || value < minimum) { + throw new Error(`${name} must be an integer of at least ${minimum}`) + } + return value +} + +/** A repeating idFactory would silently cross-wire the tournament. */ +export const assertUniqueIds = (matches: TournamentMatch[]): void => { + const ids = new Set() + for (const match of matches) { + if (typeof match.id !== 'string' || match.id.length === 0) { + throw new Error('idFactory must return non-empty string ids') + } + if (ids.has(match.id)) { + throw new Error(`idFactory returned a duplicate id: "${match.id}"`) + } + ids.add(match.id) + } +} + +/** + * Builds a match, defaulting every field a format does not care about. + * + * Written out key by key rather than spread over defaults so that every match + * in a tournament shares one object shape, which keeps large fields fast to + * build and to iterate. + */ +export const createMatch = ( + fields: Pick< + TournamentMatch, + 'eventId' | 'round' | 'bracketPosition' | 'bracketType' + > & + Partial, + idFactory: IdFactory +): TournamentMatch => ({ + id: idFactory(), + eventId: fields.eventId, + round: fields.round, + matchNumber: fields.matchNumber ?? fields.bracketPosition + 1, + registration1Id: fields.registration1Id ?? null, + registration2Id: fields.registration2Id ?? null, + bracketPosition: fields.bracketPosition, + winnerTo: fields.winnerTo ?? null, + winnerToSlot: fields.winnerToSlot ?? null, + loserTo: fields.loserTo ?? null, + loserToSlot: fields.loserToSlot ?? null, + bracketType: fields.bracketType, + group: fields.group ?? null, + leg: fields.leg ?? 1, +}) diff --git a/src/planBracket.ts b/src/planBracket.ts index 1925b08..35eb2d6 100644 --- a/src/planBracket.ts +++ b/src/planBracket.ts @@ -1,5 +1,10 @@ -import { GeneratorOptions, GrandFinalFormat, Participant } from './types.js' +import { + DoubleEliminationOptions, + GrandFinalFormat, + Participant, +} from './types.js' import { log2, nextPowerOf2 } from './bracketUtils.js' +import { rankParticipants } from './participants.js' export interface BracketPlan { eventId: string @@ -14,24 +19,9 @@ export interface BracketPlan { } /** Validates the options and derives every number the generators need. */ -export const planBracket = (options: GeneratorOptions): BracketPlan => { - const { - eventId, - participants, - idFactory, - losersStartRoundsBeforeFinal, - grandFinal = 'none', - } = options +export const planBracket = (options: DoubleEliminationOptions): BracketPlan => { + const { eventId, losersStartRoundsBeforeFinal, grandFinal = 'none' } = options - if (typeof eventId !== 'string' || eventId.length === 0) { - throw new Error('eventId must be a non-empty string') - } - if (typeof idFactory !== 'function') { - throw new Error('idFactory must be a function returning unique ids') - } - if (!Array.isArray(participants) || participants.length < 2) { - throw new Error('At least 2 participants required') - } if ( grandFinal !== 'none' && grandFinal !== 'single' && @@ -40,7 +30,7 @@ export const planBracket = (options: GeneratorOptions): BracketPlan => { throw new Error(`grandFinal must be 'none', 'single' or 'reset'`) } - const ranked = rankParticipants(participants) + const ranked = rankParticipants(options) const bracketSize = nextPowerOf2(ranked.length) const winnersRounds = log2(bracketSize) @@ -93,49 +83,3 @@ export const planBracket = (options: GeneratorOptions): BracketPlan => { grandFinal, } } - -/** - * Sorts by seed and re-ranks to 1..N. - * - * Callers commonly pass sparse or 0-based seeds; ranking makes those behave the - * same as 1..N instead of silently leaving participants out of the bracket. - */ -const rankParticipants = (participants: Participant[]): Participant[] => { - const seenIds = new Set() - const seenSeeds = new Set() - - for (const participant of participants) { - if ( - !participant || - typeof participant.registrationId !== 'string' || - participant.registrationId.length === 0 - ) { - throw new Error('Every participant needs a non-empty registrationId') - } - if ( - typeof participant.seed !== 'number' || - !Number.isFinite(participant.seed) - ) { - throw new Error( - `Participant "${participant.registrationId}" has a non-numeric seed` - ) - } - if (seenIds.has(participant.registrationId)) { - throw new Error( - `Duplicate registrationId: "${participant.registrationId}"` - ) - } - if (seenSeeds.has(participant.seed)) { - throw new Error(`Duplicate seed: ${participant.seed}`) - } - seenIds.add(participant.registrationId) - seenSeeds.add(participant.seed) - } - - return [...participants] - .sort((a, b) => a.seed - b.seed) - .map((participant, index) => ({ - registrationId: participant.registrationId, - seed: index + 1, - })) -} diff --git a/src/resolveByes.ts b/src/resolveByes.ts index 5908990..7a67cca 100644 --- a/src/resolveByes.ts +++ b/src/resolveByes.ts @@ -1,4 +1,4 @@ -import { BracketMatch } from './types.js' +import { TournamentMatch } from './types.js' /** * Resolves every walkover the bracket structure already implies. @@ -17,7 +17,7 @@ import { BracketMatch } from './types.js' * - A match no one can reach is left in place with empty slots and no routing, * so bracket positions stay stable for rendering. */ -export const resolveByes = (matches: BracketMatch[]): void => { +export const resolveByes = (matches: TournamentMatch[]): void => { const byId = new Map(matches.map((match) => [match.id, match])) const order = topologicalOrder(matches, byId) @@ -115,7 +115,7 @@ type MatchShape = | 'unused' const classify = ( - match: BracketMatch, + match: TournamentMatch, slot1: SlotState, slot2: SlotState ): MatchShape => { @@ -144,7 +144,7 @@ const NO_LINK: Link = { id: null, slot: null } const followBypasses = ( targetId: string | null, targetSlot: number | null, - byId: Map, + byId: Map, shapes: Map ): Link => { let id = targetId @@ -165,13 +165,13 @@ const followBypasses = ( /** Orders matches so that every match comes after the matches that feed it. */ const topologicalOrder = ( - matches: BracketMatch[], - byId: Map -): BracketMatch[] => { + matches: TournamentMatch[], + byId: Map +): TournamentMatch[] => { const incoming = new Map() for (const match of matches) incoming.set(match.id, 0) - const targetsOf = (match: BracketMatch): string[] => { + const targetsOf = (match: TournamentMatch): string[] => { const targets: string[] = [] if (match.winnerTo && byId.has(match.winnerTo)) targets.push(match.winnerTo) if (match.loserTo && byId.has(match.loserTo)) targets.push(match.loserTo) @@ -185,7 +185,7 @@ const topologicalOrder = ( } const queue = matches.filter((match) => incoming.get(match.id) === 0) - const order: BracketMatch[] = [] + const order: TournamentMatch[] = [] for (let i = 0; i < queue.length; i++) { const match = queue[i] diff --git a/src/roundRobinSchedule.ts b/src/roundRobinSchedule.ts new file mode 100644 index 0000000..eaa9fdb --- /dev/null +++ b/src/roundRobinSchedule.ts @@ -0,0 +1,84 @@ +/** + * Fixture scheduling for round robins, using the circle method. + * + * One entrant stays put while the rest rotate around them, which pairs every + * entrant with every other exactly once in `n - 1` rounds. Entrants are passed + * in seed order, so the marquee tie — the top two seeds — lands in the final + * round and the first round is the widest mismatch. + */ + +/** A bye is an empty seat at the table: whoever faces it sits the round out. */ +const BYE = null + +export interface Fixture { + /** 0-based round index. */ + round: number + /** Ordered pair of participant indexes (as passed in). */ + pair: [number, number] +} + +/** + * Builds one leg of fixtures for `count` entrants. + * + * With an odd `count` a bye seat is added, so every entrant sits out exactly + * one round and each round holds `floor(count / 2)` matches. + */ +export const buildRoundRobinFixtures = (count: number): Fixture[] => { + const seats: (number | typeof BYE)[] = Array.from( + { length: count }, + (_, index) => index + ) + if (seats.length % 2 === 1) seats.push(BYE) + + const size = seats.length + const rounds = size - 1 + const half = size / 2 + + const anchor = seats[0] + let rotating = seats.slice(1) + + const fixtures: Fixture[] = [] + + for (let round = 0; round < rounds; round++) { + const seated = [anchor, ...rotating] + + for (let position = 0; position < half; position++) { + const first = seated[position] + const second = seated[size - 1 - position] + if (first === BYE || second === BYE) continue + + // As the circle turns, every rotating entrant passes through every seat + // and so takes each side of the fixture about equally. The anchor never + // moves, so only their fixture needs flipping on alternate rounds. + const swap = position === 0 && round % 2 === 1 + fixtures.push({ + round, + pair: swap ? [second, first] : [first, second], + }) + } + + // Rotate everyone but the anchor one seat clockwise. + rotating = [rotating[rotating.length - 1], ...rotating.slice(0, -1)] + } + + return fixtures +} + +/** + * Splits ranked entrants into `groupCount` groups by snake seeding. + * + * Seeds run left to right across the groups, then right to left, so the groups + * stay as even in strength as the seeding allows. + */ +export const snakeIntoGroups = (ranked: T[], groupCount: number): T[][] => { + const groups: T[][] = Array.from({ length: groupCount }, () => []) + + ranked.forEach((entrant, index) => { + const row = Math.floor(index / groupCount) + const column = index % groupCount + const group = row % 2 === 0 ? column : groupCount - 1 - column + groups[group].push(entrant) + }) + + return groups +} diff --git a/src/types.ts b/src/types.ts index ecba255..ecbc9cc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -/** A competitor entered into the bracket. */ +/** A competitor entered into the tournament. */ export interface Participant { /** Caller-owned identifier written into match slots. Must be unique. */ registrationId: string @@ -12,17 +12,21 @@ export interface Participant { } /** - * Which bracket a match belongs to. + * Which part of the tournament a match belongs to. * - * `grandFinal` matches are only produced when the `grandFinal` option is - * enabled. + * `grandFinal` appears only when the `grandFinal` option is enabled, and + * `roundRobin` only in round robin tournaments. */ -export type BracketType = 'winners' | 'losers' | 'grandFinal' +export type MatchType = 'winners' | 'losers' | 'grandFinal' | 'roundRobin' -export interface BracketMatch { +/** @deprecated Renamed to {@link MatchType}. */ +export type BracketType = MatchType + +/** One match, in any format. Every generator returns this same shape. */ +export interface TournamentMatch { id: string eventId: string - /** 1-based round index, counted within `bracketType`. */ + /** 1-based round index, counted within `bracketType` (and within `group`). */ round: number /** 1-based index of the match within its round (`bracketPosition + 1`). */ matchNumber: number @@ -30,7 +34,7 @@ export interface BracketMatch { registration2Id: string | null /** 0-based index of the match within its round, top to bottom. */ bracketPosition: number - /** Match the winner advances to, or `null` if the match ends the bracket. */ + /** Match the winner advances to, or `null` if nobody advances from here. */ winnerTo: string | null /** Slot (1 or 2) the winner occupies in `winnerTo`. */ winnerToSlot: number | null @@ -38,13 +42,29 @@ export interface BracketMatch { loserTo: string | null /** Slot (1 or 2) the loser occupies in `loserTo`. */ loserToSlot: number | null - bracketType: BracketType + bracketType: MatchType + /** 0-based group index in a round robin group stage; `null` in brackets. */ + group: number | null + /** 1-based leg. Above 1 only in a multi-leg round robin. */ + leg: number } +/** @deprecated Renamed to {@link TournamentMatch}. */ +export type BracketMatch = TournamentMatch + export type IdFactory = () => string +/** Options every format shares. */ +export interface BaseOptions { + /** Identifier for the tournament, copied onto every match. */ + eventId: string + participants: Participant[] + /** Returns a unique id for each match, e.g. `() => crypto.randomUUID()`. */ + idFactory: IdFactory +} + /** - * How the winners-bracket winner and losers-bracket winner meet. + * How the winners bracket winner and losers bracket winner meet. * * - `'none'` (default): no grand final. The winners final decides 1st/2nd and * the losers final decides 3rd/4th. @@ -57,10 +77,7 @@ export type IdFactory = () => string */ export type GrandFinalFormat = 'none' | 'single' | 'reset' -export interface GeneratorOptions { - eventId: string - participants: Participant[] - idFactory: IdFactory +export interface DoubleEliminationOptions extends BaseOptions { /** * Number of rounds before the finals where the losers bracket begins. * Players who lose before this point are permanently eliminated. @@ -85,3 +102,110 @@ export interface GeneratorOptions { */ grandFinal?: GrandFinalFormat } + +/** @deprecated Renamed to {@link DoubleEliminationOptions}. */ +export type GeneratorOptions = DoubleEliminationOptions + +export interface SingleEliminationOptions extends BaseOptions { + /** + * Adds a match between the two semifinal losers. Defaults to `false`. + * + * Requires at least 3 participants, so that a semifinal round exists. + */ + thirdPlaceMatch?: boolean +} + +export interface RoundRobinOptions extends BaseOptions { + /** + * How many times everyone plays everyone. Defaults to `1`. + * + * Later legs repeat the same fixtures with the sides swapped, which is how a + * home-and-away league season is built. + */ + legs?: number + /** + * Split participants into this many groups, each playing its own round + * robin. Defaults to `1` (a single pool). + * + * Participants are distributed by snake seeding, so group strength stays + * even: seeds 1..G go to groups 1..G, seeds G+1..2G come back the other way. + */ + groupCount?: number +} + +export type TournamentFormat = + | 'single-elimination' + | 'double-elimination' + | 'round-robin' + +/** Options for {@link generateTournament}, discriminated by `format`. */ +export type TournamentOptions = + | ({ format: 'single-elimination' } & SingleEliminationOptions) + | ({ format: 'double-elimination' } & DoubleEliminationOptions) + | ({ format: 'round-robin' } & RoundRobinOptions) + +/** + * The outcome of one match, as reported by your application. + * + * Supply `score1`/`score2` when you track scores, or `winnerId` when you only + * track who won (`winnerId: null` records a draw). + */ +export interface MatchResult { + matchId: string + score1?: number + score2?: number + winnerId?: string | null +} + +/** Points awarded per outcome. Defaults to 3 / 1 / 0. */ +export interface PointsConfig { + win: number + draw: number + loss: number +} + +/** + * Comparisons applied, in order, to participants level on points. + * + * - `headToHead`: points, then score difference, in the matches the tied + * participants played against each other + * - `scoreDifference`: `scoreFor - scoreAgainst` + * - `scoreFor`: total scored + * - `wins`: number of wins + * - `seed`: the stronger seed ranks higher (a deterministic last resort) + */ +export type Tiebreaker = + | 'headToHead' + | 'scoreDifference' + | 'scoreFor' + | 'wins' + | 'seed' + +export interface StandingsOptions { + /** The matches to rank, typically the output of `generateRoundRobin`. */ + matches: TournamentMatch[] + /** Results recorded so far. Matches without a result count as unplayed. */ + results: MatchResult[] + /** Needed only for the `seed` tiebreaker and to order equal rows. */ + participants?: Participant[] + points?: Partial + /** Defaults to `['headToHead', 'scoreDifference', 'scoreFor', 'wins']`. */ + tiebreakers?: Tiebreaker[] +} + +/** One row of a standings table. */ +export interface Standing { + registrationId: string + /** The group this row belongs to, or `null` outside a group stage. */ + group: number | null + /** 1-based position within the group. Tied rows share a rank. */ + rank: number + played: number + won: number + drawn: number + lost: number + scoreFor: number + scoreAgainst: number + scoreDifference: number + points: number +} diff --git a/src/wireLoserRouting.ts b/src/wireLoserRouting.ts index 5b42d39..eb7c050 100644 --- a/src/wireLoserRouting.ts +++ b/src/wireLoserRouting.ts @@ -1,4 +1,4 @@ -import { BracketMatch } from './types.js' +import { TournamentMatch } from './types.js' /** * Reorders a round of winners bracket losers before they are dropped into the @@ -36,8 +36,8 @@ const CROSSOVER_ORDERINGS: PositionOrdering[] = [ /** Points every winners bracket match at the losers bracket match it feeds. */ export const wireLoserRouting = ( - winnersMatches: BracketMatch[], - losersMatches: BracketMatch[], + winnersMatches: TournamentMatch[], + losersMatches: TournamentMatch[], winnersRounds: number, startFromWbRound: number, winnersFinalFeedsLosers: boolean diff --git a/tests/byes.test.ts b/tests/byes.test.ts index c1c8d14..6a857e2 100644 --- a/tests/byes.test.ts +++ b/tests/byes.test.ts @@ -75,6 +75,24 @@ describe('bye handling', () => { } }) + it('marks a walkover whose entrant is not yet known', () => { + // 3 players: seed 1 has a bye, so only one player loses a semifinal and + // the third place match can never be contested. + const matches = generate(3) + const fed = fedSlots(matches) + + const willFill = (match: (typeof matches)[number], slot: 1 | 2) => + (slot === 1 ? match.registration1Id : match.registration2Id) !== null || + fed.has(`${match.id}#${slot}`) + + const thirdPlace = matches.find((m) => m.bracketType === 'losers')! + + // Exactly one side can arrive, so consumers can tell this apart from a + // match that is merely waiting on an earlier round. + expect(willFill(thirdPlace, 1) !== willFill(thirdPlace, 2)).toBe(true) + expect(thirdPlace.winnerTo).toBeNull() + }) + it.each([3, 5, 6, 7, 9, 11, 23, 33])( 'completes the losers bracket with %i participants', (count) => { diff --git a/tests/formats.test.ts b/tests/formats.test.ts new file mode 100644 index 0000000..bcb04ef --- /dev/null +++ b/tests/formats.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest' +import { + generateDoubleElimination, + generateRoundRobin, + generateSingleElimination, + generateTournament, + type TournamentMatch, +} from '../src' +import { + chalk, + createIdFactory, + createParticipants, + roundOf, + seededPicker, + simulate, +} from './helpers' + +describe('generateSingleElimination', () => { + it.each([2, 3, 4, 5, 8, 9, 16, 23])( + 'plays %i participants down to one champion', + (count) => { + const matches = generateSingleElimination({ + eventId: 'cup-1', + participants: createParticipants(count), + idFactory: createIdFactory(), + }) + + expect(matches.every((m) => m.bracketType === 'winners')).toBe(true) + + const { stalled, champion, losses } = simulate(matches, seededPicker(3)) + expect(stalled).toEqual([]) + expect(champion).not.toBeNull() + + // One loss and you are out, so nobody is beaten twice. + for (const [, beaten] of losses) expect(beaten).toBe(1) + } + ) + + it('eliminates every loser exactly once', () => { + const matches = generateSingleElimination({ + eventId: 'cup-1', + participants: createParticipants(8), + idFactory: createIdFactory(), + }) + + expect(matches).toHaveLength(7) + for (const match of matches) expect(match.loserTo).toBeNull() + }) + + it('adds a third place match on request', () => { + const matches = generateSingleElimination({ + eventId: 'cup-1', + participants: createParticipants(8), + idFactory: createIdFactory(), + thirdPlaceMatch: true, + }) + + const thirdPlace = matches.filter((m) => m.bracketType === 'losers') + expect(thirdPlace).toHaveLength(1) + + // Both semifinal losers, and nobody else, land in it. + const semifinals = roundOf(matches, 'winners', 2) + for (const semifinal of semifinals) { + expect(semifinal.loserTo).toBe(thirdPlace[0].id) + } + for (const first of roundOf(matches, 'winners', 1)) { + expect(first.loserTo).toBeNull() + } + }) + + it('sends the top two seeds to the final', () => { + const matches = generateSingleElimination({ + eventId: 'cup-1', + participants: createParticipants(16), + idFactory: createIdFactory(), + }) + + const { played } = simulate(matches, chalk) + const final = played[played.length - 1] + + expect([final.winner, final.loser].sort()).toEqual(['player-1', 'player-2']) + }) + + it('rejects a non-boolean thirdPlaceMatch', () => { + expect(() => + generateSingleElimination({ + eventId: 'cup-1', + participants: createParticipants(8), + idFactory: createIdFactory(), + // @ts-expect-error deliberately wrong type + thirdPlaceMatch: 'yes', + }) + ).toThrow('thirdPlaceMatch must be a boolean') + }) + + it('rejects a third place match without a semifinal', () => { + expect(() => + generateSingleElimination({ + eventId: 'cup-1', + participants: createParticipants(2), + idFactory: createIdFactory(), + thirdPlaceMatch: true, + }) + ).toThrow('requires at least 3 participants') + }) +}) + +describe('generateTournament', () => { + const participants = createParticipants(8) + const base = { eventId: 'event-1', participants } + + const sameShape = (a: TournamentMatch[], b: TournamentMatch[]) => { + const strip = (matches: TournamentMatch[]) => + matches.map(({ id, winnerTo, loserTo, ...rest }) => rest) + expect(strip(a)).toEqual(strip(b)) + } + + it('dispatches to single elimination', () => { + sameShape( + generateTournament({ + format: 'single-elimination', + ...base, + idFactory: createIdFactory(), + }), + generateSingleElimination({ ...base, idFactory: createIdFactory() }) + ) + }) + + it('dispatches to double elimination', () => { + sameShape( + generateTournament({ + format: 'double-elimination', + ...base, + idFactory: createIdFactory(), + grandFinal: 'reset', + }), + generateDoubleElimination({ + ...base, + idFactory: createIdFactory(), + grandFinal: 'reset', + }) + ) + }) + + it('dispatches to round robin', () => { + sameShape( + generateTournament({ + format: 'round-robin', + ...base, + idFactory: createIdFactory(), + legs: 2, + }), + generateRoundRobin({ ...base, idFactory: createIdFactory(), legs: 2 }) + ) + }) + + it('rejects an unknown format', () => { + expect(() => + generateTournament({ + // @ts-expect-error deliberately wrong value + format: 'swiss', + ...base, + idFactory: createIdFactory(), + }) + ).toThrow('Unknown format: "swiss"') + }) +}) + +describe('shared match shape', () => { + const participants = createParticipants(8) + + it('returns the same fields in every format', () => { + const formats = [ + generateSingleElimination({ + eventId: 'e', + participants, + idFactory: createIdFactory(), + }), + generateDoubleElimination({ + eventId: 'e', + participants, + idFactory: createIdFactory(), + grandFinal: 'reset', + }), + generateRoundRobin({ + eventId: 'e', + participants, + idFactory: createIdFactory(), + groupCount: 2, + }), + ] + + const expected = [ + 'bracketPosition', + 'bracketType', + 'eventId', + 'group', + 'id', + 'leg', + 'loserTo', + 'loserToSlot', + 'matchNumber', + 'registration1Id', + 'registration2Id', + 'round', + 'winnerTo', + 'winnerToSlot', + ] + + for (const matches of formats) { + for (const match of matches) { + expect(Object.keys(match).sort()).toEqual(expected) + expect(match.eventId).toBe('e') + } + } + }) +}) diff --git a/tests/roundRobin.test.ts b/tests/roundRobin.test.ts new file mode 100644 index 0000000..9a5c7c9 --- /dev/null +++ b/tests/roundRobin.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect } from 'vitest' +import { generateRoundRobin, type TournamentMatch } from '../src' +import { createIdFactory, createParticipants } from './helpers' + +const generate = (count: number, overrides = {}) => + generateRoundRobin({ + eventId: 'league-1', + participants: createParticipants(count), + idFactory: createIdFactory(), + ...overrides, + }) + +const pairingsOf = (matches: TournamentMatch[]): string[] => + matches.map((m) => [m.registration1Id, m.registration2Id].sort().join(' v ')) + +const roundsOf = (matches: TournamentMatch[], group: number | null = null) => { + const rounds = new Map() + for (const match of matches.filter((m) => m.group === group)) { + rounds.set(match.round, [...(rounds.get(match.round) ?? []), match]) + } + return rounds +} + +const SIZES = [2, 3, 4, 5, 6, 7, 8, 9, 12, 15, 16, 21] + +describe.each(SIZES)('round robin for %i participants', (count) => { + it('pairs every participant with every other exactly once', () => { + const matches = generate(count) + const pairings = pairingsOf(matches) + + expect(matches).toHaveLength((count * (count - 1)) / 2) + expect(new Set(pairings).size).toBe(pairings.length) + }) + + it('never schedules a participant twice in the same round', () => { + for (const [round, matches] of roundsOf(generate(count))) { + const players = matches.flatMap((m) => [ + m.registration1Id, + m.registration2Id, + ]) + expect(new Set(players).size, `round ${round}`).toBe(players.length) + } + }) + + it('runs the minimum number of rounds', () => { + const matches = generate(count) + const rounds = new Set(matches.map((m) => m.round)) + + // An even field plays every round; an odd field needs one extra round + // because somebody always sits out. + expect(rounds.size).toBe(count % 2 === 0 ? count - 1 : count) + }) + + it('rests each participant exactly once when the field is odd', () => { + if (count % 2 === 0) return + const matches = generate(count) + + for (const { registrationId } of createParticipants(count)) { + const played = new Set( + matches + .filter( + (m) => + m.registration1Id === registrationId || + m.registration2Id === registrationId + ) + .map((m) => m.round) + ) + expect(played.size).toBe(count - 1) + } + }) + + it('splits the two sides of the fixture as evenly as possible', () => { + const matches = generate(count) + const firstNamed = new Map() + for (const match of matches) { + const id = match.registration1Id! + firstNamed.set(id, (firstNamed.get(id) ?? 0) + 1) + } + + const gamesEach = count - 1 + for (const { registrationId } of createParticipants(count)) { + const home = firstNamed.get(registrationId) ?? 0 + // Perfect when everyone plays an even number of games, off by one + // otherwise, which is the best any schedule can do. + expect(Math.abs(home - (gamesEach - home))).toBeLessThanOrEqual(1) + } + }) + + it('leaves both participants known and nothing to advance', () => { + for (const match of generate(count)) { + expect(match.registration1Id).not.toBeNull() + expect(match.registration2Id).not.toBeNull() + expect(match.winnerTo).toBeNull() + expect(match.loserTo).toBeNull() + expect(match.bracketType).toBe('roundRobin') + expect(match.group).toBeNull() + expect(match.leg).toBe(1) + } + }) + + it('numbers matches contiguously within each round', () => { + for (const [, matches] of roundsOf(generate(count))) { + const ordered = [...matches].sort( + (a, b) => a.bracketPosition - b.bracketPosition + ) + ordered.forEach((match, index) => { + expect(match.bracketPosition).toBe(index) + expect(match.matchNumber).toBe(index + 1) + }) + } + }) +}) + +describe('seeded scheduling', () => { + it.each([4, 5, 8, 9, 16])( + 'saves the top two seeds for the final round with %i participants', + (count) => { + const matches = generate(count) + const lastRound = Math.max(...matches.map((m) => m.round)) + + const headliner = matches.find( + (m) => + [m.registration1Id, m.registration2Id].sort().join() === + ['player-1', 'player-2'].sort().join() + )! + + expect(headliner.round).toBe(lastRound) + } + ) + + it('opens with the widest mismatch', () => { + const matches = generate(8) + const openingRound = matches.filter((m) => m.round === 1) + + expect(pairingsOf(openingRound)).toContain('player-1 v player-8') + }) +}) + +describe('multiple legs', () => { + it('plays every pairing once per leg', () => { + const matches = generate(6, { legs: 2 }) + + expect(matches).toHaveLength(30) + const counts = new Map() + for (const pairing of pairingsOf(matches)) { + counts.set(pairing, (counts.get(pairing) ?? 0) + 1) + } + for (const [, played] of counts) expect(played).toBe(2) + }) + + it('swaps the sides in the return leg', () => { + const matches = generate(6, { legs: 2 }) + const firstLeg = matches.filter((m) => m.leg === 1) + const secondLeg = matches.filter((m) => m.leg === 2) + + for (const home of firstLeg) { + const reverse = secondLeg.find( + (m) => + m.registration1Id === home.registration2Id && + m.registration2Id === home.registration1Id + ) + expect(reverse, `return leg for ${home.id}`).toBeDefined() + } + }) + + it('gives everyone an even split of sides over two legs', () => { + const matches = generate(7, { legs: 2 }) + const firstNamed = new Map() + for (const match of matches) { + const id = match.registration1Id! + firstNamed.set(id, (firstNamed.get(id) ?? 0) + 1) + } + + for (const { registrationId } of createParticipants(7)) { + expect(firstNamed.get(registrationId)).toBe(6) + } + }) + + it('continues round numbering across legs', () => { + const matches = generate(4, { legs: 3 }) + const rounds = [...new Set(matches.map((m) => m.round))].sort( + (a, b) => a - b + ) + + expect(rounds).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]) + expect( + new Set(matches.filter((m) => m.round <= 3).map((m) => m.leg)) + ).toEqual(new Set([1])) + }) +}) + +describe('group stage', () => { + it('spreads seeds across groups by snake seeding', () => { + const matches = generate(8, { groupCount: 2 }) + + const membersOf = (group: number) => + new Set( + matches + .filter((m) => m.group === group) + .flatMap((m) => [m.registration1Id, m.registration2Id]) + ) + + // Seeds 1 and 2 lead separate groups; 3 and 4 come back the other way. + expect(membersOf(0)).toEqual( + new Set(['player-1', 'player-4', 'player-5', 'player-8']) + ) + expect(membersOf(1)).toEqual( + new Set(['player-2', 'player-3', 'player-6', 'player-7']) + ) + }) + + it('keeps groups within one participant of each other', () => { + const matches = generate(10, { groupCount: 3 }) + + const sizes = [0, 1, 2].map( + (group) => + new Set( + matches + .filter((m) => m.group === group) + .flatMap((m) => [m.registration1Id, m.registration2Id]) + ).size + ) + + expect(sizes.sort()).toEqual([3, 3, 4]) + }) + + it('never pairs participants from different groups', () => { + const matches = generate(12, { groupCount: 4 }) + const groupOf = new Map() + + for (const match of matches) { + for (const id of [match.registration1Id!, match.registration2Id!]) { + const known = groupOf.get(id) + if (known === undefined) groupOf.set(id, match.group!) + else expect(known).toBe(match.group) + } + } + + expect(groupOf.size).toBe(12) + }) + + it('numbers rounds from 1 within each group', () => { + const matches = generate(11, { groupCount: 2 }) + + for (const group of [0, 1]) { + const rounds = [ + ...new Set( + matches.filter((m) => m.group === group).map((m) => m.round) + ), + ].sort((a, b) => a - b) + expect(rounds[0]).toBe(1) + expect(rounds).toEqual(rounds.map((_, index) => index + 1)) + } + }) +}) + +describe('validation', () => { + it('rejects fewer than 2 participants', () => { + expect(() => generate(1)).toThrow('At least 2 participants required') + }) + + it('rejects a fractional number of legs', () => { + expect(() => generate(4, { legs: 1.5 })).toThrow( + 'legs must be an integer of at least 1' + ) + }) + + it('rejects zero legs', () => { + expect(() => generate(4, { legs: 0 })).toThrow( + 'legs must be an integer of at least 1' + ) + }) + + it('rejects more groups than participants can fill', () => { + expect(() => generate(5, { groupCount: 3 })).toThrow( + 'leaves a group with fewer than 2 participants' + ) + }) + + it('rejects duplicate seeds', () => { + expect(() => + generateRoundRobin({ + eventId: 'league-1', + idFactory: createIdFactory(), + participants: [ + { registrationId: 'a', seed: 1 }, + { registrationId: 'b', seed: 1 }, + ], + }) + ).toThrow('Duplicate seed: 1') + }) + + it('rejects an idFactory that repeats ids', () => { + expect(() => + generateRoundRobin({ + eventId: 'league-1', + participants: createParticipants(4), + idFactory: () => 'same-id', + }) + ).toThrow('duplicate id') + }) +}) diff --git a/tests/standings.test.ts b/tests/standings.test.ts new file mode 100644 index 0000000..d21bd85 --- /dev/null +++ b/tests/standings.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect } from 'vitest' +import { + calculateStandings, + generateRoundRobin, + type MatchResult, + type TournamentMatch, +} from '../src' +import { createIdFactory, createParticipants } from './helpers' + +const generate = (count: number, overrides = {}) => + generateRoundRobin({ + eventId: 'league-1', + participants: createParticipants(count), + idFactory: createIdFactory(), + ...overrides, + }) + +/** Records a scoreline for the match between two named participants. */ +const score = ( + matches: TournamentMatch[], + a: string, + b: string, + scoreA: number, + scoreB: number +): MatchResult => { + const match = matches.find( + (m) => + (m.registration1Id === a && m.registration2Id === b) || + (m.registration1Id === b && m.registration2Id === a) + ) + if (!match) throw new Error(`no match between ${a} and ${b}`) + + return match.registration1Id === a + ? { matchId: match.id, score1: scoreA, score2: scoreB } + : { matchId: match.id, score1: scoreB, score2: scoreA } +} + +const order = (standings: { registrationId: string }[]) => + standings.map((row) => row.registrationId) + +describe('calculateStandings', () => { + it('lists every participant before anything is played', () => { + const matches = generate(4) + const standings = calculateStandings({ matches, results: [] }) + + expect(standings).toHaveLength(4) + for (const row of standings) { + expect(row.played).toBe(0) + expect(row.points).toBe(0) + expect(row.rank).toBe(1) // all level + } + }) + + it('counts wins, draws, losses, scores and points', () => { + const matches = generate(3) + const results = [ + score(matches, 'player-1', 'player-2', 3, 1), + score(matches, 'player-1', 'player-3', 2, 2), + score(matches, 'player-2', 'player-3', 0, 4), + ] + + const standings = calculateStandings({ matches, results }) + const row = (id: string) => standings.find((s) => s.registrationId === id)! + + expect(row('player-1')).toMatchObject({ + played: 2, + won: 1, + drawn: 1, + lost: 0, + scoreFor: 5, + scoreAgainst: 3, + scoreDifference: 2, + points: 4, + }) + // Level on points and level head-to-head (2-2), so score difference + // decides and player-3 takes top spot. + expect(row('player-3')).toMatchObject({ + played: 2, + won: 1, + drawn: 1, + lost: 0, + scoreFor: 6, + scoreAgainst: 2, + scoreDifference: 4, + points: 4, + rank: 1, + }) + expect(row('player-1').rank).toBe(2) + expect(row('player-2')).toMatchObject({ + played: 2, + won: 0, + drawn: 0, + lost: 2, + points: 0, + rank: 3, + }) + }) + + it('counts only the matches that have results', () => { + const matches = generate(4) + const standings = calculateStandings({ + matches, + results: [score(matches, 'player-1', 'player-4', 1, 0)], + }) + + expect(standings.find((s) => s.registrationId === 'player-1')!.played).toBe( + 1 + ) + expect(standings.find((s) => s.registrationId === 'player-2')!.played).toBe( + 0 + ) + }) + + it('accepts results without scores', () => { + const matches = generate(3) + const byWinner = matches.map((match) => ({ + matchId: match.id, + winnerId: match.registration1Id, + })) + + const standings = calculateStandings({ matches, results: byWinner }) + + expect(standings.reduce((total, row) => total + row.won, 0)).toBe(3) + expect(standings.reduce((total, row) => total + row.drawn, 0)).toBe(0) + }) + + it('records a draw for a null winnerId', () => { + const matches = generate(3) + const standings = calculateStandings({ + matches, + results: matches.map((match) => ({ matchId: match.id, winnerId: null })), + }) + + for (const row of standings) { + expect(row.drawn).toBe(row.played) + expect(row.points).toBe(row.played) + } + }) + + it('honours a custom points table', () => { + const matches = generate(3) + const results = [score(matches, 'player-1', 'player-2', 1, 0)] + + const standings = calculateStandings({ + matches, + results, + points: { win: 2, draw: 0, loss: -1 }, + }) + + expect(standings.find((s) => s.registrationId === 'player-1')!.points).toBe( + 2 + ) + expect(standings.find((s) => s.registrationId === 'player-2')!.points).toBe( + -1 + ) + }) +}) + +describe('tiebreakers', () => { + it('separates level teams on head-to-head first', () => { + const matches = generate(4) + // player-2 and player-3 both finish on 6 points, but player-3 won between + // them; player-2 has the better goal difference, which must not count yet. + const results = [ + score(matches, 'player-1', 'player-2', 0, 1), + score(matches, 'player-1', 'player-3', 1, 0), + score(matches, 'player-1', 'player-4', 0, 1), + score(matches, 'player-2', 'player-3', 0, 1), + score(matches, 'player-2', 'player-4', 5, 0), + score(matches, 'player-3', 'player-4', 1, 0), + ] + + const standings = calculateStandings({ matches, results }) + + expect(order(standings).slice(0, 2)).toEqual(['player-3', 'player-2']) + }) + + it('falls back to score difference when head-to-head is level', () => { + const matches = generate(4) + const results = [ + score(matches, 'player-1', 'player-2', 1, 1), + score(matches, 'player-1', 'player-3', 4, 0), + score(matches, 'player-1', 'player-4', 0, 1), + score(matches, 'player-2', 'player-3', 1, 0), + score(matches, 'player-2', 'player-4', 0, 1), + score(matches, 'player-3', 'player-4', 0, 1), + ] + + const standings = calculateStandings({ matches, results }) + const one = standings.find((s) => s.registrationId === 'player-1')! + const two = standings.find((s) => s.registrationId === 'player-2')! + + expect(one.points).toBe(two.points) + expect(one.rank).toBeLessThan(two.rank) + }) + + it('applies tiebreakers in the order given', () => { + const matches = generate(4) + const results = [ + score(matches, 'player-1', 'player-2', 0, 1), + score(matches, 'player-1', 'player-3', 1, 0), + score(matches, 'player-1', 'player-4', 0, 1), + score(matches, 'player-2', 'player-3', 0, 1), + score(matches, 'player-2', 'player-4', 5, 0), + score(matches, 'player-3', 'player-4', 1, 0), + ] + + // Same results as the head-to-head test, but score difference leads. + const standings = calculateStandings({ + matches, + results, + tiebreakers: ['scoreDifference', 'headToHead'], + }) + + expect(order(standings).slice(0, 2)).toEqual(['player-2', 'player-3']) + }) + + it('shares a rank when nothing separates two participants', () => { + const matches = generate(3) + const standings = calculateStandings({ + matches, + results: matches.map((match) => ({ matchId: match.id, winnerId: null })), + }) + + expect(standings.map((row) => row.rank)).toEqual([1, 1, 1]) + }) + + it('numbers ranks so that a shared rank consumes the places below it', () => { + const matches = generate(4) + const results = [ + score(matches, 'player-1', 'player-2', 1, 0), + score(matches, 'player-1', 'player-3', 1, 0), + score(matches, 'player-1', 'player-4', 1, 0), + score(matches, 'player-2', 'player-3', 0, 0), + score(matches, 'player-2', 'player-4', 0, 0), + score(matches, 'player-3', 'player-4', 0, 0), + ] + + const standings = calculateStandings({ matches, results }) + + expect(standings.map((row) => row.rank)).toEqual([1, 2, 2, 2]) + }) + + it('can break a tie on seed as a last resort', () => { + const matches = generate(3) + const participants = createParticipants(3) + const standings = calculateStandings({ + matches, + participants, + results: matches.map((match) => ({ matchId: match.id, winnerId: null })), + tiebreakers: ['seed'], + }) + + expect(order(standings)).toEqual(['player-1', 'player-2', 'player-3']) + expect(standings.map((row) => row.rank)).toEqual([1, 2, 3]) + }) +}) + +describe('group standings', () => { + it('ranks each group independently', () => { + const matches = generate(8, { groupCount: 2 }) + const results = matches.map((match) => ({ + matchId: match.id, + // The stronger seed always wins. + winnerId: + Number(match.registration1Id!.replace('player-', '')) < + Number(match.registration2Id!.replace('player-', '')) + ? match.registration1Id + : match.registration2Id, + })) + + const standings = calculateStandings({ matches, results }) + + expect(standings).toHaveLength(8) + expect( + standings.filter((row) => row.group === 0).map((row) => row.rank) + ).toEqual([1, 2, 3, 4]) + expect(standings.filter((row) => row.group === 1)[0].registrationId).toBe( + 'player-2' + ) + expect(standings.filter((row) => row.group === 0)[0].registrationId).toBe( + 'player-1' + ) + }) + + it('keeps head-to-head inside the group', () => { + const matches = generate(8, { groupCount: 2 }) + const standings = calculateStandings({ matches, results: [] }) + + for (const row of standings) { + expect([0, 1]).toContain(row.group) + expect(row.rank).toBe(1) + } + }) +}) + +describe('standings validation', () => { + it('rejects a result for an unknown match', () => { + const matches = generate(3) + expect(() => + calculateStandings({ + matches, + results: [{ matchId: 'nope', score1: 1, score2: 0 }], + }) + ).toThrow('Result references unknown match: "nope"') + }) + + it('rejects two results for the same match', () => { + const matches = generate(3) + expect(() => + calculateStandings({ + matches, + results: [ + { matchId: matches[0].id, score1: 1, score2: 0 }, + { matchId: matches[0].id, score1: 0, score2: 1 }, + ], + }) + ).toThrow('Duplicate result for match') + }) + + it('rejects half a scoreline', () => { + const matches = generate(3) + expect(() => + calculateStandings({ + matches, + results: [{ matchId: matches[0].id, score1: 1 }], + }) + ).toThrow('needs both score1 and score2, or neither') + }) + + it('rejects a result with no outcome at all', () => { + const matches = generate(3) + expect(() => + calculateStandings({ matches, results: [{ matchId: matches[0].id }] }) + ).toThrow('needs score1 and score2, or a winnerId') + }) + + it('rejects a winner who did not play in the match', () => { + const matches = generate(3) + expect(() => + calculateStandings({ + matches, + results: [{ matchId: matches[0].id, winnerId: 'stranger' }], + }) + ).toThrow('did not play in match') + }) + + it('rejects points as a tiebreaker, since it always applies first', () => { + const matches = generate(3) + expect(() => + calculateStandings({ + matches, + results: [], + // @ts-expect-error points is not a tiebreaker + tiebreakers: ['points'], + }) + ).toThrow('Unknown tiebreaker: "points"') + }) + + it('rejects an unknown tiebreaker', () => { + const matches = generate(3) + expect(() => + calculateStandings({ + matches, + results: [], + // @ts-expect-error deliberately wrong value + tiebreakers: ['coinToss'], + }) + ).toThrow('Unknown tiebreaker: "coinToss"') + }) +}) diff --git a/website/src/components/BracketDemo.tsx b/website/src/components/BracketDemo.tsx index fc83153..7fb59ad 100644 --- a/website/src/components/BracketDemo.tsx +++ b/website/src/components/BracketDemo.tsx @@ -1,65 +1,126 @@ import { useState, useMemo } from 'react' import { motion } from 'framer-motion' -import { generateDoubleElimination } from 'double-elimination' +import { generateTournament, type TournamentOptions } from 'double-elimination' import { Slider } from '@/components/ui/slider' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Badge } from '@/components/ui/badge' import BracketVisualization from './BracketVisualization' +import RoundRobinVisualization from './RoundRobinVisualization' -type FormatType = 'double' | 'single-3rd' | 'single' +type DemoFormat = + | 'double' + | 'double-gf' + | 'single' + | 'single-3rd' + | 'round-robin' + +const FORMAT_LABELS: Record = { + double: 'Double Elim', + 'double-gf': 'DE + Grand Final', + single: 'Single Elim', + 'single-3rd': 'SE + 3rd', + 'round-robin': 'Round Robin', +} + +const FORMAT_BLURBS: Record = { + double: 'Winners final decides 1st/2nd, losers final decides 3rd/4th.', + 'double-gf': + 'The winners final loser drops to the losers final, then both bracket winners meet — with a bracket reset if the comeback lands.', + single: 'One loss and you are out.', + 'single-3rd': 'Knockout, with the two semifinal losers playing for bronze.', + 'round-robin': + 'Everyone plays everyone, then the table sorts it out. Try legs and groups.', +} const BracketDemo = () => { const [participantCount, setParticipantCount] = useState(8) - const [format, setFormat] = useState('double') + const [format, setFormat] = useState('double') + const [legs, setLegs] = useState(1) + const [groupCount, setGroupCount] = useState(1) - const formatConfig: Record = { - double: undefined, - 'single-3rd': 1, - single: 0, - } + const isRoundRobin = format === 'round-robin' + // A full round robin grows quadratically, so keep the demo responsive. + const maxParticipants = isRoundRobin ? 24 : 128 + const cappedCount = Math.min(participantCount, maxParticipants) + const maxGroups = Math.max(1, Math.floor(cappedCount / 2)) + const cappedGroups = Math.min(groupCount, maxGroups) - const matches = useMemo(() => { - const participants = Array.from({ length: participantCount }, (_, i) => ({ - registrationId: `player-${i + 1}`, - seed: i + 1, - })) + const participants = useMemo( + () => + Array.from({ length: cappedCount }, (_, i) => ({ + registrationId: `player-${i + 1}`, + seed: i + 1, + })), + [cappedCount] + ) + const matches = useMemo(() => { let counter = 0 - return generateDoubleElimination({ + const base = { eventId: 'demo', participants, idFactory: () => `match-${++counter}`, - losersStartRoundsBeforeFinal: formatConfig[format], - }) - }, [participantCount, format]) + } + + const options: TournamentOptions = + format === 'round-robin' + ? { format: 'round-robin', ...base, legs, groupCount: cappedGroups } + : format === 'single' + ? { format: 'single-elimination', ...base } + : format === 'single-3rd' + ? { format: 'single-elimination', ...base, thirdPlaceMatch: true } + : { + format: 'double-elimination', + ...base, + grandFinal: format === 'double-gf' ? 'reset' : 'none', + } + + return generateTournament(options) + }, [participants, format, legs, cappedGroups]) const winnersMatches = matches.filter((m) => m.bracketType === 'winners') const losersMatches = matches.filter((m) => m.bracketType === 'losers') + const grandFinalMatches = matches.filter((m) => m.bracketType === 'grandFinal') + + // Rounds are numbered from 1 within each bracket and each group, so the + // highest round number is how long the longest of them runs. + const rounds = Math.max(...matches.map((m) => m.round), 0) + + // In a group stage the field is split, so nobody plays everyone. + const gamesEach = useMemo(() => { + const appearances = new Map() + for (const match of matches) { + for (const id of [match.registration1Id, match.registration2Id]) { + if (id) appearances.set(id, (appearances.get(id) ?? 0) + 1) + } + } + return Math.max(...appearances.values(), 0) + }, [matches]) return ( -
-
+
-

- Interactive Demo + Interactive Demo

-

- See the bracket generator in action. Adjust participants and format to - explore different tournament structures. +

+ Every format the package generates, live. Adjust the field and the + format to see what comes out.

@@ -70,82 +131,220 @@ const BracketDemo = () => { transition={{ duration: 0.6, delay: 0.1 }} style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }} > -
-

Configuration

- -
+ +
- setParticipantCount(value)} min={4} - max={128} + max={maxParticipants} step={1} - className="w-full max-w-md" + className='w-full max-w-md' />
- setFormat(v as FormatType)} + onValueChange={(v) => setFormat(v as DemoFormat)} > - - - Double Elim - - - SE + 3rd - - - Single Elim - + + {(Object.keys(FORMAT_LABELS) as DemoFormat[]).map((key) => ( + + {FORMAT_LABELS[key]} + + ))} +

+ {FORMAT_BLURBS[format]} +

-
+
+ + setLegs(value)} + min={1} + max={3} + step={1} + className='w-full max-w-xs' + /> +
+
+ + setGroupCount(value)} + min={1} + max={Math.min(maxGroups, 8)} + step={1} + className='w-full max-w-xs' + /> +
+
+ )} + +
- - {matches.length} total - - - {winnersMatches.length} winners + + + {matches.length} + {' '} + matches - - {losersMatches.length} losers + + + {rounds} + {' '} + rounds + {isRoundRobin ? ( + + + {gamesEach} + {' '} + games each + + ) : ( + <> + + + {winnersMatches.length} + {' '} + winners + + {losersMatches.length > 0 && ( + + + {losersMatches.length} + {' '} + {format === 'single-3rd' ? 'third place' : 'losers'} + + )} + {grandFinalMatches.length > 0 && ( + + + {grandFinalMatches.length} + {' '} + grand final + + )} + + )}
- + {isRoundRobin ? ( + + ) : ( + + )}
diff --git a/website/src/components/BracketVisualization.tsx b/website/src/components/BracketVisualization.tsx index 057c8e0..7ebb90d 100644 --- a/website/src/components/BracketVisualization.tsx +++ b/website/src/components/BracketVisualization.tsx @@ -1,21 +1,23 @@ import { useMemo } from 'react' -import type { BracketMatch } from 'double-elimination' +import type { TournamentMatch } from 'double-elimination' interface Props { - winnersMatches: BracketMatch[] - losersMatches: BracketMatch[] + winnersMatches: TournamentMatch[] + losersMatches: TournamentMatch[] + grandFinalMatches?: TournamentMatch[] participantCount: number } const BracketVisualization = ({ winnersMatches, losersMatches, + grandFinalMatches = [], participantCount, }: Props) => { const { winnersByRound, losersByRound, maxWinnersRound, maxLosersRound } = useMemo(() => { - const winnersByRound: Record = {} - const losersByRound: Record = {} + const winnersByRound: Record = {} + const losersByRound: Record = {} winnersMatches.forEach((m) => { if (!winnersByRound[m.round]) winnersByRound[m.round] = [] @@ -162,6 +164,32 @@ const BracketVisualization = ({ No losers bracket in single elimination )} + + {grandFinalMatches.length > 0 && ( +
+
+ + + Grand Final + + + {grandFinalMatches.length}{' '} + {grandFinalMatches.length === 1 ? 'match' : 'matches'} + +
+

+ {grandFinalMatches.length > 1 + ? 'Match 2 is the bracket reset, played only if the losers bracket winner takes match 1.' + : 'Winners bracket winner vs losers bracket winner.'} +

+
+ )} @@ -180,7 +208,7 @@ const BracketVisualization = ({ return `#${num}` } - const renderMatch = (match: BracketMatch, yOffset: number) => ( + const renderMatch = (match: TournamentMatch, yOffset: number) => ( { @@ -319,6 +347,49 @@ const BracketVisualization = ({ )} + + {grandFinalMatches.length > 0 && ( +
+
+

+ + Grand Final +

+
+
+ {grandFinalMatches + .slice() + .sort((a, b) => a.round - b.round) + .map((match) => ( +
+
+ {match.round === 1 ? 'Grand Final' : 'Bracket Reset'} +
+
+ {match.round === 1 + ? 'Winners bracket winner vs losers bracket winner' + : 'Played only if the losers bracket winner takes match 1'} +
+
+ ))} +
+
+ )} ) } diff --git a/website/src/components/CodeExample.tsx b/website/src/components/CodeExample.tsx index c03bb31..fa7e5c1 100644 --- a/website/src/components/CodeExample.tsx +++ b/website/src/components/CodeExample.tsx @@ -5,40 +5,64 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' const doubleEliminationCode = `import { generateDoubleElimination } from 'double-elimination' const matches = generateDoubleElimination({ - eventId: 'championship-2024', + eventId: 'spring-major', participants: [ { registrationId: 'player-1', seed: 1 }, { registrationId: 'player-2', seed: 2 }, // ... more participants ], idFactory: () => crypto.randomUUID(), -})` + grandFinal: 'reset', // 'none' | 'single' | 'reset' +}) -const singleEliminationCode = `import { generateDoubleElimination } from 'double-elimination' +// Winners bracket, losers bracket and the grand final, +// already wired together through winnerTo / loserTo.` -// Pure single elimination (no losers bracket) -const matches = generateDoubleElimination({ +const singleEliminationCode = `import { generateSingleElimination } from 'double-elimination' + +const matches = generateSingleElimination({ eventId: 'knockout-cup', participants: teams.map((team, i) => ({ registrationId: team.id, seed: i + 1, })), idFactory: () => crypto.randomUUID(), - losersStartRoundsBeforeFinal: 0, -})` + thirdPlaceMatch: true, // semifinal losers play for bronze +}) -const thirdPlaceCode = `import { generateDoubleElimination } from 'double-elimination' +// 16 teams -> 15 matches, plus the third place match. +// Byes are pre-resolved, so odd counts just work.` -// Single elimination with 3rd place match -const matches = generateDoubleElimination({ - eventId: 'world-cup', - participants: countries.map((c, i) => ({ - registrationId: c.code, +const roundRobinCode = `import { generateRoundRobin } from 'double-elimination' + +const fixtures = generateRoundRobin({ + eventId: 'league-2026', + participants: clubs.map((club, i) => ({ + registrationId: club.id, seed: i + 1, })), idFactory: () => crypto.randomUUID(), - losersStartRoundsBeforeFinal: 1, -})` + legs: 2, // home and away + groupCount: 1, // or split into snake-seeded groups +}) + +// Everyone plays everyone: seeded fixtures, balanced +// sides, and a rest round when the field is odd.` + +const standingsCode = `import { calculateStandings } from 'double-elimination' + +const table = calculateStandings({ + matches: fixtures, + results: [ + { matchId: fixtures[0].id, score1: 2, score2: 1 }, + { matchId: fixtures[1].id, winnerId: null }, // a draw + ], + points: { win: 3, draw: 1, loss: 0 }, + tiebreakers: ['headToHead', 'scoreDifference', 'wins'], +}) + +// -> [{ rank, registrationId, played, won, drawn, +// lost, scoreFor, scoreAgainst, points }, ...]` interface CodeBlockProps { code: string @@ -92,8 +116,8 @@ const CodeExample = () => { Simple API

- One function, multiple tournament formats. - Configure with a single option to switch between SE and DE. + A generator per format, or one generateTournament call when the + format is data. Same match shape either way.

@@ -110,15 +134,18 @@ const CodeExample = () => { style={{ padding: '1rem', gap: '0.75rem' }} >

Usage Examples

- + - Double + Double Elim - Single + Single Elim - - 3rd Place + + Round Robin + + + Standings @@ -128,8 +155,11 @@ const CodeExample = () => { - - + + + + + diff --git a/website/src/components/Features.tsx b/website/src/components/Features.tsx index b913259..42a3f2c 100644 --- a/website/src/components/Features.tsx +++ b/website/src/components/Features.tsx @@ -3,21 +3,21 @@ import { motion } from 'framer-motion' const features = [ { icon: '🏆', - title: 'SE + DE Support', + title: 'Three Formats', description: - 'Full single elimination, double elimination, or anything in between with the flexible losersStartRoundsBeforeFinal option.', + 'Double elimination, single elimination and round robin — all returning the same match shape, so one renderer serves every format.', }, { - icon: '⚡', - title: 'Zero Dependencies', + icon: '🥇', + title: 'Grand Final & Reset', description: - 'Lightweight and fast. No external runtime dependencies means smaller bundle sizes and fewer security concerns.', + 'Opt into a real double elimination finish: the winners final loser drops to the losers final, and a bracket reset when the comeback lands.', }, { - icon: '🔷', - title: 'TypeScript First', + icon: '📊', + title: 'Standings & Tiebreakers', description: - 'Built with TypeScript from the ground up. Full type definitions included for excellent IDE support and type safety.', + 'League tables from your results, with a configurable points system and head-to-head, score difference, score for, wins and seed tiebreakers.', }, { icon: '🎯', @@ -27,15 +27,33 @@ const features = [ }, { icon: '✨', - title: 'Automatic Byes', + title: 'Byes Done Right', description: - 'Handles any participant count, not just powers of 2. Byes are automatically calculated and pre-resolved.', + 'Any participant count, not just powers of 2. Walkovers are pre-resolved through the whole bracket, so nothing ever stalls on a missing opponent.', }, { icon: '🔄', title: 'Rematch Prevention', description: - 'Intelligent loser routing prevents early rematches in the losers bracket through strategic cross-bracket matchups.', + 'Rotating loser routing keeps players away from opponents they already beat, pushing the first possible rematch deep into the losers bracket.', + }, + { + icon: '🗓️', + title: 'Leagues & Groups', + description: + 'Circle-method fixtures with balanced sides, home-and-away legs, and snake-seeded group stages that feed straight into a playoff bracket.', + }, + { + icon: '⚡', + title: 'Zero Dependencies', + description: + 'Lightweight and fast. ESM and CommonJS builds, no runtime dependencies, smaller bundles and fewer security concerns.', + }, + { + icon: '🔷', + title: 'TypeScript First', + description: + 'Built with TypeScript from the ground up. Full type definitions included for excellent IDE support and type safety.', }, ] diff --git a/website/src/components/Hero.tsx b/website/src/components/Hero.tsx index 2c6c2d6..bf9d7d1 100644 --- a/website/src/components/Hero.tsx +++ b/website/src/components/Hero.tsx @@ -24,7 +24,7 @@ const Hero = () => { style={{ gap: '0.5rem', marginBottom: '2rem' }} > - v1.2.2 + v2.0.0 Zero Dependencies @@ -42,10 +42,10 @@ const Hero = () => { style={{ marginBottom: '1.5rem' }} > - Tournament Brackets + Every Tournament
- Made Simple + From One Package { className="text-base sm:text-xl md:text-2xl text-muted max-w-2xl px-2" style={{ marginBottom: '2.5rem' }} > - Generate single & double elimination brackets with automatic seeding, - bye handling, and full TypeScript support. + Double elimination, single elimination and round robin — with standard + seeding, byes handled end to end, grand finals, group stages and + standings. { + const next = (seed * 1103515245 + 12345) & 0x7fffffff + return next % 4 +} + +const RoundRobinVisualization = ({ + matches, + participants, + showSampleResults, +}: Props) => { + const groups = useMemo( + () => [...new Set(matches.map((m) => m.group))], + [matches] + ) + + const results = useMemo(() => { + if (!showSampleResults) return [] + return matches.map((match, index) => ({ + matchId: match.id, + score1: sampleScore(index + 1), + score2: sampleScore(index + 7), + })) + }, [matches, showSampleResults]) + + const standings = useMemo( + () => calculateStandings({ matches, results, participants }), + [matches, results, participants] + ) + + const roundsOf = (group: number | null) => { + const rounds = new Map() + for (const match of matches.filter((m) => m.group === group)) { + rounds.set(match.round, [...(rounds.get(match.round) ?? []), match]) + } + for (const [, inRound] of rounds) { + inRound.sort((a, b) => a.bracketPosition - b.bracketPosition) + } + return [...rounds.entries()].sort((a, b) => a[0] - b[0]) + } + + const shortName = (id: string | null) => + (id ?? '').replace('player-', 'P') + + return ( +
+ {groups.map((group) => { + const rounds = roundsOf(group) + const groupStandings = standings.filter((row) => row.group === group) + + return ( +
+
+
+

+ + {group === null ? 'Fixtures' : `Group ${group + 1} fixtures`} +

+ + {rounds.length} rounds + +
+ +
+ {rounds.map(([round, inRound]) => ( +
+
+ Round {round} + {inRound[0].leg > 1 ? ` · leg ${inRound[0].leg}` : ''} +
+
+ {inRound.map((match) => ( +
+ + {shortName(match.registration1Id)} + + v + + {shortName(match.registration2Id)} + +
+ ))} +
+
+ ))} +
+
+ +
+
+

+ + {group === null ? 'Standings' : `Group ${group + 1} table`} +

+ + {showSampleResults ? 'sample results' : 'no results yet'} + +
+ +
+ + + + + + + + + + + + + + + {groupStandings.map((row) => ( + + + + + + + + + + + ))} + +
+ # + + Player + + P + + W + + D + + L + + SD + + Pts +
+ {row.rank} + + {shortName(row.registrationId)} + + {row.played} + + {row.won} + + {row.drawn} + + {row.lost} + + {row.scoreDifference > 0 ? '+' : ''} + {row.scoreDifference} + + {row.points} +
+
+
+
+ ) + })} +
+ ) +} + +export default RoundRobinVisualization From 58c317a2851d755a364884f8f4b925e55365c15c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 21:44:30 +0000 Subject: [PATCH 3/3] Fix demo scorelines and tie-unsafe qualifier recipes Review follow-ups on the 2.0.0 branch. - The website's sample scoreline generator took a single step of a linear congruential generator on consecutive inputs, which walks a short cycle: every sample match came out a two-goal win with alternating sides and never a draw, so the demo standings table always showed a draw column of zeroes. Mix the bits instead, which gives varied scorelines and about one draw in four. - The documented "top two from every group" recipe filtered on `rank <= 2`, which is unsound by the library's own semantics: participants nothing separates share a rank, so a three-way tie returns three rows, and the next snippet fed them straight into a bracket. The recipes now end the tiebreakers with `seed`, which is unique and therefore always decides, and say what happens when you leave it out. Covered by a test that draws every match in a group stage and asserts the ranks still come out 1, 2, 3, 4. Also: the marketing page's feature heading and footer said "brackets", which no longer covers round robin, and the usage examples had no `generateTournament` tab despite the section promising one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M6MiM4MbcS4dhfwzbZBi4K --- EXAMPLES.md | 32 +++++++++++++++++-- README.md | 13 +++++++- tests/standings.test.ts | 27 ++++++++++++++++ website/src/components/CodeExample.tsx | 22 +++++++++++++ website/src/components/Features.tsx | 7 ++-- website/src/components/Footer.tsx | 2 +- .../components/RoundRobinVisualization.tsx | 18 ++++++++--- 7 files changed, 108 insertions(+), 13 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 94c19e1..8cda17c 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -273,10 +273,19 @@ const table2 = calculateStandings({ ### Qualifiers from a group stage +Participants nothing separates share a rank, so `rank <= 2` can return three +rows from one group. When you need a strict cut, end the tiebreakers with +`seed`: seeds are unique, so it always decides. + ```typescript -const table = calculateStandings({ matches: groups, results }) +const table = calculateStandings({ + matches: groups, + results, + participants, // required by the 'seed' tiebreaker + tiebreakers: ['headToHead', 'scoreDifference', 'scoreFor', 'wins', 'seed'], +}) -// Top two from every group +// Exactly the top two from every group const qualifiers = table.filter((row) => row.rank <= 2) // Best third-placed teams across groups @@ -286,6 +295,17 @@ const thirds = table .slice(0, 4) ``` +Without `seed`, inspect the ties instead of cutting through them: + +```typescript +const tied = table.filter( + (row) => + table.filter( + (other) => other.group === row.group && other.rank === row.rank + ).length > 1 +) +``` + ## Group Stage into a Playoff Bracket The common real-world shape: pools first, then a knockout bracket seeded by how @@ -301,7 +321,13 @@ const groupStage = generateRoundRobin({ // ...play the group stage, collecting results... -const table = calculateStandings({ matches: groupStage, results }) +const table = calculateStandings({ + matches: groupStage, + results, + participants, + // 'seed' last, so two teams per group qualify and never three + tiebreakers: ['headToHead', 'scoreDifference', 'scoreFor', 'wins', 'seed'], +}) // Group winners are seeded above runners-up, then by points const qualifiers = table diff --git a/README.md b/README.md index 490d4c7..6ddbc67 100644 --- a/README.md +++ b/README.md @@ -519,11 +519,22 @@ there is only one pool. ### Standings and tiebreakers ```typescript -const table = calculateStandings({ matches: groupStage, results }) +const table = calculateStandings({ + matches: groupStage, + results, + participants, // required by the 'seed' tiebreaker below + // Ranks are shared when nothing separates two participants, so end with + // 'seed' whenever you need a strict cut — seeds are unique, so it always + // decides, and every rank becomes a distinct position. + tiebreakers: ['headToHead', 'scoreDifference', 'scoreFor', 'wins', 'seed'], +}) const qualifiers = table.filter((row) => row.rank <= 2) ``` +Leave `seed` out and a three-way tie really does give you three rows at rank 1 — +which is the honest answer, and the one to show a human before a playoff draw. + Rows are ranked on points first, then by each tiebreaker in turn — and each tiebreaker only applies to the rows the previous one left level, exactly as a real competition rulebook works: diff --git a/tests/standings.test.ts b/tests/standings.test.ts index d21bd85..23b6d43 100644 --- a/tests/standings.test.ts +++ b/tests/standings.test.ts @@ -283,6 +283,33 @@ describe('group standings', () => { ) }) + it('gives a strict cut per group when seed is the last tiebreaker', () => { + const matches = generate(12, { groupCount: 3 }) + // Every match drawn: nothing but seed can separate anyone. + const standings = calculateStandings({ + matches, + participants: createParticipants(12), + results: matches.map((match) => ({ matchId: match.id, winnerId: null })), + tiebreakers: [ + 'headToHead', + 'scoreDifference', + 'scoreFor', + 'wins', + 'seed', + ], + }) + + for (const group of [0, 1, 2]) { + const ranks = standings + .filter((row) => row.group === group) + .map((row) => row.rank) + // Distinct positions, so `rank <= 2` really is two qualifiers. + expect(ranks).toEqual([1, 2, 3, 4]) + } + + expect(standings.filter((row) => row.rank <= 2)).toHaveLength(6) + }) + it('keeps head-to-head inside the group', () => { const matches = generate(8, { groupCount: 2 }) const standings = calculateStandings({ matches, results: [] }) diff --git a/website/src/components/CodeExample.tsx b/website/src/components/CodeExample.tsx index fa7e5c1..73af4a3 100644 --- a/website/src/components/CodeExample.tsx +++ b/website/src/components/CodeExample.tsx @@ -49,6 +49,22 @@ const fixtures = generateRoundRobin({ // Everyone plays everyone: seeded fixtures, balanced // sides, and a rest round when the field is odd.` +const anyFormatCode = `import { generateTournament } from 'double-elimination' + +// When the format is data — a column, a form value — rather than +// something known when the code is written. +const matches = generateTournament({ + format: event.format, // 'single-elimination' + // 'double-elimination' + // 'round-robin' + eventId: event.id, + participants: event.entrants, + idFactory: () => crypto.randomUUID(), +}) + +// Every format returns the same match shape, so one renderer +// and one database table serve all three.` + const standingsCode = `import { calculateStandings } from 'double-elimination' const table = calculateStandings({ @@ -147,6 +163,9 @@ const CodeExample = () => { Standings + + Any Format +
@@ -161,6 +180,9 @@ const CodeExample = () => { + + + diff --git a/website/src/components/Features.tsx b/website/src/components/Features.tsx index 42a3f2c..622ace4 100644 --- a/website/src/components/Features.tsx +++ b/website/src/components/Features.tsx @@ -90,11 +90,12 @@ const Features = () => { className="text-2xl sm:text-4xl md:text-5xl font-bold" style={{ marginBottom: '1rem' }} > - Everything You Need for{' '} - Tournament Brackets + Everything You Need to{' '} + Run a Tournament

- A complete solution for generating tournament structures programmatically + Brackets, leagues and standings — generated from a list of + participants, in one dependency-free package

diff --git a/website/src/components/Footer.tsx b/website/src/components/Footer.tsx index 186fa5e..e1851c3 100644 --- a/website/src/components/Footer.tsx +++ b/website/src/components/Footer.tsx @@ -17,7 +17,7 @@ const Footer = () => { double-elimination

- Tournament bracket generation made simple + Brackets, leagues and standings for any tournament format

diff --git a/website/src/components/RoundRobinVisualization.tsx b/website/src/components/RoundRobinVisualization.tsx index 79c83d5..647291d 100644 --- a/website/src/components/RoundRobinVisualization.tsx +++ b/website/src/components/RoundRobinVisualization.tsx @@ -13,10 +13,18 @@ interface Props { showSampleResults: boolean } -/** Deterministic pseudo-random scoreline, so the demo is stable across renders. */ +/** + * Deterministic pseudo-random scoreline, so the demo is stable across renders. + * + * Mixes the bits rather than taking a single step of a linear generator, which + * on consecutive inputs would walk a short cycle and never produce a draw. + */ const sampleScore = (seed: number) => { - const next = (seed * 1103515245 + 12345) & 0x7fffffff - return next % 4 + let mixed = Math.imul(seed + 1, 2654435761) + mixed ^= mixed >>> 15 + mixed = Math.imul(mixed, 2246822519) + mixed ^= mixed >>> 13 + return Math.abs(mixed) % 4 } const RoundRobinVisualization = ({ @@ -33,8 +41,8 @@ const RoundRobinVisualization = ({ if (!showSampleResults) return [] return matches.map((match, index) => ({ matchId: match.id, - score1: sampleScore(index + 1), - score2: sampleScore(index + 7), + score1: sampleScore(index * 2), + score2: sampleScore(index * 2 + 1), })) }, [matches, showSampleResults])