Initial Commit
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
export type { TournamentManagerDeps } from "./tournament-manager.js";
|
||||
export { TournamentManager } from "./tournament-manager.js";
|
||||
export type {
|
||||
BracketType,
|
||||
Match,
|
||||
MatchResult,
|
||||
MatchStatus,
|
||||
Participant,
|
||||
Round,
|
||||
Standing,
|
||||
Tournament,
|
||||
TournamentConfig,
|
||||
TournamentStats,
|
||||
TournamentStatus,
|
||||
} from "./tournament-manager.types.js";
|
||||
export {
|
||||
BracketTypeSchema,
|
||||
MatchStatusSchema,
|
||||
TournamentConfigSchema,
|
||||
TournamentStatusSchema,
|
||||
} from "./tournament-manager.types.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# tournament-bracket.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure bracket-generation helpers used by `TournamentManager`. Extracted to
|
||||
a sibling internal module to keep `tournament-manager.ts` under the
|
||||
project's 300-line hard limit while preserving full coverage of the
|
||||
generation paths through the manager's public surface.
|
||||
|
||||
The helpers are deliberately framework-free — they accept an injectable
|
||||
`createMatch` factory so the manager retains exclusive ownership of match
|
||||
ids, OTel counters, and other side-effecting machinery, leaving these
|
||||
helpers as deterministic pure functions over the participant + match
|
||||
type domain.
|
||||
|
||||
## Features
|
||||
|
||||
- `roundName(round, total)` — human-readable round labels.
|
||||
- `shuffleWithRng(arr, rng)` — in-place Fisher-Yates via injectable `Rng`.
|
||||
- `padToPowerOfTwo(participants)` — null-padding for single-elim.
|
||||
- `buildSingleElim(...)` — bracket with first-round byes.
|
||||
- `buildRoundRobin(...)` — circle-method scheduling.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildSingleElim } from "./tournament-bracket.internal.js";
|
||||
const rounds = buildSingleElim(participants, (round, position) =>
|
||||
createMatch(round, position)
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildRoundRobin,
|
||||
shuffleWithRng,
|
||||
} from "./tournament-bracket.internal.js";
|
||||
|
||||
shuffleWithRng(participants, rng);
|
||||
const rounds = buildRoundRobin(participants, (round, position) => ({
|
||||
id: nextId(),
|
||||
round,
|
||||
position,
|
||||
participant1: null,
|
||||
participant2: null,
|
||||
status: "pending",
|
||||
result: null,
|
||||
nextMatchId: null,
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `roundName(round: number, total: number): string`
|
||||
- `shuffleWithRng<T>(arr: T[], rng: Rng | undefined): void`
|
||||
- `padToPowerOfTwo<T>(participants: Participant<T>[]): (Participant<T> | null)[]`
|
||||
- `buildSingleElim<TP, TR>(participants, createMatch): Round<TP, TR>[]`
|
||||
- `buildRoundRobin<TP, TR>(participants, createMatch): Round<TP, TR>[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`buildSingleElim` throws `INVALID_BRACKET_SIZE` if the padded size is not
|
||||
a power of two — this should be unreachable in practice since
|
||||
`padToPowerOfTwo` always pads to a power of two, but the guard catches
|
||||
caller misuse (e.g. handing in a pre-padded list) without silent
|
||||
corruption of the bracket shape.
|
||||
|
||||
`buildRoundRobin` uses the standard circle method: one participant is
|
||||
fixed and the others rotate around it. For an odd participant count a
|
||||
`null` slot is injected so each round contains exactly `floor(n/2)`
|
||||
matches; pairs with `null` are filtered out before emitting matches so
|
||||
they don't appear in the bracket. This yields `n - 1` rounds for `n`
|
||||
even and `n` rounds for `n` odd, matching the conventional schedule.
|
||||
|
||||
Shuffle is unbiased Fisher-Yates and uses `Rng.nextIntInRange` —
|
||||
implementations of `Rng` that do not provide it (it is optional in the
|
||||
core contract) leave the order untouched, which is the conservative
|
||||
default for any tournament harness that prefers explicit error over silent
|
||||
non-determinism.
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import type { Match, Participant, Round } from "./tournament-manager.types.js";
|
||||
|
||||
/** Round-name helper for single-elim bracket. */
|
||||
export function roundName(round: number, total: number): string {
|
||||
const remaining = total - round;
|
||||
if (remaining === 0) return "Finals";
|
||||
if (remaining === 1) return "Semi-Finals";
|
||||
if (remaining === 2) return "Quarter-Finals";
|
||||
return `Round ${round}`;
|
||||
}
|
||||
|
||||
/** Fisher-Yates shuffle backed by an injectable `Rng`. */
|
||||
export function shuffleWithRng<T>(arr: T[], rng: Rng | undefined): void {
|
||||
if (!rng?.nextIntInRange) return;
|
||||
for (let i = arr.length - 1; i > 0; i -= 1) {
|
||||
const j = rng.nextIntInRange(0, i + 1);
|
||||
[arr[i], arr[j]] = [arr[j]!, arr[i]!];
|
||||
}
|
||||
}
|
||||
|
||||
/** Pad participants to the next power of two. */
|
||||
export function padToPowerOfTwo<T>(
|
||||
participants: Participant<T>[]
|
||||
): (Participant<T> | null)[] {
|
||||
const size = 2 ** Math.ceil(Math.log2(participants.length));
|
||||
const out: (Participant<T> | null)[] = [...participants];
|
||||
while (out.length < size) out.push(null);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a single-elimination bracket and propagate first-round byes. */
|
||||
export function buildSingleElim<TP, TR>(
|
||||
participants: Participant<TP>[],
|
||||
createMatch: (round: number, position: number) => Match<TP, TR>
|
||||
): Round<TP, TR>[] {
|
||||
const padded = padToPowerOfTwo(participants);
|
||||
const n = padded.length;
|
||||
const totalRounds = Math.log2(n);
|
||||
const rounds: Round<TP, TR>[] = [];
|
||||
let count = n / 2;
|
||||
for (let r = 1; r <= totalRounds; r += 1) {
|
||||
const round: Round<TP, TR> = {
|
||||
number: r,
|
||||
name: roundName(r, totalRounds),
|
||||
matches: [],
|
||||
completed: false,
|
||||
};
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const m = createMatch(r, i);
|
||||
if (r === 1) {
|
||||
// padded length is always n=count*2 so both indices exist
|
||||
m.participant1 = padded[i * 2] as Participant<TP> | null;
|
||||
m.participant2 = padded[i * 2 + 1] as Participant<TP> | null;
|
||||
if (m.participant1 && !m.participant2) {
|
||||
m.status = "bye";
|
||||
m.result = {
|
||||
winnerId: m.participant1.id,
|
||||
loserId: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
round.matches.push(m);
|
||||
}
|
||||
rounds.push(round);
|
||||
count = Math.ceil(count / 2);
|
||||
}
|
||||
linkBracket(rounds);
|
||||
return rounds;
|
||||
}
|
||||
|
||||
/** Wire `nextMatchId` from round N to round N+1. */
|
||||
function linkBracket<TP, TR>(rounds: Round<TP, TR>[]): void {
|
||||
for (let r = 0; r < rounds.length - 1; r += 1) {
|
||||
const cur = rounds[r]!.matches;
|
||||
const next = rounds[r + 1]!.matches;
|
||||
for (let i = 0; i < cur.length; i += 1) {
|
||||
cur[i]!.nextMatchId = next[Math.floor(i / 2)]!.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a round-robin bracket (circle method). */
|
||||
export function buildRoundRobin<TP, TR>(
|
||||
participants: Participant<TP>[],
|
||||
createMatch: (round: number, position: number) => Match<TP, TR>
|
||||
): Round<TP, TR>[] {
|
||||
const list: (Participant<TP> | null)[] = [...participants];
|
||||
if (list.length % 2 === 1) list.push(null);
|
||||
const totalRounds = list.length - 1;
|
||||
const half = list.length / 2;
|
||||
const rounds: Round<TP, TR>[] = [];
|
||||
for (let r = 0; r < totalRounds; r += 1) {
|
||||
const round: Round<TP, TR> = {
|
||||
number: r + 1,
|
||||
name: `Round ${r + 1}`,
|
||||
matches: [],
|
||||
completed: false,
|
||||
};
|
||||
for (let i = 0; i < half; i += 1) {
|
||||
const p1 = list[i];
|
||||
const p2 = list[list.length - 1 - i];
|
||||
if (p1 && p2) {
|
||||
const m = createMatch(r + 1, i);
|
||||
m.participant1 = p1;
|
||||
m.participant2 = p2;
|
||||
round.matches.push(m);
|
||||
}
|
||||
}
|
||||
rounds.push(round);
|
||||
list.splice(1, 0, list.pop()!);
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
# TournamentManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TournamentManager` orchestrates single-elimination and round-robin
|
||||
tournaments. Participants register during the `pending` phase, then a
|
||||
single `start()` locks the field, applies seeding (or RNG-driven
|
||||
shuffling), and constructs the bracket. From there the manager tracks
|
||||
match results, propagates winners through `nextMatchId` links, updates
|
||||
standings, and emits stable error codes whenever the caller would
|
||||
otherwise corrupt the bracket.
|
||||
|
||||
The manager injects its `Clock`, `IdGenerator`, and `Rng` so deterministic
|
||||
test rigs and seeded replays produce byte-identical brackets each run.
|
||||
OTel counters record per-match creation and completion so production
|
||||
operators can monitor tournament throughput without bespoke
|
||||
instrumentation.
|
||||
|
||||
## Features
|
||||
|
||||
- Two bracket types: single-elimination, round-robin.
|
||||
- Automatic first-round bye propagation for non-power-of-two fields.
|
||||
- Deterministic seeded shuffling via injectable `Rng`.
|
||||
- Standings table with stable points/wins/losses tie-break.
|
||||
- Stable `GAME_SERVER.*` error codes (`INVALID_BRACKET_SIZE`,
|
||||
`TOURNAMENT_INVALID_STATE`, `TOURNAMENT_FULL`,
|
||||
`TOURNAMENT_DUPLICATE_PARTICIPANT`,
|
||||
`TOURNAMENT_INSUFFICIENT_PARTICIPANTS`, `TOURNAMENT_MATCH_NOT_FOUND`,
|
||||
`TOURNAMENT_INVALID_RESULT`).
|
||||
- Zod-validated config.
|
||||
- OTel match counter tagged by `op` (`created`, `completed`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TournamentManager } from "@suaveplan/game-server/enhanced/tournament/tournament-manager";
|
||||
|
||||
const tm = new TournamentManager({
|
||||
id: "weekly-cup",
|
||||
name: "Weekly Cup",
|
||||
bracketType: "single-elimination",
|
||||
maxParticipants: 16,
|
||||
});
|
||||
tm.addParticipant("alice");
|
||||
tm.addParticipant("bob");
|
||||
tm.start();
|
||||
const firstMatch = tm.getTournament().rounds[0]!.matches[0]!;
|
||||
tm.recordResult(firstMatch.id, "alice");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TournamentManager } from "@suaveplan/game-server/enhanced/tournament/tournament-manager";
|
||||
|
||||
const tm = new TournamentManager<PlayerProfile, MatchTelemetry>(
|
||||
{
|
||||
id: "monthly",
|
||||
name: "Monthly Championship",
|
||||
bracketType: "round-robin",
|
||||
maxParticipants: 8,
|
||||
shuffleSeeds: true,
|
||||
winPoints: 3,
|
||||
drawPoints: 1,
|
||||
lossPoints: 0,
|
||||
},
|
||||
{
|
||||
clock: () => now,
|
||||
rng: seededRng,
|
||||
idGenerator: () => `m-${++counter}`,
|
||||
}
|
||||
);
|
||||
|
||||
for (const player of players) {
|
||||
tm.addParticipant(player.id, player.profile);
|
||||
}
|
||||
tm.start();
|
||||
|
||||
for (const round of tm.getTournament().rounds) {
|
||||
for (const match of round.matches) {
|
||||
const winnerId = await playMatch(match);
|
||||
tm.recordResult(match.id, winnerId, { latencyMs: 27 });
|
||||
}
|
||||
}
|
||||
|
||||
const champion = tm.getWinner();
|
||||
const standings = tm.getStandings();
|
||||
const stats = tm.getStats();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new TournamentManager(config, deps?)`
|
||||
- `addParticipant(id, data?, seed?): Participant`
|
||||
- `start(): void`
|
||||
- `getMatch(id): Match`
|
||||
- `recordResult(matchId, winnerId, data?): Match`
|
||||
- `cancel(): void`
|
||||
- `getTournament(): Tournament`
|
||||
- `getWinner(): Participant | null`
|
||||
- `getStandings(): readonly Standing[]`
|
||||
- `getStats(): TournamentStats`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Bracket generation is split into `tournament-bracket.internal.ts` and
|
||||
standings/match utilities into `tournament-standings.internal.ts` to keep
|
||||
this file under the project's 300-line hard limit while preserving full
|
||||
behaviour and 100% coverage through the public surface. Internal modules
|
||||
are not exported through the package barrel — their tests run via the
|
||||
manager.
|
||||
|
||||
Single-elimination brackets pad participants to the next power of two
|
||||
with `null` slots that become first-round byes. Byes auto-advance their
|
||||
opponent into the next round during `start()`, so the bracket is
|
||||
immediately consistent before the first `recordResult()` call. Tournament
|
||||
completion is detected when exactly one participant remains active
|
||||
(single-elim) or every round has completed (round-robin).
|
||||
|
||||
Seeding has two paths: explicit `useSeeding=true` sorts by the
|
||||
caller-supplied `seed` value, while `shuffleSeeds=true` overrides that
|
||||
ordering by Fisher-Yates shuffling with the injected `Rng`. An `Rng`
|
||||
implementation that does not provide `nextIntInRange` is treated as a
|
||||
no-op shuffle — the conservative default for tournament harnesses that
|
||||
prefer explicit ordering errors over silent non-determinism.
|
||||
@@ -0,0 +1,405 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import type { GameServerError } from "../../../errors/index.js";
|
||||
import { TournamentManager } from "./tournament-manager.js";
|
||||
|
||||
function deterministicRng(seq: number[]): Rng {
|
||||
let i = 0;
|
||||
return {
|
||||
nextUint32: () => seq[i % seq.length]! >>> 0,
|
||||
nextFloat: () => (seq[i % seq.length]! % 1000) / 1000,
|
||||
nextIntInRange: (min: number, max: number) => {
|
||||
const v = seq[i++ % seq.length]!;
|
||||
return min + (v % (max - min));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTm(
|
||||
opts: {
|
||||
bracketType?: "single-elimination" | "round-robin";
|
||||
max?: number;
|
||||
shuffle?: boolean;
|
||||
useSeeding?: boolean;
|
||||
rng?: Rng;
|
||||
clock?: () => number;
|
||||
} = {}
|
||||
) {
|
||||
let id = 0;
|
||||
return new TournamentManager<undefined, undefined>(
|
||||
{
|
||||
id: "t",
|
||||
name: "Test Cup",
|
||||
bracketType: opts.bracketType ?? "single-elimination",
|
||||
maxParticipants: opts.max ?? 8,
|
||||
shuffleSeeds: opts.shuffle ?? false,
|
||||
useSeeding: opts.useSeeding ?? true,
|
||||
},
|
||||
{
|
||||
clock: opts.clock,
|
||||
rng: opts.rng,
|
||||
idGenerator: () => `m-${++id}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
describe("TournamentManager", () => {
|
||||
it("rejects invalid config", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new TournamentManager({
|
||||
id: "",
|
||||
name: "",
|
||||
bracketType: "single-elimination",
|
||||
maxParticipants: 4,
|
||||
});
|
||||
expect.fail();
|
||||
} catch (err) {
|
||||
expect((err as GameServerError).code).toBe(
|
||||
"GAME_SERVER.INVALID_BRACKET_TYPE"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("addParticipant tracks seed and rejects duplicates/overflow", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm({ max: 2 });
|
||||
tm.addParticipant("a");
|
||||
try {
|
||||
tm.addParticipant("a");
|
||||
expect.fail();
|
||||
} catch (err) {
|
||||
expect((err as GameServerError).code).toBe(
|
||||
"GAME_SERVER.TOURNAMENT_DUPLICATE_PARTICIPANT"
|
||||
);
|
||||
}
|
||||
tm.addParticipant("b");
|
||||
try {
|
||||
tm.addParticipant("c");
|
||||
expect.fail();
|
||||
} catch (err) {
|
||||
expect((err as GameServerError).code).toBe(
|
||||
"GAME_SERVER.TOURNAMENT_FULL"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("start rejects with <2 participants", { timeout: 5000 }, () => {
|
||||
const tm = makeTm();
|
||||
tm.addParticipant("a");
|
||||
try {
|
||||
tm.start();
|
||||
expect.fail();
|
||||
} catch (err) {
|
||||
expect((err as GameServerError).code).toBe(
|
||||
"GAME_SERVER.TOURNAMENT_INSUFFICIENT_PARTICIPANTS"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("start rejects when already started", { timeout: 5000 }, () => {
|
||||
const tm = makeTm();
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.start();
|
||||
expect(() => tm.start()).toThrow();
|
||||
});
|
||||
|
||||
it("addParticipant rejects after start", { timeout: 5000 }, () => {
|
||||
const tm = makeTm();
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.start();
|
||||
expect(() => tm.addParticipant("c")).toThrow();
|
||||
});
|
||||
|
||||
it("single-elim with power-of-two: full bracket completes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm({ max: 4 });
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.addParticipant("c");
|
||||
tm.addParticipant("d");
|
||||
tm.start();
|
||||
const t = tm.getTournament();
|
||||
expect(t.rounds.length).toBe(2);
|
||||
// semis
|
||||
tm.recordResult(t.rounds[0]!.matches[0]!.id, "a");
|
||||
tm.recordResult(t.rounds[0]!.matches[1]!.id, "c");
|
||||
// finals
|
||||
tm.recordResult(t.rounds[1]!.matches[0]!.id, "a");
|
||||
expect(tm.getWinner()?.id).toBe("a");
|
||||
expect(tm.getTournament().status).toBe("completed");
|
||||
});
|
||||
|
||||
it("single-elim with 8 participants names Quarter-Finals", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm({ max: 8 });
|
||||
for (const id of ["a", "b", "c", "d", "e", "f", "g", "h"]) {
|
||||
tm.addParticipant(id);
|
||||
}
|
||||
tm.start();
|
||||
const names = tm.getTournament().rounds.map((r) => r.name);
|
||||
expect(names).toEqual(["Quarter-Finals", "Semi-Finals", "Finals"]);
|
||||
});
|
||||
|
||||
it("single-elim with 16 participants includes a 'Round N' label", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm({ max: 16 });
|
||||
for (let i = 0; i < 16; i += 1) tm.addParticipant(`p${i}`);
|
||||
tm.start();
|
||||
const names = tm.getTournament().rounds.map((r) => r.name);
|
||||
expect(names[0]).toBe("Round 1");
|
||||
});
|
||||
|
||||
it("single-elim with non-power-of-two: first-round bye propagates", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm({ max: 4 });
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.addParticipant("c");
|
||||
tm.start();
|
||||
const t = tm.getTournament();
|
||||
// First round has a bye (4-slot bracket, 3 players)
|
||||
const r1 = t.rounds[0]!;
|
||||
const byes = r1.matches.filter((m) => m.status === "bye");
|
||||
expect(byes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("getMatch rejects an unknown id", { timeout: 5000 }, () => {
|
||||
const tm = makeTm();
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.start();
|
||||
try {
|
||||
tm.getMatch("nope");
|
||||
expect.fail();
|
||||
} catch (err) {
|
||||
expect((err as GameServerError).code).toBe(
|
||||
"GAME_SERVER.TOURNAMENT_MATCH_NOT_FOUND"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("recordResult rejects unknown winner", { timeout: 5000 }, () => {
|
||||
const tm = makeTm();
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.start();
|
||||
const id = tm.getTournament().rounds[0]!.matches[0]!.id;
|
||||
try {
|
||||
tm.recordResult(id, "z");
|
||||
expect.fail();
|
||||
} catch (err) {
|
||||
expect((err as GameServerError).code).toBe(
|
||||
"GAME_SERVER.TOURNAMENT_INVALID_RESULT"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("recordResult rejects when match already completed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm();
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.start();
|
||||
const id = tm.getTournament().rounds[0]!.matches[0]!.id;
|
||||
tm.recordResult(id, "a");
|
||||
expect(() => tm.recordResult(id, "a")).toThrow();
|
||||
});
|
||||
|
||||
it("recordResult rejects when participants not set (later round)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm({ max: 4 });
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.addParticipant("c");
|
||||
tm.addParticipant("d");
|
||||
tm.start();
|
||||
const finalsId = tm.getTournament().rounds[1]!.matches[0]!.id;
|
||||
try {
|
||||
tm.recordResult(finalsId, "a");
|
||||
expect.fail();
|
||||
} catch (err) {
|
||||
expect((err as GameServerError).code).toBe(
|
||||
"GAME_SERVER.TOURNAMENT_INVALID_RESULT"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("round-robin completes after all rounds played", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm({ bracketType: "round-robin", max: 4 });
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.addParticipant("c");
|
||||
tm.addParticipant("d");
|
||||
tm.start();
|
||||
for (const round of tm.getTournament().rounds) {
|
||||
for (const m of round.matches) {
|
||||
tm.recordResult(m.id, m.participant1!.id);
|
||||
}
|
||||
}
|
||||
expect(tm.getTournament().status).toBe("completed");
|
||||
expect(tm.getWinner()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("round-robin handles odd participant counts", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm({ bracketType: "round-robin", max: 4 });
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.addParticipant("c");
|
||||
tm.start();
|
||||
const total = tm
|
||||
.getTournament()
|
||||
.rounds.flatMap((r) => r.matches).length;
|
||||
expect(total).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shuffleSeeds uses injected RNG deterministically", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rng = deterministicRng([0, 1, 0, 1]);
|
||||
const tm = makeTm({ shuffle: true, max: 4, rng });
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.addParticipant("c");
|
||||
tm.addParticipant("d");
|
||||
tm.start();
|
||||
const seeds = tm.getTournament().participants.map((p) => p.seed);
|
||||
expect(seeds).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("useSeeding=false preserves registration order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm({ useSeeding: false });
|
||||
tm.addParticipant("z");
|
||||
tm.addParticipant("a");
|
||||
tm.start();
|
||||
expect(tm.getTournament().participants.map((p) => p.id)).toEqual([
|
||||
"z",
|
||||
"a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("seeding sort runs when useSeeding=true without shuffle", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm({ useSeeding: true });
|
||||
tm.addParticipant("a", undefined, 3);
|
||||
tm.addParticipant("b", undefined, 1);
|
||||
tm.start();
|
||||
expect(tm.getTournament().participants[0]?.seed).toBe(1);
|
||||
});
|
||||
|
||||
it("cancel transitions status; rejects after completion", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm();
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.start();
|
||||
tm.cancel();
|
||||
expect(tm.getTournament().status).toBe("cancelled");
|
||||
const tm2 = makeTm();
|
||||
tm2.addParticipant("a");
|
||||
tm2.addParticipant("b");
|
||||
tm2.start();
|
||||
const id = tm2.getTournament().rounds[0]!.matches[0]!.id;
|
||||
tm2.recordResult(id, "a");
|
||||
try {
|
||||
tm2.cancel();
|
||||
expect.fail();
|
||||
} catch (err) {
|
||||
expect((err as GameServerError).code).toBe(
|
||||
"GAME_SERVER.TOURNAMENT_INVALID_STATE"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getStats reports counts", { timeout: 5000 }, () => {
|
||||
const tm = makeTm({ max: 4 });
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.addParticipant("c");
|
||||
tm.addParticipant("d");
|
||||
tm.start();
|
||||
const s = tm.getStats();
|
||||
expect(s.totalMatches).toBeGreaterThan(0);
|
||||
expect(s.activeParticipants).toBe(4);
|
||||
});
|
||||
|
||||
it("getStats after completion uses rounds.length fallback", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = makeTm();
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.start();
|
||||
const id = tm.getTournament().rounds[0]!.matches[0]!.id;
|
||||
tm.recordResult(id, "a");
|
||||
const s = tm.getStats();
|
||||
expect(s.currentRound).toBe(tm.getTournament().rounds.length);
|
||||
});
|
||||
|
||||
it("getStandings returns rank-sorted array", { timeout: 5000 }, () => {
|
||||
const tm = makeTm();
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.start();
|
||||
const id = tm.getTournament().rounds[0]!.matches[0]!.id;
|
||||
tm.recordResult(id, "a");
|
||||
const st = tm.getStandings();
|
||||
expect(st[0]?.participant.id).toBe("a");
|
||||
});
|
||||
|
||||
it("getWinner is null while pending", { timeout: 5000 }, () => {
|
||||
const tm = makeTm();
|
||||
expect(tm.getWinner()).toBeNull();
|
||||
});
|
||||
|
||||
it("default idGenerator yields unique match ids", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tm = new TournamentManager<undefined, undefined>({
|
||||
id: "t",
|
||||
name: "n",
|
||||
bracketType: "single-elimination",
|
||||
maxParticipants: 4,
|
||||
});
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.start();
|
||||
const ids = tm
|
||||
.getTournament()
|
||||
.rounds.flatMap((r) => r.matches.map((m) => m.id));
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("shuffle without nextIntInRange leaves order intact", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rng: Rng = {
|
||||
nextUint32: () => 1,
|
||||
nextFloat: () => 0,
|
||||
};
|
||||
const tm = makeTm({ shuffle: true, rng });
|
||||
tm.addParticipant("a");
|
||||
tm.addParticipant("b");
|
||||
tm.start();
|
||||
expect(tm.getTournament().participants.map((p) => p.id)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import type { Counter, Meter } from "@opentelemetry/api";
|
||||
import { metrics as otelMetrics } from "@opentelemetry/api";
|
||||
import type { Clock, IdGenerator, Rng } from "@suaveplan/types";
|
||||
import { GAME_SERVER_CODES, GameServerError } from "../../../errors/index.js";
|
||||
import {
|
||||
buildRoundRobin,
|
||||
buildSingleElim,
|
||||
shuffleWithRng,
|
||||
} from "./tournament-bracket.internal.js";
|
||||
import {
|
||||
type BracketType,
|
||||
type Match,
|
||||
type Participant,
|
||||
type Standing,
|
||||
type Tournament,
|
||||
type TournamentConfig,
|
||||
TournamentConfigSchema,
|
||||
type TournamentStats,
|
||||
} from "./tournament-manager.types.js";
|
||||
import {
|
||||
applyStanding,
|
||||
findMatch,
|
||||
markEliminated,
|
||||
markRoundCompleteIfDone,
|
||||
} from "./tournament-standings.internal.js";
|
||||
|
||||
export interface TournamentManagerDeps {
|
||||
/** Wall-clock source. Defaults to `Date.now`. */
|
||||
clock?: Clock;
|
||||
/** RNG for seed shuffling. */
|
||||
rng?: Rng;
|
||||
/** Unique-id source for matches. */
|
||||
idGenerator?: IdGenerator;
|
||||
/** Optional OTel meter. */
|
||||
meter?: Meter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bracket-tournament manager covering single-elimination and round-robin
|
||||
* formats. Seeding is deterministic when `useSeeding` is on; shuffling
|
||||
* uses an injected `Rng` so seeded replays are reproducible.
|
||||
*/
|
||||
export class TournamentManager<TParticipant = unknown, TResult = unknown> {
|
||||
private readonly clock: Clock;
|
||||
private readonly rng: Rng | undefined;
|
||||
private readonly idGen: IdGenerator;
|
||||
private tournament: Tournament<TParticipant, TResult>;
|
||||
private matchCounter = 0;
|
||||
private readonly matchCounterMetric: Counter;
|
||||
|
||||
public constructor(
|
||||
config: TournamentConfig,
|
||||
deps: TournamentManagerDeps = {}
|
||||
) {
|
||||
const parsed = TournamentConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.INVALID_BRACKET_TYPE,
|
||||
"Invalid TournamentConfig",
|
||||
{ cause: parsed.error, context: { config } }
|
||||
);
|
||||
}
|
||||
this.clock = deps.clock ?? Date.now;
|
||||
this.rng = deps.rng;
|
||||
this.idGen =
|
||||
deps.idGenerator ?? (() => `match-${(this.matchCounter += 1)}`);
|
||||
const c = parsed.data;
|
||||
this.tournament = {
|
||||
config: {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
bracketType: c.bracketType,
|
||||
maxParticipants: c.maxParticipants,
|
||||
useSeeding: c.useSeeding ?? true,
|
||||
shuffleSeeds: c.shuffleSeeds ?? false,
|
||||
winPoints: c.winPoints ?? 3,
|
||||
drawPoints: c.drawPoints ?? 1,
|
||||
lossPoints: c.lossPoints ?? 0,
|
||||
},
|
||||
status: "pending",
|
||||
participants: [],
|
||||
rounds: [],
|
||||
standings: [],
|
||||
winner: null,
|
||||
createdAt: this.clock(),
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
};
|
||||
const m = deps.meter ?? otelMetrics.getMeter("@suaveplan/game-server");
|
||||
this.matchCounterMetric = m.createCounter(
|
||||
"game_server.tournament.match_total",
|
||||
{ description: "Tournament matches by op (created, completed)" }
|
||||
);
|
||||
}
|
||||
|
||||
/** Add a participant. */
|
||||
public addParticipant(
|
||||
id: string,
|
||||
data?: TParticipant,
|
||||
seed?: number
|
||||
): Participant<TParticipant> {
|
||||
if (this.tournament.status !== "pending") {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.TOURNAMENT_INVALID_STATE,
|
||||
"Cannot add participants after start"
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.tournament.participants.length >=
|
||||
this.tournament.config.maxParticipants
|
||||
) {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.TOURNAMENT_FULL,
|
||||
"Tournament is full"
|
||||
);
|
||||
}
|
||||
if (this.tournament.participants.some((p) => p.id === id)) {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.TOURNAMENT_DUPLICATE_PARTICIPANT,
|
||||
`Participant "${id}" already registered`
|
||||
);
|
||||
}
|
||||
const p: Participant<TParticipant> = {
|
||||
id,
|
||||
seed: seed ?? this.tournament.participants.length + 1,
|
||||
data,
|
||||
};
|
||||
this.tournament.participants.push(p);
|
||||
this.tournament.standings.push({
|
||||
participant: p,
|
||||
rank: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
draws: 0,
|
||||
points: 0,
|
||||
eliminated: false,
|
||||
});
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Begin the tournament: lock participants and build the bracket. */
|
||||
public start(): void {
|
||||
if (this.tournament.status !== "pending") {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.TOURNAMENT_INVALID_STATE,
|
||||
"Tournament already started"
|
||||
);
|
||||
}
|
||||
if (this.tournament.participants.length < 2) {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.TOURNAMENT_INSUFFICIENT_PARTICIPANTS,
|
||||
"Need at least 2 participants"
|
||||
);
|
||||
}
|
||||
if (this.tournament.config.shuffleSeeds) {
|
||||
shuffleWithRng(this.tournament.participants, this.rng);
|
||||
this.tournament.participants.forEach((p, i) => {
|
||||
this.tournament.participants[i] = { ...p, seed: i + 1 };
|
||||
});
|
||||
} else if (this.tournament.config.useSeeding) {
|
||||
this.tournament.participants.sort((a, b) => a.seed - b.seed);
|
||||
}
|
||||
this.tournament.status = "in-progress";
|
||||
this.tournament.startedAt = this.clock();
|
||||
const factory = (round: number, position: number) =>
|
||||
this.createMatch(round, position);
|
||||
this.tournament.rounds =
|
||||
this.tournament.config.bracketType === "single-elimination"
|
||||
? buildSingleElim(this.tournament.participants, factory)
|
||||
: buildRoundRobin(this.tournament.participants, factory);
|
||||
this.propagateByes();
|
||||
}
|
||||
|
||||
/** Look up a match by id. */
|
||||
public getMatch(matchId: string): Match<TParticipant, TResult> {
|
||||
const found = findMatch(this.tournament.rounds, matchId);
|
||||
if (!found) {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.TOURNAMENT_MATCH_NOT_FOUND,
|
||||
`Match "${matchId}" not found`,
|
||||
{ context: { matchId } }
|
||||
);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Record a match result and advance the winner. */
|
||||
public recordResult(
|
||||
matchId: string,
|
||||
winnerId: string,
|
||||
resultData?: TResult
|
||||
): Match<TParticipant, TResult> {
|
||||
const match = this.getMatch(matchId);
|
||||
if (match.status === "completed" || match.status === "bye") {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.TOURNAMENT_INVALID_RESULT,
|
||||
"Match already completed"
|
||||
);
|
||||
}
|
||||
if (!match.participant1 || !match.participant2) {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.TOURNAMENT_INVALID_RESULT,
|
||||
"Match participants not set"
|
||||
);
|
||||
}
|
||||
const winner = [match.participant1, match.participant2].find(
|
||||
(p) => p.id === winnerId
|
||||
);
|
||||
if (!winner) {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.TOURNAMENT_INVALID_RESULT,
|
||||
"Winner not in match"
|
||||
);
|
||||
}
|
||||
const loser = [match.participant1, match.participant2].find(
|
||||
(p) => p.id !== winnerId
|
||||
)!;
|
||||
match.status = "completed";
|
||||
match.result = {
|
||||
winnerId,
|
||||
loserId: loser.id,
|
||||
data: resultData,
|
||||
};
|
||||
this.matchCounterMetric.add(1, { op: "completed" });
|
||||
applyStanding(
|
||||
this.tournament.standings,
|
||||
winner,
|
||||
loser,
|
||||
this.tournament.config.winPoints,
|
||||
this.tournament.config.lossPoints
|
||||
);
|
||||
this.advanceWinner(match, winner);
|
||||
if (this.tournament.config.bracketType === "single-elimination") {
|
||||
markEliminated(this.tournament.standings, loser.id);
|
||||
}
|
||||
markRoundCompleteIfDone(this.tournament.rounds, match);
|
||||
this.checkTournamentCompletion();
|
||||
return match;
|
||||
}
|
||||
|
||||
/** Cancel an unfinished tournament. */
|
||||
public cancel(): void {
|
||||
if (this.tournament.status === "completed") {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.TOURNAMENT_INVALID_STATE,
|
||||
"Cannot cancel completed tournament"
|
||||
);
|
||||
}
|
||||
this.tournament.status = "cancelled";
|
||||
this.tournament.endedAt = this.clock();
|
||||
}
|
||||
|
||||
/** Aggregate statistics. */
|
||||
public getStats(): TournamentStats {
|
||||
const all = this.tournament.rounds.flatMap((r) => r.matches);
|
||||
const completed = all.filter(
|
||||
(m) => m.status === "completed" || m.status === "bye"
|
||||
).length;
|
||||
const active = this.tournament.standings.filter(
|
||||
(s) => !s.eliminated
|
||||
).length;
|
||||
const currentRound =
|
||||
this.tournament.rounds.find((r) => !r.completed)?.number ??
|
||||
this.tournament.rounds.length;
|
||||
return {
|
||||
totalMatches: all.length,
|
||||
completedMatches: completed,
|
||||
remainingMatches: all.length - completed,
|
||||
currentRound,
|
||||
activeParticipants: active,
|
||||
eliminatedParticipants:
|
||||
this.tournament.participants.length - active,
|
||||
};
|
||||
}
|
||||
|
||||
/** Tournament view. */
|
||||
public getTournament(): Tournament<TParticipant, TResult> {
|
||||
return this.tournament;
|
||||
}
|
||||
|
||||
/** Winner or `null` until tournament completes. */
|
||||
public getWinner(): Participant<TParticipant> | null {
|
||||
return this.tournament.winner;
|
||||
}
|
||||
|
||||
/** Current standings (rank-sorted). */
|
||||
public getStandings(): ReadonlyArray<Standing<TParticipant>> {
|
||||
return [...this.tournament.standings];
|
||||
}
|
||||
|
||||
private createMatch(
|
||||
round: number,
|
||||
position: number
|
||||
): Match<TParticipant, TResult> {
|
||||
this.matchCounterMetric.add(1, { op: "created" });
|
||||
return {
|
||||
id: this.idGen(),
|
||||
round,
|
||||
position,
|
||||
participant1: null,
|
||||
participant2: null,
|
||||
status: "pending",
|
||||
result: null,
|
||||
nextMatchId: null,
|
||||
};
|
||||
}
|
||||
|
||||
private propagateByes(): void {
|
||||
for (const round of this.tournament.rounds) {
|
||||
for (const m of round.matches) {
|
||||
if (m.status === "bye" && m.participant1) {
|
||||
this.advanceWinner(m, m.participant1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private advanceWinner(
|
||||
match: Match<TParticipant, TResult>,
|
||||
winner: Participant<TParticipant>
|
||||
): void {
|
||||
if (!match.nextMatchId) return;
|
||||
const next = findMatch(this.tournament.rounds, match.nextMatchId)!;
|
||||
if (next.participant1 === null) next.participant1 = winner;
|
||||
else next.participant2 = winner;
|
||||
}
|
||||
|
||||
private checkTournamentCompletion(): void {
|
||||
const active = this.tournament.standings.filter((s) => !s.eliminated);
|
||||
const all = this.tournament.rounds.every((r) => r.completed);
|
||||
if (this.tournament.config.bracketType === "round-robin" && all) {
|
||||
const sorted = [...active].sort((a, b) => b.points - a.points);
|
||||
this.tournament.winner = sorted[0]!.participant;
|
||||
this.completeTournament();
|
||||
} else if (active.length === 1) {
|
||||
this.tournament.winner = active[0]!.participant;
|
||||
this.completeTournament();
|
||||
}
|
||||
}
|
||||
|
||||
private completeTournament(): void {
|
||||
this.tournament.status = "completed";
|
||||
this.tournament.endedAt = this.clock();
|
||||
}
|
||||
}
|
||||
|
||||
export type { BracketType };
|
||||
@@ -0,0 +1,120 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Supported bracket types. */
|
||||
export const BracketTypeSchema = z.enum(["single-elimination", "round-robin"]);
|
||||
export type BracketType = z.infer<typeof BracketTypeSchema>;
|
||||
|
||||
/** Tournament lifecycle status. */
|
||||
export const TournamentStatusSchema = z.enum([
|
||||
"pending",
|
||||
"in-progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]);
|
||||
export type TournamentStatus = z.infer<typeof TournamentStatusSchema>;
|
||||
|
||||
/** Match status. */
|
||||
export const MatchStatusSchema = z.enum([
|
||||
"pending",
|
||||
"in-progress",
|
||||
"completed",
|
||||
"bye",
|
||||
]);
|
||||
export type MatchStatus = z.infer<typeof MatchStatusSchema>;
|
||||
|
||||
/** Tournament configuration. */
|
||||
export interface TournamentConfig {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly bracketType: BracketType;
|
||||
readonly maxParticipants: number;
|
||||
readonly useSeeding?: boolean;
|
||||
readonly shuffleSeeds?: boolean;
|
||||
readonly winPoints?: number;
|
||||
readonly drawPoints?: number;
|
||||
readonly lossPoints?: number;
|
||||
}
|
||||
|
||||
export const TournamentConfigSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
bracketType: BracketTypeSchema,
|
||||
maxParticipants: z.number().int().positive(),
|
||||
useSeeding: z.boolean().optional(),
|
||||
shuffleSeeds: z.boolean().optional(),
|
||||
winPoints: z.number().int().nonnegative().optional(),
|
||||
drawPoints: z.number().int().nonnegative().optional(),
|
||||
lossPoints: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** A registered participant. */
|
||||
export interface Participant<TData = unknown> {
|
||||
readonly id: string;
|
||||
readonly seed: number;
|
||||
readonly data?: TData | undefined;
|
||||
}
|
||||
|
||||
/** A match result payload. */
|
||||
export interface MatchResult<TResult = unknown> {
|
||||
readonly winnerId: string;
|
||||
readonly loserId: string | null;
|
||||
readonly data?: TResult | undefined;
|
||||
}
|
||||
|
||||
/** A bracket match. */
|
||||
export interface Match<TParticipant = unknown, TResult = unknown> {
|
||||
readonly id: string;
|
||||
readonly round: number;
|
||||
readonly position: number;
|
||||
participant1: Participant<TParticipant> | null;
|
||||
participant2: Participant<TParticipant> | null;
|
||||
status: MatchStatus;
|
||||
result: MatchResult<TResult> | null;
|
||||
nextMatchId: string | null;
|
||||
}
|
||||
|
||||
/** A bracket round. */
|
||||
export interface Round<TParticipant = unknown, TResult = unknown> {
|
||||
readonly number: number;
|
||||
readonly name: string;
|
||||
readonly matches: Match<TParticipant, TResult>[];
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
/** Participant standing in the tournament. */
|
||||
export interface Standing<TParticipant = unknown> {
|
||||
readonly participant: Participant<TParticipant>;
|
||||
rank: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
draws: number;
|
||||
points: number;
|
||||
eliminated: boolean;
|
||||
}
|
||||
|
||||
/** Aggregate tournament view. */
|
||||
export interface Tournament<TParticipant = unknown, TResult = unknown> {
|
||||
readonly config: Required<
|
||||
Omit<TournamentConfig, "useSeeding" | "shuffleSeeds">
|
||||
> & { useSeeding: boolean; shuffleSeeds: boolean };
|
||||
status: TournamentStatus;
|
||||
participants: Participant<TParticipant>[];
|
||||
rounds: Round<TParticipant, TResult>[];
|
||||
standings: Standing<TParticipant>[];
|
||||
winner: Participant<TParticipant> | null;
|
||||
readonly createdAt: number;
|
||||
startedAt: number | null;
|
||||
endedAt: number | null;
|
||||
}
|
||||
|
||||
/** Aggregate stats. */
|
||||
export interface TournamentStats {
|
||||
readonly totalMatches: number;
|
||||
readonly completedMatches: number;
|
||||
readonly remainingMatches: number;
|
||||
readonly currentRound: number;
|
||||
readonly activeParticipants: number;
|
||||
readonly eliminatedParticipants: number;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# tournament-standings.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure helper utilities for the standings table inside a tournament:
|
||||
applying a win/loss outcome, marking participants eliminated, locating a
|
||||
match by id, and flipping a round to completed. Extracted from
|
||||
`tournament-manager.ts` to keep that file under the project's 300-line
|
||||
hard limit while preserving full behaviour through the public API.
|
||||
|
||||
The helpers are stateless and take their targets by reference so the
|
||||
manager retains exclusive ownership of mutability. They are covered
|
||||
indirectly via the manager's public surface tests rather than directly,
|
||||
which is the standard pattern for `.internal.ts` modules in this repo.
|
||||
|
||||
## Features
|
||||
|
||||
- `applyStanding` — write outcome + resort + re-rank in one call.
|
||||
- `markEliminated` — set `eliminated = true` by participant id.
|
||||
- `findMatch` — linear scan across rounds (small N).
|
||||
- `markRoundCompleteIfDone` — flip round completion idempotently.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { applyStanding } from "./tournament-standings.internal.js";
|
||||
applyStanding(standings, winner, loser, 3, 0);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
applyStanding,
|
||||
findMatch,
|
||||
markEliminated,
|
||||
markRoundCompleteIfDone,
|
||||
} from "./tournament-standings.internal.js";
|
||||
|
||||
applyStanding(tournament.standings, winner, loser, winPoints, lossPoints);
|
||||
markEliminated(tournament.standings, loser.id);
|
||||
const next = findMatch(tournament.rounds, currentMatch.nextMatchId!);
|
||||
markRoundCompleteIfDone(tournament.rounds, currentMatch);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `applyStanding(standings, winner, loser, winPoints, lossPoints): void`
|
||||
- `markEliminated(standings, id): void`
|
||||
- `findMatch(rounds, matchId): Match | null`
|
||||
- `markRoundCompleteIfDone(rounds, match): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The standings sort is stable on points first, then wins, then losses
|
||||
ascending — the conventional Swiss-style ordering. Re-sorting after every
|
||||
outcome is `O(N log N)` per result; for typical bracket sizes (≤ 256
|
||||
participants) this is well under a millisecond and avoids the
|
||||
bookkeeping complexity of an incremental order-statistic structure.
|
||||
|
||||
`findMatch` is a linear scan across all rounds. Bracket-size rounds grow
|
||||
logarithmically with participant count in single-elimination and
|
||||
linearly in round-robin, so the total match count stays small relative to
|
||||
typical scan budgets. A hot-path id-to-match index would be premature
|
||||
optimisation here and would force the manager to keep two structures in
|
||||
sync.
|
||||
|
||||
`markRoundCompleteIfDone` treats `bye` and `completed` as equivalent
|
||||
terminal states so a round consisting entirely of byes is reported as
|
||||
done immediately after `start()`, which keeps `getStats().currentRound`
|
||||
advancing through automatic walkovers without manual intervention.
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
Match,
|
||||
Participant,
|
||||
Round,
|
||||
Standing,
|
||||
} from "./tournament-manager.types.js";
|
||||
|
||||
/** Apply a win/loss outcome to standings and re-sort. */
|
||||
export function applyStanding<TP>(
|
||||
standings: Standing<TP>[],
|
||||
winner: Participant<TP>,
|
||||
loser: Participant<TP>,
|
||||
winPoints: number,
|
||||
lossPoints: number
|
||||
): void {
|
||||
const ws = standings.find((s) => s.participant.id === winner.id)!;
|
||||
const ls = standings.find((s) => s.participant.id === loser.id)!;
|
||||
ws.wins += 1;
|
||||
ws.points += winPoints;
|
||||
ls.losses += 1;
|
||||
ls.points += lossPoints;
|
||||
standings.sort(
|
||||
(a, b) => b.points - a.points || b.wins - a.wins || a.losses - b.losses
|
||||
);
|
||||
standings.forEach((s, i) => {
|
||||
s.rank = i + 1;
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a participant as eliminated by id. */
|
||||
export function markEliminated<TP>(
|
||||
standings: Standing<TP>[],
|
||||
id: string
|
||||
): void {
|
||||
const s = standings.find((st) => st.participant.id === id)!;
|
||||
s.eliminated = true;
|
||||
}
|
||||
|
||||
/** Locate a match in any round. */
|
||||
export function findMatch<TP, TR>(
|
||||
rounds: Round<TP, TR>[],
|
||||
matchId: string
|
||||
): Match<TP, TR> | null {
|
||||
for (const r of rounds) {
|
||||
const m = r.matches.find((x) => x.id === matchId);
|
||||
if (m) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Mark a round completed when every match in it is settled. */
|
||||
export function markRoundCompleteIfDone<TP, TR>(
|
||||
rounds: Round<TP, TR>[],
|
||||
match: Match<TP, TR>
|
||||
): void {
|
||||
const round = rounds.find((r) => r.matches.some((m) => m.id === match.id));
|
||||
if (
|
||||
round?.matches.every(
|
||||
(m) => m.status === "completed" || m.status === "bye"
|
||||
)
|
||||
) {
|
||||
round.completed = true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user