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..4b146b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,100 @@ 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). +## [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 + +- **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. +- **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, + 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. + +### Changed + +- **`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 ### Changed @@ -87,4 +181,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..9df3534 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,38 @@ are in opposite halves for large brackets. ``` double-elimination/ ├── src/ # Source TypeScript files -├── dist/ # Compiled JavaScript (generated) -├── tests/ # Test 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 +├── 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. 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 - **Bug fixes**: Fix reported issues diff --git a/EXAMPLES.md b/EXAMPLES.md index 98f224b..8cda17c 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1,511 +1,569 @@ # 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) -- [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(), -}) +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, + })) -console.log(`Generated ${matches.length} matches`) -// Output: Generated 12 matches +const idFactory = () => crypto.randomUUID() +``` -// Filter winners bracket matches -const winnersMatches = matches.filter((m) => m.bracketType === 'winners') -console.log(`Winners bracket: ${winnersMatches.length} matches`) -// Output: Winners bracket: 7 matches +## Single Elimination -// Filter losers bracket matches -const losersMatches = matches.filter((m) => m.bracketType === 'losers') -console.log(`Losers bracket: ${losersMatches.length} matches`) -// Output: Losers bracket: 5 matches -``` +### A straight knockout -## Small Tournament (4-8 Participants) +```typescript +const cup = generateSingleElimination({ + eventId: 'friday-night-cup', + participants: createParticipants(16), + idFactory, +}) -### 4-Player Tournament +console.log(cup.length) // 15 — every match eliminates exactly one player +console.log(new Set(cup.map((m) => m.round)).size) // 4 rounds +``` -Perfect for small local tournaments or testing: +### With a third place match ```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(8), + idFactory, + thirdPlaceMatch: true, }) -// Get first round matches -const round1 = smallTournament.filter( - (m) => m.round === 1 && m.bracketType === 'winners' +// The third place match is the only non-winners match in the array +const thirdPlace = cup.find((m) => m.bracketType === 'losers')! + +// Both semifinals feed it +const semifinals = cup.filter( + (m) => m.bracketType === 'winners' && m.round === 2 ) -console.log('Round 1 matchups:') -round1.forEach((match) => { - console.log(` ${match.registration1Id} vs ${match.registration2Id}`) -}) -// Output: -// alice vs diana -// charlie vs bob +console.log(semifinals.every((m) => m.loserTo === thirdPlace.id)) // true ``` -## Medium Tournament (16-32 Participants) +## Double Elimination -### 16-Player Esports Tournament +### Standard, with a grand final and bracket reset ```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(), +const major = generateDoubleElimination({ + eventId: 'spring-major', + participants: createParticipants(16), + idFactory, + grandFinal: 'reset', }) -// 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 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 +} ``` -### 32-Player Tournament +### Deciding whether the reset is played ```typescript -// Create 32 participants -const createParticipants = (count: number) => { - return Array.from({ length: count }, (_, i) => ({ - registrationId: `player-${i + 1}`, - seed: i + 1, - })) -} +const [grandFinal, reset] = major + .filter((m) => m.bracketType === 'grandFinal') + .sort((a, b) => a.round - b.round) -const largeBracket = generateDoubleElimination({ - eventId: 'mega-tournament', - participants: createParticipants(32), - idFactory: () => crypto.randomUUID(), -}) +const reportGrandFinal = (winnerId: string) => { + // Slot 2 is the losers bracket representative. + const fromLosersBracket = winnerId === grandFinal.registration2Id -console.log(`Total matches: ${largeBracket.length}`) -// Output: Total matches: 62 -``` + if (!fromLosersBracket) { + // The winners bracket representative is unbeaten: skip the reset. + return { champion: winnerId, resetMatch: null } + } -## Large Tournament (64+ Participants) + // Both finalists now have one loss each, so they play again. + return { champion: null, resetMatch: reset } +} +``` -### 64-Player Championship +### Without a grand final (the 1.x default) ```typescript -const championship = generateDoubleElimination({ - eventId: 'world-championship-2024', - participants: createParticipants(64), - idFactory: () => crypto.randomUUID(), +// Winners final decides 1st/2nd, losers final decides 3rd/4th +const bracket = generateDoubleElimination({ + eventId: 'club-night', + participants: createParticipants(8), + idFactory, }) -// 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)), -} - -console.log('Tournament Statistics:', stats) -// Output: -// { -// totalMatches: 126, -// winnersMatches: 63, -// losersMatches: 63, -// totalRounds: 6 -// } +console.log(bracket.length) // 12 ``` -## Single Elimination Mode - -### Pure Single Elimination +### Delayed losers bracket -No losers bracket - one loss and you're out: +Early rounds are single elimination; only the later losers get a second life. ```typescript -const singleElimination = generateDoubleElimination({ - eventId: 'single-elim-tournament', +const compressed = generateDoubleElimination({ + eventId: 'one-day-event', participants: createParticipants(16), - idFactory: () => crypto.randomUUID(), - losersStartRoundsBeforeFinal: 0, // No losers bracket + idFactory, + losersStartRoundsBeforeFinal: 2, // QF and SF losers drop }) -// 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 +const ro16 = compressed.filter( + (m) => m.bracketType === 'winners' && m.round === 1 +) +console.log(ro16.every((m) => m.loserTo === null)) // true — lose and you are out ``` -### Single Elimination with 3rd Place Match +## Round Robin -Only semifinal losers compete for 3rd place: +### A league season, home and away ```typescript -const withThirdPlace = generateDoubleElimination({ - eventId: 'tournament-with-third', - participants: createParticipants(8), - idFactory: () => crypto.randomUUID(), - losersStartRoundsBeforeFinal: 1, // Only semifinal losers +const season = generateRoundRobin({ + eventId: 'league-2026', + participants: createParticipants(10), + idFactory, + legs: 2, }) -// 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(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}`) ``` -## Delayed Losers Bracket +### Group stage + +```typescript +const groups = generateRoundRobin({ + eventId: 'world-cup', + participants: createParticipants(32), + idFactory, + groupCount: 8, // 8 groups of 4, snake seeded +}) + +const groupA = groups.filter((m) => m.group === 0) +console.log(groupA.length) // 6 matches -### 16-Player Tournament with Delayed LB +// Who is in each group +const membersOf = (group: number) => [ + ...new Set( + groups + .filter((m) => m.group === group) + .flatMap((m) => [m.registration1Id!, m.registration2Id!]) + ), +] +``` -Losers bracket starts at quarterfinals: +### An odd field ```typescript -const delayedLB = generateDoubleElimination({ - eventId: 'delayed-lb-tournament', - participants: createParticipants(16), - idFactory: () => crypto.randomUUID(), - losersStartRoundsBeforeFinal: 2, // QF and SF losers go to LB +const nine = generateRoundRobin({ + eventId: 'club-ladder', + participants: createParticipants(9), + idFactory, }) -// Round 1 losers are eliminated (no LB matches for them) -const round1Losers = delayedLB.filter( - (m) => m.round === 1 && m.bracketType === 'winners' -) -console.log(`Round 1 matches: ${round1Losers.length}`) -// Round 1 losers don't have corresponding LB matches -``` +console.log(nine.length) // 36 matches over 9 rounds -## Handling Odd Participant Counts +// 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)) +} +``` -### Tournament with 7 Participants +## Standings -Automatic bye handling: +### A league table ```typescript -const oddCount = generateDoubleElimination({ - eventId: 'odd-participants', - participants: createParticipants(7), // 7 players, bracket size = 8 - idFactory: () => crypto.randomUUID(), -}) +const results = [ + { matchId: season[0].id, score1: 2, score2: 1 }, + { matchId: season[1].id, score1: 0, score2: 0 }, +] -// Find matches with byes (null registration) -const matchesWithByes = oddCount.filter( - (m) => m.registration1Id === null || m.registration2Id === null -) +const table = calculateStandings({ matches: season, results }) -console.log(`Matches with byes: ${matchesWithByes.length}`) -// Output: Matches with byes: 1 (seed 1 gets a bye in round 1) +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` + ) +} ``` -### Tournament with 13 Participants +Matches without a result simply count as unplayed, so the table is correct at +any point in the season. + +### Results without scores ```typescript -const thirteenPlayers = generateDoubleElimination({ - eventId: 'thirteen-players', - participants: createParticipants(13), // 13 players, bracket size = 16 - idFactory: () => crypto.randomUUID(), +const table = calculateStandings({ + matches: season, + results: [ + { matchId: season[0].id, winnerId: 'player-3' }, + { matchId: season[1].id, winnerId: null }, // a draw + ], }) +``` + +### Custom points and tiebreakers -// Count byes -const byeCount = thirteenPlayers.filter( - (m) => m.registration1Id === null || m.registration2Id === null -).length +```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'], +}) -console.log(`Total byes: ${byeCount}`) -// Output: Total byes: 3 (seeds 1, 2, 3 get byes) +// A format where a loss costs you +const table2 = calculateStandings({ + matches: season, + results, + points: { win: 2, draw: 0, loss: -1 }, +}) ``` -## Integration with Database +### Qualifiers from a group stage -### Saving Matches to Database +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 -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 table = calculateStandings({ + matches: groups, + results, + participants, // required by the 'seed' tiebreaker + tiebreakers: ['headToHead', 'scoreDifference', 'scoreFor', 'wins', 'seed'], +}) - return matches -} +// Exactly the top two from every group +const qualifiers = table.filter((row) => row.rank <= 2) + +// 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) ``` -### Loading Participants from Database +Without `seed`, inspect the ties instead of cutting through them: ```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, +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 +teams finished. + +```typescript +const groupStage = generateRoundRobin({ + eventId: 'champions-league-2026', + participants: createParticipants(32), + idFactory, + groupCount: 8, +}) + +// ...play the group stage, collecting 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 + .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. -### Group Matches by Round and Type +## Picking a Format at Runtime + +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 -const finalsMatch = matches.find( - (m) => - m.bracketType === 'winners' && - m.round === Math.max(...matches.map((m) => m.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/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..6ddbc67 100644 --- a/README.md +++ b/README.md @@ -7,338 +7,689 @@ [![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) + - [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 + +- ✅ **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 + +## Installation + +```bash +npm install double-elimination +``` + +Works on Node 18+ and in any bundler; `import` and `require` both resolve. + +## Quick Start -Double elimination tournaments are the gold standard for competitive events because they: +Every generator takes the same three things: an `eventId` copied onto each +match, your `participants`, and an `idFactory` that returns unique match ids. -- **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 +```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(), +} -This library implements the standard double elimination format used in major esports tournaments, fighting game competitions, and sports events worldwide. +// One loss and you are out +const cup = generateSingleElimination({ ...options, thirdPlaceMatch: true }) -## When to Use This Package +// Two losses to go out, with a grand final and bracket reset +const major = generateDoubleElimination({ ...options, grandFinal: 'reset' }) -Use `double-elimination` when you need to: +// Everyone plays everyone, home and away +const league = generateRoundRobin({ ...options, legs: 2 }) -- **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 +// ...and once results come in +const table = calculateStandings({ + matches: league, + results: [{ matchId: league[0].id, score1: 2, score2: 1 }], +}) +``` -Perfect for developers building: +## Choosing a Format -- Esports tournament platforms -- Sports competition management systems -- Gaming tournament organizers -- Bracket visualization tools -- Tournament scheduling applications +| | **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 | -## Features +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. -- ✅ **Complete bracket generation** - Winners and losers bracket structures -- ✅ **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 -- ✅ **Zero dependencies** - Lightweight and fast -- ✅ **Configurable ID generation** - Use any ID factory function +## The Match Shape -## Installation +Every generator returns `TournamentMatch[]`: -```bash -npm install double-elimination +```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 +} ``` -## Quick Start +| 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 | + +### 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. | +### `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' -} +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 ``` -## Bracket Structure +A result naming an unknown match, a duplicate result, half a scoreline, or a +winner who did not play in that match all throw. -### Placements +## Double Elimination -This library uses a simplified double elimination format without grand finals: +### Placements -| Match | Winner | Loser | -| ---------------------- | --------- | --------- | -| Winners Bracket Finals | 1st Place | 2nd Place | -| Losers Bracket Finals | 3rd Place | 4th Place | +By default there is no grand final: the winners final decides 1st/2nd and the +losers final decides 3rd/4th. -### Match Counts +| Match | Winner | Loser | +| --------------------- | --------- | --------- | +| Winners bracket final | 1st place | 2nd place | +| Losers bracket final | 3rd place | 4th place | -For `N` participants: +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: -- Bracket size = next power of 2 ≥ N -- Winners rounds = log₂(bracket_size) -- Losers rounds = (winners_rounds - 1) × 2 - 1 +| Match | Winner | Loser | +| --------------------- | ------------- | -------------- | +| Winners bracket final | → grand final | → losers final | +| Losers bracket final | → grand final | 3rd place | +| Grand final | 1st place | 2nd place | -| 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 | +### Match counts -## Match Routing +For a bracket size `B` (the next power of two at or above the participant +count): -### Winners Bracket +| | 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` | -- **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) +| 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 | -### Losers Bracket +Matches are returned winners bracket first, then losers bracket, then grand +final, each ordered by round and then by `bracketPosition`. -- Receives losers from winners bracket rounds 1 and 2 -- Winners from losers bracket finals get 3rd place, loser gets 4th +### Match routing -### Cross-Bracket Matchups +**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`. -To prevent early rematches, losers from the winners bracket are routed using a specific pattern: +**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 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 +**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: -This ensures players from opposite sides of the winners bracket meet in the losers bracket, preventing rematches until later rounds. +| 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 | -## Bye Handling +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. -When participant count isn't a power of 2, byes are automatically created and processed: +### Grand final and bracket reset ```typescript -// 7 participants in 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 auto-advances ``` -Byes are pre-resolved at generation time - the advancing player is already placed in the next round. +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'`. -## Delayed Losers Bracket and Single Elimination +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: -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. +```typescript +const [grandFinal, reset] = matches + .filter((m) => m.bracketType === 'grandFinal') + .sort((a, b) => a.round - b.round) -### Single Elimination Modes +// slot 2 of the grand final is the losers bracket representative +const resetRequired = grandFinalWinnerId === grandFinal.registration2Id +``` -You can create pure single elimination or single elimination with a 3rd place match: +A grand final needs a losers bracket, so it cannot be combined with +`losersStartRoundsBeforeFinal: 0`, and needs at least 3 participants. -```typescript -// Pure single elimination (no losers bracket) -const matches = generateDoubleElimination({ - eventId: 'event-1', - participants: createParticipants(8), - idFactory: () => crypto.randomUUID(), - losersStartRoundsBeforeFinal: 0, // No losers bracket -}) +### Delayed losers bracket -// Single elimination with 3rd place match (only semifinal losers) +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 +// 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: 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 4 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. -## Seeding +### Group stages + +`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 +const groupStage = generateRoundRobin({ + eventId: 'world-cup', + participants, // 32 teams + idFactory: () => crypto.randomUUID(), + groupCount: 8, // 8 groups of 4 +}) + +const groupA = groupStage.filter((m) => m.group === 0) +``` -The library uses standard tournament seeding to ensure fair bracket placement: +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. -- **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 +### Standings and tiebreakers -For 8 participants, Round 1 matchups are: `1v8, 4v5, 2v7, 3v6` +```typescript +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'], +}) -For 32 participants: +const qualifiers = table.filter((row) => row.rank <= 2) +``` -- 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 +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. -## Use Cases +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: -### Esports Tournament Platform +| 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 | -Generate brackets for competitive gaming tournaments with proper seeding and fair matchups. +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: ```typescript -const esportsBracket = generateDoubleElimination({ - eventId: 'valorant-championship-2024', - participants: teams.map((team, index) => ({ - registrationId: team.id, - seed: team.rank, - })), - idFactory: () => crypto.randomUUID(), +calculateStandings({ + matches, + results, + tiebreakers: ['scoreDifference', 'scoreFor', 'headToHead'], }) ``` -### Sports Competition Management +## Seeding -Create tournament brackets for sports leagues, ensuring top seeds don't meet until later rounds. +Brackets use standard tournament seeding, so the strongest seeds are kept apart +for as long as possible: -### Gaming Tournament Organizer +- **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 -Run local or online gaming tournaments with automatic bracket generation and bye handling. +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. -### Bracket Visualization +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. -Generate bracket data for visualization libraries like D3.js, React components, or custom renderers. +## Bye Handling -## Example Output +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: -For 8 participants: +```typescript +// 7 participants in a bracket of 8 = 1 bye +const matches = generateDoubleElimination({ + eventId: 'event-1', + participants, // seeds 1-7 + idFactory: () => crypto.randomUUID(), +}) +// Seed 1 vs seed 8 (missing) = seed 1 is already placed in round 2 ``` -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 + +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, 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: + +```typescript +const fed = new Set( + matches.flatMap((m) => [ + m.winnerTo ? `${m.winnerTo}#${m.winnerToSlot}` : null, + m.loserTo ? `${m.loserTo}#${m.loserToSlot}` : null, + ]) +) + +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' +} ``` -## Performance +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. -The library is optimized for performance: +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. -- **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 +## Performance -Benchmark results (typical): +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: -- 8 participants: < 1ms -- 32 participants: < 2ms -- 128 participants: < 5ms -- 512 participants: < 15ms +```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 f2c953d..a4ebb9b 100644 --- a/demo.ts +++ b/demo.ts @@ -1,75 +1,162 @@ -import { generateDoubleElimination, Participant } from './src' +import { + calculateStandings, + generateTournament, + type TournamentFormat, + type TournamentMatch, +} 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 -- [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, -}) +const base = { eventId: 'event-1', participants, idFactory } -console.log(matches) +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', + } +) -// 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 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 printSection = (title: string, section: TournamentMatch[]) => { + if (section.length === 0) return + + console.log(`\n=== ${title} ===`) + const lastRound = Math.max(...section.map((m) => m.round)) + + for (let round = 1; round <= lastRound; round++) { + const inRound = section + .filter((m) => m.round === round) + .sort((a, b) => a.bracketPosition - b.bracketPosition) + 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('=== 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}]` + ` [${match.bracketPosition}] ${match.id}: ${label( + match.registration1Id + )} vs ${label(match.registration2Id)}${note}${routing}` ) - }) + } + } } -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' +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( - ` [pos ${m.bracketPosition}] ${m.id}: ${p1} vs ${p2} → W:${ - m.winnerTo ?? 'GF' - }[${m.winnerToSlot}]` + ['#', '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(`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(`Format: ${format}`) +console.log(`Participants: ${participants.length}`) +console.log(`Total matches: ${matches.length}`) diff --git a/package-lock.json b/package-lock.json index fe9e105..35a4b01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "double-elimination", - "version": "1.2.2", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "double-elimination", - "version": "1.2.2", + "version": "2.0.0", "license": "MIT", "devDependencies": { "@types/node": "^20.0.0", diff --git a/package.json b/package.json index 0b6c64f..9a01445 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,23 @@ { "name": "double-elimination", - "version": "1.2.2", - "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", + "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", "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" @@ -20,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", @@ -54,5 +70,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/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 new file mode 100644 index 0000000..728717b --- /dev/null +++ b/src/createGrandFinal.ts @@ -0,0 +1,40 @@ +import { GrandFinalFormat, IdFactory, TournamentMatch } from './types.js' +import { createMatch } from './participants.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 +): TournamentMatch[] => { + if (format === 'none') return [] + + const rounds = format === 'reset' ? 2 : 1 + const matches: TournamentMatch[] = [] + + for (let round = 1; round <= rounds; round++) { + matches.push( + createMatch( + { eventId, round, bracketPosition: 0, bracketType: 'grandFinal' }, + idFactory + ) + ) + } + + 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..c8b8d33 100644 --- a/src/createLosersBracket.ts +++ b/src/createLosersBracket.ts @@ -1,99 +1,68 @@ -import { BracketMatch, IdFactory } from './types' +import { IdFactory, TournamentMatch } from './types.js' +import { createMatch } from './participants.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 -): BracketMatch[] => { - const matches: BracketMatch[] = [] + idFactory: IdFactory +): TournamentMatch[] => { + const matches: TournamentMatch[] = [] 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() - 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) } } - // 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[], + matches: TournamentMatch[], idMap: Map, totalRounds: number ): void => { 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..a629d57 100644 --- a/src/createWinnersBracket.ts +++ b/src/createWinnersBracket.ts @@ -1,53 +1,41 @@ -import { BracketMatch, IdFactory } from './types'; +import { IdFactory, TournamentMatch } from './types.js' +import { createMatch } from './participants.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 +): TournamentMatch[] => { + const matches: TournamentMatch[] = [] + 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); - - 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) } } - // 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..4029100 100644 --- a/src/generateDoubleElimination.ts +++ b/src/generateDoubleElimination.ts @@ -1,77 +1,39 @@ -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 { + 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' +import { wireLoserRouting } from './wireLoserRouting.js' +import { resolveByes } from './resolveByes.js' +/** + * 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, - 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 +41,85 @@ 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 } -const placeParticipants = ( - matches: BracketMatch[], - participants: Participant[], - bracketSize: number +/** Sends both bracket winners into the grand final. */ +const wireGrandFinal = ( + winnersMatches: TournamentMatch[], + losersMatches: TournamentMatch[], + grandFinalMatch: TournamentMatch ): void => { - const round1 = matches.filter((m) => m.round === 1) - const seedMap = new Map(participants.map((p) => [p.seed, p.registrationId])) + const lastOf = (matches: TournamentMatch[]): TournamentMatch | undefined => + matches.reduce( + (latest, match) => + !latest || match.round > latest.round ? match : latest, + undefined + ) - // Standard seeding: 1vN, 4v(N-3), 2v(N-1), 3v(N-2) pattern - const pairs = generateSeedPairs(bracketSize) + const winnersFinal = lastOf(winnersMatches) + if (winnersFinal) { + winnersFinal.winnerTo = grandFinalMatch.id + winnersFinal.winnerToSlot = 1 + } - pairs.forEach(([seed1, seed2], idx) => { - const match = round1[idx] - if (match) { - match.registration1Id = seedMap.get(seed1) ?? null - match.registration2Id = seedMap.get(seed2) ?? null - } - }) + const losersFinal = lastOf(losersMatches) + if (losersFinal) { + losersFinal.winnerTo = grandFinalMatch.id + losersFinal.winnerToSlot = 2 + } } -/** - * 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) - } - - positions = newPositions - } +const placeParticipants = ( + matches: TournamentMatch[], + participants: Participant[], + bracketSize: number +): void => { + const round1 = matches.filter((match) => match.round === 1) + const seedMap = new Map( + participants.map((participant) => [ + participant.seed, + participant.registrationId, + ]) + ) - // 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]]) - } - return pairs + 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 + }) } 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 3b54ceb..16647cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,31 @@ -export { generateDoubleElimination } from './generateDoubleElimination' +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, - BracketMatch, + 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, GeneratorOptions, -} from './types' +} 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 new file mode 100644 index 0000000..35eb2d6 --- /dev/null +++ b/src/planBracket.ts @@ -0,0 +1,85 @@ +import { + DoubleEliminationOptions, + GrandFinalFormat, + Participant, +} from './types.js' +import { log2, nextPowerOf2 } from './bracketUtils.js' +import { rankParticipants } from './participants.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: DoubleEliminationOptions): BracketPlan => { + const { eventId, losersStartRoundsBeforeFinal, grandFinal = 'none' } = options + + if ( + grandFinal !== 'none' && + grandFinal !== 'single' && + grandFinal !== 'reset' + ) { + throw new Error(`grandFinal must be 'none', 'single' or 'reset'`) + } + + const ranked = rankParticipants(options) + + 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, + } +} 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..7a67cca --- /dev/null +++ b/src/resolveByes.ts @@ -0,0 +1,205 @@ +import { TournamentMatch } 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: TournamentMatch[]): 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: TournamentMatch, + 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: TournamentMatch[], + byId: Map +): TournamentMatch[] => { + const incoming = new Map() + for (const match of matches) incoming.set(match.id, 0) + + 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) + 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: TournamentMatch[] = [] + + 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/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 4772c3c..ecbc9cc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,29 +1,83 @@ +/** A competitor entered into the tournament. */ 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 } -export interface BracketMatch { +/** + * Which part of the tournament a match belongs to. + * + * `grandFinal` appears only when the `grandFinal` option is enabled, and + * `roundRobin` only in round robin tournaments. + */ +export type MatchType = 'winners' | 'losers' | 'grandFinal' | 'roundRobin' + +/** @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` (and within `group`). */ 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 nobody advances from here. */ 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: 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 -export interface GeneratorOptions { +/** 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. + * + * - `'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 DoubleEliminationOptions extends BaseOptions { /** * Number of rounds before the finals where the losers bracket begins. * Players who lose before this point are permanently eliminated. @@ -39,4 +93,119 @@ 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 +} + +/** @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 2150646..eb7c050 100644 --- a/src/wireLoserRouting.ts +++ b/src/wireLoserRouting.ts @@ -1,42 +1,70 @@ -import { BracketMatch } from './types' +import { TournamentMatch } 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[], + winnersMatches: TournamentMatch[], + losersMatches: TournamentMatch[], 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..6a857e2 --- /dev/null +++ b/tests/byes.test.ts @@ -0,0 +1,120 @@ +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('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) => { + 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/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/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/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..23b6d43 --- /dev/null +++ b/tests/standings.test.ts @@ -0,0 +1,398 @@ +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('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: [] }) + + 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/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"] +} 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..73af4a3 100644 --- a/website/src/components/CodeExample.tsx +++ b/website/src/components/CodeExample.tsx @@ -5,40 +5,80 @@ 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 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({ + 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 +132,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 +150,21 @@ const CodeExample = () => { style={{ padding: '1rem', gap: '0.75rem' }} >

Usage Examples

- + - Double + Double Elim - Single + Single Elim - - 3rd Place + + Round Robin + + + Standings + + + Any Format @@ -128,8 +174,14 @@ const CodeExample = () => { - - + + + + + + + + diff --git a/website/src/components/Features.tsx b/website/src/components/Features.tsx index b913259..622ace4 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.', }, ] @@ -72,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/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. { + 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 = ({ + 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 * 2), + score2: sampleScore(index * 2 + 1), + })) + }, [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