- {visibleRounds.map((round, ri) => {
- const roundMatches = matchesByRound.get(round) ?? [];
- const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
- const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT);
- const cardTop = (slotHeight - cardHeight) / 2;
- const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null;
- const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : [];
+ {visible.map((column, vi) => {
+ const ci = firstColumn + vi;
+ const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci);
return (
-
+ {/* Mobile — paged one group at a time, matching the desktop split. Paging a
+ whole phase would merge the winners and elimination brackets into one
+ tree, and a double-elimination phase is a DAG rather than a tree: the
+ same game feeds forward and sideways, so its column placement would be
+ arbitrary. */}
+
{phase.layout === "play-in" ? (
+ ) : phase.groups ? (
+ <>
+ {phase.groups.map((group) => (
+
+
+ {group.name}
+
+
group.roundMatchNumbers[r] !== undefined)}
+ matchesByRound={groupMatches(matchesByRound, group)}
+ ownershipMap={ownershipMap}
+ userParticipantIds={userParticipantIds}
+ feeders={feeders}
+ template={template}
+ />
+
+ ))}
+ {sharedRounds.length > 0 && (
+
+ )}
+ >
) : (
= 0 ? phaseFirstScoringIdx : undefined}
+ feeders={feeders}
+ template={template}
/>
)}
diff --git a/app/components/scoring/__tests__/PlayoffBracket.test.tsx b/app/components/scoring/__tests__/PlayoffBracket.test.tsx
index a4df24f..e0fc986 100644
--- a/app/components/scoring/__tests__/PlayoffBracket.test.tsx
+++ b/app/components/scoring/__tests__/PlayoffBracket.test.tsx
@@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
import { render, screen, within } from "@testing-library/react";
import {
PlayoffBracket,
- buildFeederMap,
groupMatchesByRound,
computeEliminatedByRound,
computeRankedEntries,
@@ -64,88 +63,72 @@ describe("groupMatchesByRound", () => {
});
// ---------------------------------------------------------------------------
-// buildFeederMap
+// Rendered LLWS bracket — geometry and empty-slot labels
// ---------------------------------------------------------------------------
-describe("buildFeederMap", () => {
- it("returns an empty map when there is only one round", () => {
- const matches = [makeMatch("Finals", 1)];
- const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]);
- expect(map.size).toBe(0);
+describe("PlayoffBracket — rendered LLWS bracket", () => {
+ const LLWS_ROUNDS = (getBracketTemplate("llws_20")?.rounds ?? []).map((r) => r.name);
+
+ /** Every LLWS match, all unplayed, so each slot shows what will fill it. */
+ function emptyLlwsMatches(): Match[] {
+ const template = getBracketTemplate("llws_20");
+ const matches: Match[] = [];
+ for (const round of template?.rounds ?? []) {
+ for (let n = 1; n <= round.matchCount; n++) {
+ matches.push({
+ ...makeMatch(round.name, n, { participant1Id: null, participant2Id: null }),
+ participant1: null,
+ participant2: null,
+ });
+ }
+ }
+ return matches;
+ }
+
+ it("names empty slots after the game that feeds them", () => {
+ render(
+
+ );
+
+ // A winners-bracket loss drops into the elimination bracket — an edge that spans
+ // two separately rendered trees, so the label is the only way to show it.
+ expect(screen.getAllByText("Loser of Winners SF 1").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Winner of Opening 1").length).toBeGreaterThan(0);
});
- it("maps SF slots to the correct QF matches for an 8-team bracket", () => {
- const rounds = ["Quarterfinals", "Semifinals", "Finals"];
- const matches = [
- makeMatch("Quarterfinals", 1),
- makeMatch("Quarterfinals", 2),
- makeMatch("Quarterfinals", 3),
- makeMatch("Quarterfinals", 4),
- makeMatch("Semifinals", 1),
- makeMatch("Semifinals", 2),
- makeMatch("Finals", 1),
- ];
+ it("still shows TBD for a directly seeded slot", () => {
+ render(
+
+ );
- const map = buildFeederMap(groupMatchesByRound(matches), rounds);
-
- // SF Match 1, slot p1 ← QF Match 1
- expect(map.get("Semifinals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
- // SF Match 1, slot p2 ← QF Match 2
- expect(map.get("Semifinals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
- // SF Match 2, slot p1 ← QF Match 3
- expect(map.get("Semifinals:2:p1")).toEqual({ round: "Quarterfinals", matchNumber: 3 });
- // SF Match 2, slot p2 ← QF Match 4
- expect(map.get("Semifinals:2:p2")).toEqual({ round: "Quarterfinals", matchNumber: 4 });
+ // The opening round is seeded, not fed, so it has nothing to name.
+ expect(screen.getAllByText("TBD").length).toBeGreaterThan(0);
});
- it("maps Finals slots to the correct SF matches", () => {
- const rounds = ["Quarterfinals", "Semifinals", "Finals"];
- const matches = [
- makeMatch("Quarterfinals", 1),
- makeMatch("Quarterfinals", 2),
- makeMatch("Quarterfinals", 3),
- makeMatch("Quarterfinals", 4),
- makeMatch("Semifinals", 1),
- makeMatch("Semifinals", 2),
- makeMatch("Finals", 1),
- ];
+ it("gives every card the same height, including a lone final", () => {
+ const { container } = render(
+
+ );
- const map = buildFeederMap(groupMatchesByRound(matches), rounds);
-
- expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 });
- expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 });
- });
-
- it("does not add an entry when the source match does not exist in the previous round", () => {
- const rounds = ["Quarterfinals", "Finals"];
- const matches = [
- makeMatch("Quarterfinals", 1),
- makeMatch("Quarterfinals", 2),
- makeMatch("Finals", 1),
- ];
-
- const map = buildFeederMap(groupMatchesByRound(matches), rounds);
-
- expect(map.get("Finals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
- expect(map.get("Finals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
- expect(map.has("Finals:2:p1")).toBe(false);
- });
-
- it("handles a 16-team bracket correctly for Round of 16 → Quarterfinals", () => {
- const rounds = ["Round of 16", "Quarterfinals", "Semifinals", "Finals"];
- const matches = [
- ...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => makeMatch("Round of 16", n)),
- ...[1, 2, 3, 4].map((n) => makeMatch("Quarterfinals", n)),
- ...[1, 2].map((n) => makeMatch("Semifinals", n)),
- makeMatch("Finals", 1),
- ];
-
- const map = buildFeederMap(groupMatchesByRound(matches), rounds);
-
- expect(map.get("Quarterfinals:1:p1")).toEqual({ round: "Round of 16", matchNumber: 1 });
- expect(map.get("Quarterfinals:1:p2")).toEqual({ round: "Round of 16", matchNumber: 2 });
- expect(map.get("Quarterfinals:4:p1")).toEqual({ round: "Round of 16", matchNumber: 7 });
- expect(map.get("Quarterfinals:4:p2")).toEqual({ round: "Round of 16", matchNumber: 8 });
+ const heights = new Set(
+ [...container.querySelectorAll
("[data-match-id]")].map(
+ (el) => el.style.height
+ )
+ );
+ // Previously a one-match column stretched its card to fill the bracket height.
+ expect(heights.size).toBe(1);
});
});
diff --git a/app/lib/__tests__/bracket-layout.test.ts b/app/lib/__tests__/bracket-layout.test.ts
new file mode 100644
index 0000000..f1bd782
--- /dev/null
+++ b/app/lib/__tests__/bracket-layout.test.ts
@@ -0,0 +1,377 @@
+/**
+ * Bracket layout tests.
+ *
+ * The load-bearing assertions check the LLWS geometry against the official 2026 LLBWS
+ * bracket, in the PDF's own game numbers. A bracket "lines up" when each card sits level
+ * with the game that feeds it, so these tests assert column membership, top-to-bottom
+ * order, and vertical alignment — not just that a layout was produced.
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+ LLWS_20,
+ SIMPLE_16,
+ NFL_14,
+ getBracketTemplate,
+ type BracketTemplate,
+ type ConferenceGroup,
+} from "~/lib/bracket-templates";
+import {
+ buildFeederMap,
+ computeGroupLayout,
+ describeSlotSource,
+ matchKey,
+ type SlotSource,
+} from "~/lib/bracket-layout";
+import { GAME_TO_MATCH, MATCH_TO_GAME } from "~/test/fixtures/llws-bracket";
+
+interface TestMatch {
+ round: string;
+ matchNumber: number;
+}
+
+/** Every match a template defines, as the renderer would receive them. */
+function allMatches(template: BracketTemplate): Map {
+ const byRound = new Map();
+ for (const round of template.rounds) {
+ byRound.set(
+ round.name,
+ Array.from({ length: round.matchCount }, (_, i) => ({
+ round: round.name,
+ matchNumber: i + 1,
+ }))
+ );
+ }
+ return byRound;
+}
+
+/** The matches of one phase group, filtered the way TabbedBracketLayout filters them. */
+function groupMatches(group: ConferenceGroup): Map {
+ const byRound = new Map();
+ for (const [round, nums] of Object.entries(group.roundMatchNumbers)) {
+ byRound.set(
+ round,
+ nums.map((matchNumber) => ({ round, matchNumber }))
+ );
+ }
+ return byRound;
+}
+
+function findGroup(name: string): ConferenceGroup {
+ for (const phase of LLWS_20.phases ?? []) {
+ for (const group of phase.groups ?? []) {
+ if (group.name === name) return group;
+ }
+ }
+ throw new Error(`No LLWS group named ${name}`);
+}
+
+/** Lay out one LLWS group and describe it in PDF game numbers. */
+function layOutLLWSGroup(name: string) {
+ const group = findGroup(name);
+ const byRound = groupMatches(group);
+ const roundOrder = LLWS_20.rounds.map((r) => r.name);
+ const rounds = roundOrder.filter((r) => byRound.has(r));
+
+ const layout = computeGroupLayout(rounds, byRound, buildFeederMap(LLWS_20), roundOrder);
+
+ const game = (m: TestMatch) => {
+ const n = MATCH_TO_GAME.get(`${m.round}#${m.matchNumber}`);
+ if (n === undefined) throw new Error(`No PDF game for ${m.round} #${m.matchNumber}`);
+ return n;
+ };
+
+ return {
+ layout,
+ labels: layout.columns.map((c) => c.label),
+ /** Column contents, top to bottom, as PDF game numbers. */
+ columns: layout.columns.map((c) => c.matches.map((m) => game(m.match))),
+ /** Vertical centre of a game's card, in leaf-row units. */
+ centerOf(gameNumber: number): number {
+ const target = GAME_TO_MATCH[gameNumber];
+ for (const column of layout.columns) {
+ for (const { match, center } of column.matches) {
+ if (match.round === target.round && match.matchNumber === target.matchNumber) {
+ return center;
+ }
+ }
+ }
+ throw new Error(`G${gameNumber} is not in this group`);
+ },
+ };
+}
+
+describe("computeGroupLayout — LLWS winners brackets", () => {
+ // The International side is the one in the reported screenshot. Under the old index
+ // math, G5 and G7 were stranded in the first column: they skip Winners Round 2 and go
+ // straight to the semifinals, so nothing in column two lined up with them.
+ it("puts the International winners bracket in the printed bracket's columns", () => {
+ const { columns } = layOutLLWSGroup("International Winner's Bracket");
+ expect(columns).toEqual([
+ [1, 3],
+ [5, 9, 11, 7],
+ [18, 20],
+ [29],
+ ]);
+ });
+
+ it("mirrors that layout on the U.S. side", () => {
+ const { columns } = layOutLLWSGroup("U.S. Winner's Bracket");
+ expect(columns).toEqual([
+ [2, 4],
+ [6, 10, 12, 8],
+ [17, 19],
+ [30],
+ ]);
+ });
+
+ it("names a mixed column for the latest round it holds", () => {
+ // Column two holds two Opening Round games (G5, G7) alongside Winners Round 2.
+ const { labels } = layOutLLWSGroup("International Winner's Bracket");
+ expect(labels).toEqual([
+ "Opening Round",
+ "Winners Round 2",
+ "Winners Semifinals",
+ "Winners Final",
+ ]);
+ });
+
+ it("levels each card with the game that feeds it", () => {
+ const { centerOf } = layOutLLWSGroup("International Winner's Bracket");
+
+ // G1's winner fills a slot of G9, so the two sit at the same height.
+ expect(centerOf(1)).toBe(centerOf(9));
+ expect(centerOf(3)).toBe(centerOf(11));
+
+ // G18 = W5 v W9, so it sits midway between them.
+ expect(centerOf(18)).toBe((centerOf(5) + centerOf(9)) / 2);
+ expect(centerOf(20)).toBe((centerOf(11) + centerOf(7)) / 2);
+ expect(centerOf(29)).toBe((centerOf(18) + centerOf(20)) / 2);
+ });
+
+ it("draws an edge for every in-group feed, played or not", () => {
+ const { layout } = layOutLLWSGroup("International Winner's Bracket");
+ // G9←G1, G11←G3, G18←{G5,G9}, G20←{G11,G7}, G29←{G18,G20}: 8 in-group edges.
+ expect(layout.edges).toHaveLength(8);
+ // Every edge crosses exactly one gutter, which is what makes them drawable.
+ for (const edge of layout.edges) {
+ expect(edge.fromColumn).toBeGreaterThanOrEqual(0);
+ expect(edge.fromColumn).toBeLessThan(layout.columns.length - 1);
+ }
+ });
+});
+
+describe("computeGroupLayout — LLWS elimination brackets", () => {
+ it("orders Elimination Round 3 the way the printed bracket does", () => {
+ // G31 = W27 v W25, so the later game is printed on top — the reverse of match
+ // number order, which is how the old index-based sort got it wrong.
+ const { columns } = layOutLLWSGroup("International Elimination Bracket");
+ expect(columns).toEqual([
+ [13, 15],
+ [21, 23],
+ [27, 25],
+ [31],
+ [33],
+ ]);
+ });
+
+ it("orders the U.S. elimination bracket the same way", () => {
+ const { columns } = layOutLLWSGroup("U.S. Elimination Bracket");
+ expect(columns).toEqual([
+ [14, 16],
+ [22, 24],
+ [28, 26],
+ [32],
+ [34],
+ ]);
+ });
+
+ it("ignores feeds arriving from the winners bracket", () => {
+ // G21 = L9 v W13. L9 is in the winners bracket group, so only W13 is an edge here.
+ const { layout, centerOf } = layOutLLWSGroup("International Elimination Bracket");
+ expect(centerOf(21)).toBe(centerOf(13));
+ expect(layout.edges).toHaveLength(7);
+ });
+});
+
+describe("buildFeederMap", () => {
+ it("routes LLWS winners and losers to the slots the printed bracket shows", () => {
+ const feeders = buildFeederMap(LLWS_20);
+
+ // G18 = W5 v W9.
+ const g18 = GAME_TO_MATCH[18];
+ expect(feeders.get(matchKey(g18.round, g18.matchNumber))).toEqual([
+ { kind: "match", ref: GAME_TO_MATCH[5], result: "winner" },
+ { kind: "match", ref: GAME_TO_MATCH[9], result: "winner" },
+ ]);
+
+ // G13 = L3 v L5 — a winners-bracket loss drops into the elimination bracket.
+ const g13 = GAME_TO_MATCH[13];
+ expect(feeders.get(matchKey(g13.round, g13.matchNumber))).toEqual([
+ { kind: "match", ref: GAME_TO_MATCH[3], result: "loser" },
+ { kind: "match", ref: GAME_TO_MATCH[5], result: "loser" },
+ ]);
+ });
+
+ it("marks directly seeded slots as seeds", () => {
+ const feeders = buildFeederMap(LLWS_20);
+ // G9 = a bye team v W1: slot one is seeded, slot two is fed.
+ const g9 = GAME_TO_MATCH[9];
+ const [p1, p2] = feeders.get(matchKey(g9.round, g9.matchNumber)) ?? [];
+ expect(p1).toEqual({ kind: "seed" });
+ expect(p2).toEqual({ kind: "match", ref: GAME_TO_MATCH[1], result: "winner" });
+ });
+
+ it("applies the standard halving rule to other templates", () => {
+ const feeders = buildFeederMap(SIMPLE_16);
+ expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
+ { kind: "match", ref: { round: "Round of 16", matchNumber: 1 }, result: "winner" },
+ { kind: "match", ref: { round: "Round of 16", matchNumber: 2 }, result: "winner" },
+ ]);
+ expect(feeders.get(matchKey("Quarterfinals", 4))).toEqual([
+ { kind: "match", ref: { round: "Round of 16", matchNumber: 7 }, result: "winner" },
+ { kind: "match", ref: { round: "Round of 16", matchNumber: 8 }, result: "winner" },
+ ]);
+ // The first round is seeded, not fed.
+ expect(feeders.get(matchKey("Round of 16", 1))).toEqual([
+ { kind: "seed" },
+ { kind: "seed" },
+ ]);
+ });
+
+ it("returns an empty map without a template", () => {
+ expect(buildFeederMap(undefined).size).toBe(0);
+ });
+});
+
+describe("computeGroupLayout — standard brackets are unchanged", () => {
+ it("halves a 16-team bracket evenly, first round in seeded order", () => {
+ const byRound = allMatches(SIMPLE_16);
+ const roundOrder = SIMPLE_16.rounds.map((r) => r.name);
+ const layout = computeGroupLayout(
+ roundOrder,
+ byRound,
+ buildFeederMap(SIMPLE_16),
+ roundOrder
+ );
+
+ expect(layout.leafCount).toBe(8);
+ expect(layout.columns.map((c) => c.label)).toEqual(roundOrder);
+ expect(layout.columns.map((c) => c.matches.map((m) => m.match.matchNumber))).toEqual([
+ [1, 2, 3, 4, 5, 6, 7, 8],
+ [1, 2, 3, 4],
+ [1, 2],
+ [1],
+ ]);
+ // Evenly spread, exactly as the previous index math placed them.
+ expect(layout.columns[0].matches.map((m) => m.center)).toEqual([
+ 0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5,
+ ]);
+ expect(layout.columns[3].matches[0].center).toBe(4);
+ });
+
+ it("handles byes, placing a seeded team level with the round it enters", () => {
+ // The NFL bracket's top seeds skip the wild card round.
+ const byRound = allMatches(NFL_14);
+ const roundOrder = NFL_14.rounds.map((r) => r.name);
+ const layout = computeGroupLayout(roundOrder, byRound, buildFeederMap(NFL_14), roundOrder);
+ expect(layout.columns.length).toBeGreaterThan(0);
+ for (const column of layout.columns) {
+ expect(column.matches.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("falls back to even spreading when a group has no single root", () => {
+ // Two finals and no way to join them — the shape can't resolve to one tree.
+ const byRound = new Map([
+ ["Semifinals", [{ round: "Semifinals", matchNumber: 1 }]],
+ [
+ "Finals",
+ [
+ { round: "Finals", matchNumber: 1 },
+ { round: "Finals", matchNumber: 2 },
+ ],
+ ],
+ ]);
+ const layout = computeGroupLayout(
+ ["Semifinals", "Finals"],
+ byRound,
+ new Map(),
+ ["Semifinals", "Finals"]
+ );
+ expect(layout.columns.map((c) => c.label)).toEqual(["Semifinals", "Finals"]);
+ expect(layout.edges).toEqual([]);
+ expect(layout.columns[0].matches[0].center).toBe(1);
+ expect(layout.columns[1].matches.map((m) => m.center)).toEqual([0.5, 1.5]);
+ });
+
+ it("returns nothing for an empty group", () => {
+ const layout = computeGroupLayout([], new Map(), new Map(), []);
+ expect(layout).toEqual({ columns: [], leafCount: 0, edges: [] });
+ });
+});
+
+describe("describeSlotSource", () => {
+ const feeders = buildFeederMap(LLWS_20);
+ const sourcesFor = (game: number): [SlotSource, SlotSource] => {
+ const m = GAME_TO_MATCH[game];
+ const pair = feeders.get(matchKey(m.round, m.matchNumber));
+ if (!pair) throw new Error(`No feeders for G${game}`);
+ return pair;
+ };
+
+ it("names a winner feed", () => {
+ // G18 = W5 v W9; G5 is Opening Round match 3 on the International side.
+ expect(describeSlotSource(sourcesFor(18)[0], LLWS_20)).toBe("Winner of Opening 3");
+ });
+
+ it("names a loser feed, which is the one no line can show", () => {
+ // G21 = L9 v W13; G9 is Winners Round 2 match 1 on the International side.
+ expect(describeSlotSource(sourcesFor(21)[0], LLWS_20)).toBe("Loser of Winners R2 1");
+ // G25 = L18 v W23; G18 is International semifinal 1.
+ expect(describeSlotSource(sourcesFor(25)[0], LLWS_20)).toBe("Loser of Winners SF 1");
+ });
+
+ it("uses side-local numbers, as the printed bracket does", () => {
+ // G27 = L20 v W21. G20 is Winners Semifinals match 4 globally, but International
+ // semifinal 2 — the number the printed bracket uses.
+ expect(describeSlotSource(sourcesFor(27)[0], LLWS_20)).toBe("Loser of Winners SF 2");
+ });
+
+ it("names the side where each side plays only one such game", () => {
+ // G37 = L36 v L35: both feeds are Bracket Championship losers, one per side, so a
+ // number would say nothing and the side is the only thing that tells them apart.
+ const [p1, p2] = sourcesFor(37);
+ expect(describeSlotSource(p1, LLWS_20)).toBe("Loser of U.S. Bracket Final");
+ expect(describeSlotSource(p2, LLWS_20)).toBe("Loser of Intl Bracket Final");
+
+ // Same rule inside a side bracket: G34 = L30 v W32.
+ const [elimP1, elimP2] = sourcesFor(34);
+ expect(describeSlotSource(elimP1, LLWS_20)).toBe("Loser of U.S. Winners Final");
+ expect(describeSlotSource(elimP2, LLWS_20)).toBe("Winner of U.S. Elim R4");
+ });
+
+ it("drops both number and side for the shared final games", () => {
+ // The two sides meet here, so there is only one of each game in the whole bracket.
+ const wc = describeSlotSource(
+ { kind: "match", ref: GAME_TO_MATCH[38], result: "winner" },
+ LLWS_20
+ );
+ expect(wc).toBe("Winner of World Championship");
+ });
+
+ it("returns null for a seeded slot so the caller can render TBD", () => {
+ expect(describeSlotSource({ kind: "seed" }, LLWS_20)).toBeNull();
+ expect(describeSlotSource(undefined, LLWS_20)).toBeNull();
+ });
+
+ it("uses plain round names for non-LLWS templates", () => {
+ const template = getBracketTemplate("simple_16");
+ const source: SlotSource = {
+ kind: "match",
+ ref: { round: "Quarterfinals", matchNumber: 3 },
+ result: "winner",
+ };
+ expect(describeSlotSource(source, template)).toBe("Winner of Quarterfinals 3");
+ });
+});
diff --git a/app/lib/bracket-layout.ts b/app/lib/bracket-layout.ts
new file mode 100644
index 0000000..49f5a09
--- /dev/null
+++ b/app/lib/bracket-layout.ts
@@ -0,0 +1,351 @@
+/**
+ * Bracket geometry, derived from the real feeder graph.
+ *
+ * The renderer used to place cards by index within a round — match i at
+ * `i * (height / roundSize)` — and drew connectors assuming matches 2k and 2k+1 feed
+ * match k. That holds only when each round is an exact halving of the previous one.
+ *
+ * The LLWS winners bracket is not a halving: two of the four Opening Round games skip
+ * Winners Round 2 entirely and go straight to the semifinals (see LLWS_ADVANCEMENT).
+ * Under index math those games get pulled to the bottom of column one with nothing
+ * above them in column two, and the connectors confidently join the wrong pairs.
+ *
+ * So lay out from the graph instead:
+ * column = depth from the group's final, counted backwards
+ * vertical order = the parent's slot order (participant1 above participant2)
+ * connectors = actual feeder edges
+ *
+ * Counting columns back from the final is what makes a printed bracket line up: a team
+ * entering late sits in the column where it actually plays, not the column its round
+ * name suggests. For the LLWS International side this reproduces the official bracket
+ * exactly, including putting the Australia/Mexico game alongside Winners Round 2.
+ *
+ * Pure — no React, no DB — so the geometry can be asserted against the printed bracket
+ * in tests.
+ */
+
+import {
+ llwsSideAndLocal,
+ type BracketTemplate,
+} from "~/lib/bracket-templates";
+import { resolveLLWSAdvancement } from "~/lib/llws-bracket";
+
+// ── Feeder graph ──────────────────────────────────────────────────────────────
+
+export interface MatchRef {
+ round: string;
+ matchNumber: number;
+}
+
+/** What fills one participant slot of a match. */
+export type SlotSource =
+ | { kind: "match"; ref: MatchRef; result: "winner" | "loser" }
+ | { kind: "seed" };
+
+/** Keyed by `${round}#${matchNumber}`; the pair is [participant1, participant2]. */
+export type FeederMap = Map;
+
+const SEED: SlotSource = { kind: "seed" };
+
+export function matchKey(round: string, matchNumber: number): string {
+ return `${round}#${matchNumber}`;
+}
+
+/**
+ * Invert a template's advancement rules into "what fills each slot".
+ *
+ * `llws_20` has an explicit, hand-verified routing table with deliberate cross-overs, so
+ * it is inverted from that. Everything else follows the standard rule: slot p1 of match N
+ * is the winner of match 2N-1 in the previous round, slot p2 the winner of match 2N.
+ */
+export function buildFeederMap(template: BracketTemplate | undefined): FeederMap {
+ const feeders: FeederMap = new Map();
+ if (!template) return feeders;
+
+ const slots = (key: string): [SlotSource, SlotSource] => {
+ let pair = feeders.get(key);
+ if (!pair) {
+ pair = [SEED, SEED];
+ feeders.set(key, pair);
+ }
+ return pair;
+ };
+
+ // Seed every match in the template so unfed slots read as directly seeded.
+ for (const round of template.rounds) {
+ for (let n = 1; n <= round.matchCount; n++) slots(matchKey(round.name, n));
+ }
+
+ if (template.id === "llws_20") {
+ for (const round of template.rounds) {
+ for (let n = 1; n <= round.matchCount; n++) {
+ const { winner, loser } = resolveLLWSAdvancement(round.name, n);
+ const ref: MatchRef = { round: round.name, matchNumber: n };
+ for (const [destination, result] of [
+ [winner, "winner"],
+ [loser, "loser"],
+ ] as const) {
+ if (!destination) continue;
+ const pair = slots(matchKey(destination.round, destination.matchNumber));
+ pair[destination.slot === "participant1Id" ? 0 : 1] = { kind: "match", ref, result };
+ }
+ }
+ }
+ return feeders;
+ }
+
+ for (let ri = 1; ri < template.rounds.length; ri++) {
+ const round = template.rounds[ri];
+ const prev = template.rounds[ri - 1];
+ for (let n = 1; n <= round.matchCount; n++) {
+ const pair = slots(matchKey(round.name, n));
+ for (const [slotIdx, source] of [
+ [0, 2 * n - 1],
+ [1, 2 * n],
+ ] as const) {
+ if (source > prev.matchCount) continue;
+ pair[slotIdx] = {
+ kind: "match",
+ ref: { round: prev.name, matchNumber: source },
+ result: "winner",
+ };
+ }
+ }
+ }
+
+ return feeders;
+}
+
+// ── Slot labels ───────────────────────────────────────────────────────────────
+
+/**
+ * Round names as they read inside a card, where there is room for about twenty
+ * characters. Anything not listed keeps its full name.
+ */
+const SHORT_ROUND_NAMES: Record = {
+ "Opening Round": "Opening",
+ "Winners Round 2": "Winners R2",
+ "Winners Semifinals": "Winners SF",
+ "Winners Final": "Winners Final",
+ "Elimination Round 1": "Elim R1",
+ "Elimination Round 2": "Elim R2",
+ "Elimination Round 3": "Elim R3",
+ "Elimination Round 4": "Elim R4",
+ "Elimination Final": "Elim Final",
+ "Bracket Championship": "Bracket Final",
+};
+
+/**
+ * How an empty slot should read: "Winner of Winners SF 2" rather than "TBD".
+ *
+ * Returns null for a directly seeded slot, which the caller renders as "TBD".
+ *
+ * The cross-bracket feeds matter most here — a winners-bracket loser dropping into the
+ * elimination bracket is a real edge that no line can show, because the two sides render
+ * as separate trees.
+ */
+export function describeSlotSource(
+ source: SlotSource | undefined,
+ template: BracketTemplate | undefined
+): string | null {
+ if (!source || source.kind !== "match") return null;
+
+ const { round, matchNumber } = source.ref;
+ const name = SHORT_ROUND_NAMES[round] ?? round;
+ const verb = source.result === "winner" ? "Winner" : "Loser";
+ const roundMatchCount = template?.rounds.find((r) => r.name === round)?.matchCount ?? 0;
+
+ if (template?.id !== "llws_20") {
+ return `${verb} of ${name}${roundMatchCount <= 1 ? "" : ` ${matchNumber}`}`;
+ }
+
+ // LLWS numbers matches globally across both sides, so semifinal 4 is International
+ // semifinal 2. Name it the way the printed bracket does — by side-local number, or by
+ // side where each side plays only one such game and the number would say nothing.
+ const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
+ const isShared = round === "Consolation Third Place" || round === "World Championship";
+ const perSideCount = isShared ? roundMatchCount : roundMatchCount / 2;
+
+ if (perSideCount > 1) return `${verb} of ${name} ${localMatch}`;
+ if (isShared) return `${verb} of ${name}`;
+ return `${verb} of ${side === 0 ? "U.S." : "Intl"} ${name}`;
+}
+
+// ── Layout ────────────────────────────────────────────────────────────────────
+
+export interface LaidOutMatch {
+ match: M;
+ /** Centre of the card, in slot units (1 unit = one leaf row). */
+ center: number;
+}
+
+export interface LayoutColumn {
+ label: string;
+ matches: LaidOutMatch[];
+}
+
+export interface BracketLayout {
+ columns: LayoutColumn[];
+ /** Number of leaf rows; multiply by row height for the pixel height of the bracket. */
+ leafCount: number;
+ /** Edges to draw, as (column index of the source, source centre, target centre). */
+ edges: { fromColumn: number; fromCenter: number; toCenter: number }[];
+}
+
+interface PositionedMatch {
+ round: string;
+ matchNumber: number;
+}
+
+/**
+ * Lay out one rendered group — a winners bracket, an elimination bracket, a region.
+ *
+ * `matchesByRound` should already be filtered to the group; cross-group feeds are
+ * dropped, matching the printed bracket, which labels those slots rather than drawing
+ * lines to another tree.
+ *
+ * Falls back to the previous index-based geometry when the group has no single root
+ * (disjoint or unrecognised shapes), so no existing template can regress to a blank
+ * column.
+ */
+export function computeGroupLayout(
+ visibleRounds: string[],
+ matchesByRound: Map,
+ feeders: FeederMap,
+ templateRoundOrder: string[]
+): BracketLayout {
+ const nodes = new Map();
+ const roundOf = new Map();
+ for (const round of visibleRounds) {
+ for (const match of matchesByRound.get(round) ?? []) {
+ const key = matchKey(match.round, match.matchNumber);
+ nodes.set(key, match);
+ roundOf.set(key, round);
+ }
+ }
+ if (nodes.size === 0) return { columns: [], leafCount: 0, edges: [] };
+
+ // In-group children, in slot order. A slot fed from outside the group has no card
+ // here, so it contributes no edge.
+ const childrenOf = new Map();
+ const hasParent = new Set();
+ for (const key of nodes.keys()) {
+ const pair = feeders.get(key);
+ const kids: string[] = [];
+ for (const source of pair ?? []) {
+ if (source.kind !== "match") continue;
+ const childKey = matchKey(source.ref.round, source.ref.matchNumber);
+ if (!nodes.has(childKey) || kids.includes(childKey)) continue;
+ kids.push(childKey);
+ hasParent.add(childKey);
+ }
+ childrenOf.set(key, kids);
+ }
+
+ const roots = [...nodes.keys()].filter((k) => !hasParent.has(k));
+ if (roots.length !== 1) {
+ return fallbackLayout(visibleRounds, matchesByRound);
+ }
+ const [root] = roots;
+
+ // Depth from the root, then flip so leaves are column 0 and the final is last.
+ //
+ // Take the longest path, not the first one found: in a double-elimination bracket a
+ // match feeds two places (its winner forward, its loser into the elimination side), so
+ // the graph is a DAG and a node can be reached at several depths. The longest path is
+ // the one that leaves room for every game on the way.
+ const depth = new Map();
+ const assignDepth = (key: string, d: number) => {
+ const known = depth.get(key);
+ if (known !== undefined && known >= d) return;
+ depth.set(key, d);
+ for (const child of childrenOf.get(key) ?? []) assignDepth(child, d + 1);
+ };
+ assignDepth(root, 0);
+ if (depth.size !== nodes.size) {
+ return fallbackLayout(visibleRounds, matchesByRound);
+ }
+ const maxDepth = Math.max(...depth.values());
+ const columnOf = (key: string) => maxDepth - (depth.get(key) ?? 0);
+
+ // Vertical order comes from a depth-first walk in slot order: participant1's feeder
+ // sits above participant2's. This is why the elimination bracket's later game ends up
+ // on top, as the printed bracket has it.
+ const center = new Map();
+ let leafCount = 0;
+ const place = (key: string): number => {
+ const already = center.get(key);
+ if (already !== undefined) return already;
+ const kids = childrenOf.get(key) ?? [];
+ if (kids.length === 0) {
+ const y = leafCount + 0.5;
+ leafCount += 1;
+ center.set(key, y);
+ return y;
+ }
+ const kidCenters = kids.map(place);
+ const y = kidCenters.reduce((sum, c) => sum + c, 0) / kidCenters.length;
+ center.set(key, y);
+ return y;
+ };
+ place(root);
+
+ const columns: LayoutColumn[] = Array.from({ length: maxDepth + 1 }, () => ({
+ label: "",
+ matches: [],
+ }));
+ for (const [key, match] of nodes) {
+ columns[columnOf(key)].matches.push({ match, center: center.get(key) ?? 0 });
+ }
+ for (const column of columns) {
+ column.matches.sort((a, b) => a.center - b.center);
+ }
+
+ // A column can mix rounds — the LLWS second column holds two Opening Round games
+ // alongside Winners Round 2. Name it for the latest round it contains, which is how
+ // the printed bracket labels that column.
+ for (let ci = 0; ci < columns.length; ci++) {
+ const rounds = columns[ci].matches.map((m) => m.match.round);
+ columns[ci].label = rounds.reduce((latest, r) =>
+ templateRoundOrder.indexOf(r) > templateRoundOrder.indexOf(latest) ? r : latest
+ );
+ }
+
+ // Connectors live in the single gutter between adjacent columns, so only edges that
+ // span exactly one gutter can be drawn. In a tree every edge does; in the DAG case a
+ // feed can reach further back, and a line that stopped short would be worse than none.
+ const edges: BracketLayout["edges"] = [];
+ for (const [key] of nodes) {
+ const toCenter = center.get(key) ?? 0;
+ for (const child of childrenOf.get(key) ?? []) {
+ const fromColumn = columnOf(child);
+ if (fromColumn !== columnOf(key) - 1) continue;
+ edges.push({ fromColumn, fromCenter: center.get(child) ?? 0, toCenter });
+ }
+ }
+
+ return { columns, leafCount, edges };
+}
+
+/**
+ * The previous behaviour: one column per round, matches spread evenly, no edges.
+ * Used when a group's shape can't be resolved into a single tree.
+ */
+function fallbackLayout(
+ visibleRounds: string[],
+ matchesByRound: Map
+): BracketLayout {
+ const leafCount = Math.max(
+ ...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0),
+ 1
+ );
+ const columns = visibleRounds.map((round) => {
+ const matches = matchesByRound.get(round) ?? [];
+ const span = leafCount / Math.max(matches.length, 1);
+ return {
+ label: round,
+ matches: matches.map((match, i) => ({ match, center: (i + 0.5) * span })),
+ };
+ });
+ return { columns, leafCount, edges: [] };
+}
diff --git a/app/lib/llws-bracket.ts b/app/lib/llws-bracket.ts
new file mode 100644
index 0000000..ddaab70
--- /dev/null
+++ b/app/lib/llws-bracket.ts
@@ -0,0 +1,194 @@
+/**
+ * LLWS 20-team double-elimination routing — the pure half of the bracket.
+ *
+ * Lives in lib/ rather than models/ because the renderer needs it: models/playoff-match
+ * pulls in the database context and drizzle, which must not reach the browser bundle.
+ * models/playoff-match re-exports everything here, so server-side callers are unchanged.
+ */
+
+import { llwsMatchNumber, llwsSideAndLocal } from "~/lib/bracket-templates";
+
+/**
+ * Where one participant goes after an LLWS match: a round, a side-local match number,
+ * and which slot to fill. `null` means eliminated (or, for winners, no further game).
+ */
+interface LLWSDestination {
+ round: string;
+ localMatch: number;
+ slot: "participant1Id" | "participant2Id";
+}
+
+/**
+ * LLWS advancement map, in SIDE-LOCAL match numbers.
+ *
+ * Keyed by round, then by the local match number of the completed game. Each entry
+ * says where the winner goes and where the loser goes (null = eliminated).
+ *
+ * Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate
+ * cross-overs — the elimination bracket does NOT feed straight across:
+ * Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4)
+ * Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1)
+ * Elim R4: W(Elim R3 m1) v W(Elim R3 m2)
+ *
+ * A loss in the winners bracket routes into the elimination bracket rather than
+ * eliminating the team; a loss in the elimination bracket is final.
+ */
+const LLWS_ADVANCEMENT: Record<
+ string,
+ Record
+> = {
+ "Opening Round": {
+ 1: {
+ winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" },
+ loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" },
+ },
+ 2: {
+ winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" },
+ loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" },
+ },
+ 3: {
+ winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" },
+ loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" },
+ },
+ 4: {
+ winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" },
+ loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" },
+ },
+ },
+ "Winners Round 2": {
+ 1: {
+ winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" },
+ loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" },
+ },
+ 2: {
+ winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" },
+ loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" },
+ },
+ },
+ "Winners Semifinals": {
+ 1: {
+ winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" },
+ loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" },
+ },
+ 2: {
+ winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" },
+ loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" },
+ },
+ },
+ "Winners Final": {
+ 1: {
+ winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" },
+ // A winners-bracket final loss is not an elimination — it drops to the
+ // Elimination Final for a second chance at the side championship.
+ loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" },
+ },
+ },
+ "Elimination Round 1": {
+ 1: {
+ winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" },
+ loser: null,
+ },
+ 2: {
+ winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" },
+ loser: null,
+ },
+ },
+ "Elimination Round 2": {
+ // Cross-over: R2 m1's winner meets the OTHER semifinal loser.
+ 1: {
+ winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" },
+ loser: null,
+ },
+ 2: {
+ winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" },
+ loser: null,
+ },
+ },
+ "Elimination Round 3": {
+ // The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25.
+ 1: {
+ winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" },
+ loser: null,
+ },
+ 2: {
+ winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" },
+ loser: null,
+ },
+ },
+ "Elimination Round 4": {
+ 1: {
+ winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" },
+ loser: null,
+ },
+ },
+ "Elimination Final": {
+ 1: {
+ winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" },
+ loser: null,
+ },
+ },
+};
+
+/** Rounds whose losers drop into the elimination bracket instead of going out. */
+export const LLWS_LOSER_ADVANCES_ROUNDS = new Set([
+ "Opening Round",
+ "Winners Round 2",
+ "Winners Semifinals",
+]);
+
+/** A resolved LLWS destination, in global (not side-local) match numbers. */
+export interface LLWSResolvedDestination {
+ round: string;
+ matchNumber: number;
+ slot: "participant1Id" | "participant2Id";
+}
+
+/**
+ * Resolve where the winner and loser of a completed LLWS match go, in global match
+ * numbers. `null` means that participant has no further game (eliminated, or the
+ * tournament is over for them).
+ *
+ * Pure — no DB access — so the whole 38-game routing can be verified against the
+ * official bracket in tests. advanceLLWSWinner is a thin writer on top of this.
+ */
+export function resolveLLWSAdvancement(
+ round: string,
+ matchNumber: number
+): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } {
+ // Terminal rounds — nobody advances.
+ if (round === "Consolation Third Place" || round === "World Championship") {
+ return { winner: null, loser: null };
+ }
+
+ // Bracket Championship is the crossover: the winner goes to the World Championship
+ // and the loser to the Consolation game. The side fixes the slot in both (U.S. takes
+ // participant1, International participant2), so the two sides can't collide.
+ if (round === "Bracket Championship") {
+ const { side } = llwsSideAndLocal("Bracket Championship", matchNumber);
+ const slot: "participant1Id" | "participant2Id" =
+ side === 0 ? "participant1Id" : "participant2Id";
+ return {
+ winner: { round: "World Championship", matchNumber: 1, slot },
+ loser: { round: "Consolation Third Place", matchNumber: 1, slot },
+ };
+ }
+
+ const roundMap = LLWS_ADVANCEMENT[round];
+ if (!roundMap) {
+ throw new Error(`Round '${round}' is not part of the LLWS bracket`);
+ }
+
+ const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
+ const routes = roundMap[localMatch];
+ if (!routes) {
+ throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`);
+ }
+
+ // Winner and loser stay on their own side, so the same side offset applies to both.
+ const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null =>
+ d === null
+ ? null
+ : { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot };
+
+ return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) };
+}
diff --git a/app/models/__tests__/llws-20-bracket.test.ts b/app/models/__tests__/llws-20-bracket.test.ts
index d489ac3..11a7ba2 100644
--- a/app/models/__tests__/llws-20-bracket.test.ts
+++ b/app/models/__tests__/llws-20-bracket.test.ts
@@ -27,6 +27,13 @@ import {
calculateAveragedPoints,
type ScoringRules,
} from "../scoring-rules";
+import {
+ GAME_TO_MATCH,
+ EXPECTED_SLOTS,
+ gameNumberFor,
+ required,
+ destinationGame,
+} from "~/test/fixtures/llws-bracket";
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
// minimal stub is enough to capture the generated rows.
@@ -55,121 +62,6 @@ const DEFAULT_SCORING: ScoringRules = {
pointsFor8th: 10,
};
-// ── PDF game number ↔ (round, match number) ──────────────────────────────────
-//
-// Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match
-// numbers in each round, International the high ones.
-const GAME_TO_MATCH: Record = {
- // Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8)
- 2: { round: "Opening Round", matchNumber: 1 },
- 4: { round: "Opening Round", matchNumber: 2 },
- 6: { round: "Opening Round", matchNumber: 3 },
- 8: { round: "Opening Round", matchNumber: 4 },
- 1: { round: "Opening Round", matchNumber: 5 },
- 3: { round: "Opening Round", matchNumber: 6 },
- 5: { round: "Opening Round", matchNumber: 7 },
- 7: { round: "Opening Round", matchNumber: 8 },
- // Winners Round 2 — U.S. G10,12; Intl G9,11
- 10: { round: "Winners Round 2", matchNumber: 1 },
- 12: { round: "Winners Round 2", matchNumber: 2 },
- 9: { round: "Winners Round 2", matchNumber: 3 },
- 11: { round: "Winners Round 2", matchNumber: 4 },
- // Elimination Round 1 — U.S. G14,16; Intl G13,15
- 14: { round: "Elimination Round 1", matchNumber: 1 },
- 16: { round: "Elimination Round 1", matchNumber: 2 },
- 13: { round: "Elimination Round 1", matchNumber: 3 },
- 15: { round: "Elimination Round 1", matchNumber: 4 },
- // Winners Semifinals — U.S. G17,19; Intl G18,20
- 17: { round: "Winners Semifinals", matchNumber: 1 },
- 19: { round: "Winners Semifinals", matchNumber: 2 },
- 18: { round: "Winners Semifinals", matchNumber: 3 },
- 20: { round: "Winners Semifinals", matchNumber: 4 },
- // Elimination Round 2 — U.S. G22,24; Intl G21,23
- 22: { round: "Elimination Round 2", matchNumber: 1 },
- 24: { round: "Elimination Round 2", matchNumber: 2 },
- 21: { round: "Elimination Round 2", matchNumber: 3 },
- 23: { round: "Elimination Round 2", matchNumber: 4 },
- // Elimination Round 3 — U.S. G26,28; Intl G25,27
- 26: { round: "Elimination Round 3", matchNumber: 1 },
- 28: { round: "Elimination Round 3", matchNumber: 2 },
- 25: { round: "Elimination Round 3", matchNumber: 3 },
- 27: { round: "Elimination Round 3", matchNumber: 4 },
- // Winners Final — U.S. G30; Intl G29
- 30: { round: "Winners Final", matchNumber: 1 },
- 29: { round: "Winners Final", matchNumber: 2 },
- // Elimination Round 4 — U.S. G32; Intl G31
- 32: { round: "Elimination Round 4", matchNumber: 1 },
- 31: { round: "Elimination Round 4", matchNumber: 2 },
- // Elimination Final — U.S. G34; Intl G33
- 34: { round: "Elimination Final", matchNumber: 1 },
- 33: { round: "Elimination Final", matchNumber: 2 },
- // Bracket Championship — U.S. G36; Intl G35
- 36: { round: "Bracket Championship", matchNumber: 1 },
- 35: { round: "Bracket Championship", matchNumber: 2 },
- // Finals
- 37: { round: "Consolation Third Place", matchNumber: 1 },
- 38: { round: "World Championship", matchNumber: 1 },
-};
-
-const MATCH_TO_GAME = new Map(
- Object.entries(GAME_TO_MATCH).map(([game, m]) => [
- `${m.round}#${m.matchNumber}`,
- Number(game),
- ])
-);
-
-function gameNumberFor(round: string, matchNumber: number): number {
- const game = MATCH_TO_GAME.get(`${round}#${matchNumber}`);
- if (game === undefined) throw new Error(`No PDF game for ${round} #${matchNumber}`);
- return game;
-}
-
-/** Narrows a destination that the test expects to exist. */
-function required(destination: T | null): T {
- if (destination === null) throw new Error("Expected a destination, got null");
- return destination;
-}
-
-/** PDF game number a destination points at. */
-function destinationGame(
- destination: { round: string; matchNumber: number } | null
-): number {
- const d = required(destination);
- return gameNumberFor(d.round, d.matchNumber);
-}
-
-/**
- * The official bracket printed as feed labels: for each game, which prior game's
- * winner (W) or loser (L) fills each slot. `null` = a team seeded in directly.
- *
- * Transcribed from the PDF. This is the source of truth the routing must reproduce.
- */
-const EXPECTED_SLOTS: Record = {
- // Opening Round — all directly seeded
- 1: [null, null], 2: [null, null], 3: [null, null], 4: [null, null],
- 5: [null, null], 6: [null, null], 7: [null, null], 8: [null, null],
- // Winners Round 2 — bye team, then an Opening Round winner
- 9: [null, "W1"], 10: [null, "W2"], 11: [null, "W3"], 12: [null, "W4"],
- // Elimination Round 1
- 13: ["L3", "L5"], 14: ["L4", "L6"], 15: ["L1", "L7"], 16: ["L2", "L8"],
- // Winners Semifinals
- 17: ["W6", "W10"], 18: ["W5", "W9"], 19: ["W12", "W8"], 20: ["W11", "W7"],
- // Elimination Round 2
- 21: ["L9", "W13"], 22: ["L10", "W14"], 23: ["L11", "W15"], 24: ["L12", "W16"],
- // Elimination Round 3 — cross-over
- 25: ["L18", "W23"], 26: ["L17", "W24"], 27: ["L20", "W21"], 28: ["L19", "W22"],
- // Winners Final
- 29: ["W18", "W20"], 30: ["W17", "W19"],
- // Elimination Round 4
- 31: ["W27", "W25"], 32: ["W28", "W26"],
- // Elimination Final
- 33: ["L29", "W31"], 34: ["L30", "W32"],
- // Bracket Championship
- 35: ["W29", "W33"], 36: ["W30", "W34"],
- // Finals
- 37: ["L36", "L35"], 38: ["W36", "W35"],
-};
-
describe("LLWS 20 Bracket Template", () => {
describe("Template structure", () => {
it("has correct identity and size", () => {
diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts
index 101daad..440f326 100644
--- a/app/models/playoff-match.ts
+++ b/app/models/playoff-match.ts
@@ -10,6 +10,11 @@ import {
llwsSideAndLocal,
STANDARD_BRACKET_SEEDING,
} from "~/lib/bracket-templates";
+import {
+ LLWS_LOSER_ADVANCES_ROUNDS,
+ resolveLLWSAdvancement,
+ type LLWSResolvedDestination,
+} from "~/lib/llws-bracket";
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
@@ -1563,190 +1568,10 @@ async function advanceNBAPlayInWinner(
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
-/**
- * Where one participant goes after an LLWS match: a round, a side-local match number,
- * and which slot to fill. `null` means eliminated (or, for winners, no further game).
- */
-interface LLWSDestination {
- round: string;
- localMatch: number;
- slot: "participant1Id" | "participant2Id";
-}
-
-/**
- * LLWS advancement map, in SIDE-LOCAL match numbers.
- *
- * Keyed by round, then by the local match number of the completed game. Each entry
- * says where the winner goes and where the loser goes (null = eliminated).
- *
- * Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate
- * cross-overs — the elimination bracket does NOT feed straight across:
- * Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4)
- * Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1)
- * Elim R4: W(Elim R3 m1) v W(Elim R3 m2)
- *
- * A loss in the winners bracket routes into the elimination bracket rather than
- * eliminating the team; a loss in the elimination bracket is final.
- */
-const LLWS_ADVANCEMENT: Record<
- string,
- Record
-> = {
- "Opening Round": {
- 1: {
- winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" },
- loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" },
- },
- 2: {
- winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" },
- loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" },
- },
- 3: {
- winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" },
- loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" },
- },
- 4: {
- winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" },
- loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" },
- },
- },
- "Winners Round 2": {
- 1: {
- winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" },
- loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" },
- },
- 2: {
- winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" },
- loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" },
- },
- },
- "Winners Semifinals": {
- 1: {
- winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" },
- loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" },
- },
- 2: {
- winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" },
- loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" },
- },
- },
- "Winners Final": {
- 1: {
- winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" },
- // A winners-bracket final loss is not an elimination — it drops to the
- // Elimination Final for a second chance at the side championship.
- loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" },
- },
- },
- "Elimination Round 1": {
- 1: {
- winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" },
- loser: null,
- },
- 2: {
- winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" },
- loser: null,
- },
- },
- "Elimination Round 2": {
- // Cross-over: R2 m1's winner meets the OTHER semifinal loser.
- 1: {
- winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" },
- loser: null,
- },
- 2: {
- winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" },
- loser: null,
- },
- },
- "Elimination Round 3": {
- // The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25.
- 1: {
- winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" },
- loser: null,
- },
- 2: {
- winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" },
- loser: null,
- },
- },
- "Elimination Round 4": {
- 1: {
- winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" },
- loser: null,
- },
- },
- "Elimination Final": {
- 1: {
- winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" },
- loser: null,
- },
- },
-};
-
-/** Rounds whose losers drop into the elimination bracket instead of going out. */
-const LLWS_LOSER_ADVANCES_ROUNDS = new Set([
- "Opening Round",
- "Winners Round 2",
- "Winners Semifinals",
-]);
-
-/** A resolved LLWS destination, in global (not side-local) match numbers. */
-export interface LLWSResolvedDestination {
- round: string;
- matchNumber: number;
- slot: "participant1Id" | "participant2Id";
-}
-
-/**
- * Resolve where the winner and loser of a completed LLWS match go, in global match
- * numbers. `null` means that participant has no further game (eliminated, or the
- * tournament is over for them).
- *
- * Pure — no DB access — so the whole 38-game routing can be verified against the
- * official bracket in tests. advanceLLWSWinner is a thin writer on top of this.
- */
-export function resolveLLWSAdvancement(
- round: string,
- matchNumber: number
-): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } {
- // Terminal rounds — nobody advances.
- if (round === "Consolation Third Place" || round === "World Championship") {
- return { winner: null, loser: null };
- }
-
- // Bracket Championship is the crossover: the winner goes to the World Championship
- // and the loser to the Consolation game. The side fixes the slot in both (U.S. takes
- // participant1, International participant2), so the two sides can't collide.
- if (round === "Bracket Championship") {
- const { side } = llwsSideAndLocal("Bracket Championship", matchNumber);
- const slot: "participant1Id" | "participant2Id" =
- side === 0 ? "participant1Id" : "participant2Id";
- return {
- winner: { round: "World Championship", matchNumber: 1, slot },
- loser: { round: "Consolation Third Place", matchNumber: 1, slot },
- };
- }
-
- const roundMap = LLWS_ADVANCEMENT[round];
- if (!roundMap) {
- throw new Error(`Round '${round}' is not part of the LLWS bracket`);
- }
-
- const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
- const routes = roundMap[localMatch];
- if (!routes) {
- throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`);
- }
-
- // Winner and loser stay on their own side, so the same side offset applies to both.
- const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null =>
- d === null
- ? null
- : { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot };
-
- return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) };
-}
+// The routing table itself is pure and lives in lib/ so the renderer can import it
+// without pulling the database context into the browser bundle. Re-exported here so
+// existing server-side callers and tests keep their import path.
+export { LLWS_LOSER_ADVANCES_ROUNDS, resolveLLWSAdvancement, type LLWSResolvedDestination };
/**
* Generate the 20-team LLWS double-elimination bracket (38 matches).
diff --git a/app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts b/app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts
new file mode 100644
index 0000000..4fb255a
--- /dev/null
+++ b/app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts
@@ -0,0 +1,118 @@
+/**
+ * clear-bracket is the only path that can tear down a bracket, so the guard around it
+ * matters: it discards recorded results and the placements derived from them.
+ */
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ findPlayoffMatchesByEventId,
+ deletePlayoffMatchesByEventId,
+} from "~/models/playoff-match";
+import { deleteParticipantResultsBySportsSeasonId } from "~/models/participant-result";
+import { recalculateAffectedLeagues } from "~/models/scoring-calculator";
+import { getScoringEventById } from "~/models/scoring-event";
+import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
+
+vi.mock("~/models/scoring-event", async (importOriginal) => ({
+ ...(await importOriginal()),
+ getScoringEventById: vi.fn(),
+ updateScoringEvent: vi.fn(),
+ isReadOnlySibling: vi.fn(() => false),
+}));
+vi.mock("~/models/playoff-match", async (importOriginal) => ({
+ ...(await importOriginal()),
+ findPlayoffMatchesByEventId: vi.fn(),
+ deletePlayoffMatchesByEventId: vi.fn(),
+}));
+vi.mock("~/models/participant-result", async (importOriginal) => ({
+ ...(await importOriginal()),
+ deleteParticipantResultsBySportsSeasonId: vi.fn(),
+}));
+vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
+ ...(await importOriginal()),
+ recalculateAffectedLeagues: vi.fn(),
+}));
+
+const EVENT = { id: "event-1", sportsSeasonId: "season-1" };
+const params = { id: "season-1", eventId: "event-1" };
+
+function clearRequest(confirm?: string): Request {
+ const body = new FormData();
+ body.set("intent", "clear-bracket");
+ if (confirm !== undefined) body.set("confirm", confirm);
+ return new Request("http://localhost/clear", { method: "POST", body });
+}
+
+function match(isComplete: boolean) {
+ return { id: `m-${Math.random()}`, isComplete };
+}
+
+// The action's real signature carries React Router's generated types; the clear path
+// only reads request and params.
+const run = (request: Request) =>
+ (action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
+ error?: string;
+ success?: string;
+ }>)({ request, params });
+
+describe("clear-bracket", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getScoringEventById).mockResolvedValue(
+ EVENT as unknown as Awaited>
+ );
+ vi.mocked(deletePlayoffMatchesByEventId).mockResolvedValue(undefined);
+ vi.mocked(deleteParticipantResultsBySportsSeasonId).mockResolvedValue(undefined);
+ vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
+ undefined as unknown as Awaited>
+ );
+ });
+
+ it("deletes the matches and the placements derived from them", async () => {
+ vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
+ match(false),
+ match(false),
+ ] as unknown as Awaited>);
+
+ const result = await run(clearRequest());
+
+ expect(result.success).toContain("2 match(es) removed");
+ expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
+ expect(deleteParticipantResultsBySportsSeasonId).toHaveBeenCalledWith("season-1");
+ });
+
+ it("refuses to discard completed matches without confirmation", async () => {
+ vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
+ match(true),
+ match(false),
+ ] as unknown as Awaited>);
+
+ const result = await run(clearRequest());
+
+ expect(result.error).toContain("1 completed match(es)");
+ expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
+ expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
+ });
+
+ it("discards completed matches once confirmed", async () => {
+ vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
+ match(true),
+ ] as unknown as Awaited>);
+
+ const result = await run(clearRequest("true"));
+
+ expect(result.success).toBeDefined();
+ expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
+ });
+
+ it("rejects an event with no bracket rather than reporting a no-op success", async () => {
+ vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
+ [] as unknown as Awaited>
+ );
+
+ const result = await run(clearRequest("true"));
+
+ expect(result.error).toContain("no bracket to clear");
+ expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts
index 56773d7..a91a3db 100644
--- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts
+++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts
@@ -9,6 +9,7 @@ import {
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import {
findPlayoffMatchesByEventId,
+ deletePlayoffMatchesByEventId,
generateBracketFromTemplate,
setMatchWinner,
advanceWinnerTemplate,
@@ -288,6 +289,43 @@ export async function action({ request, params }: Route.ActionArgs) {
}
}
+ // The only way to repair a mis-seeded bracket: nothing else can rewrite a match's
+ // participants. Clearing brings back the setup form, so the admin re-seeds from there.
+ if (intent === "clear-bracket") {
+ try {
+ const event = await getScoringEventById(params.eventId);
+ if (!event) return { error: "Event not found" };
+
+ const existing = await findPlayoffMatchesByEventId(params.eventId);
+ if (existing.length === 0) {
+ return { error: "This event has no bracket to clear" };
+ }
+ // Clearing discards recorded results, so make the admin confirm once games have
+ // actually been played.
+ const completed = existing.filter((m) => m.isComplete).length;
+ if (completed > 0 && formData.get("confirm") !== "true") {
+ return {
+ error: `This bracket has ${completed} completed match(es). Confirm to discard those results.`,
+ };
+ }
+
+ await deletePlayoffMatchesByEventId(params.eventId);
+ // Placements were derived from the matches just deleted; leaving them behind would
+ // keep stale points on the standings.
+ await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId);
+ await recalculateAffectedLeagues(event.sportsSeasonId, database(), { skipDiscord: true });
+
+ return {
+ success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.`,
+ };
+ } catch (error) {
+ logger.error("Error clearing bracket:", error);
+ return {
+ error: error instanceof Error ? error.message : "Failed to clear bracket",
+ };
+ }
+ }
+
if (intent === "generate-bracket") {
const templateId = formData.get("templateId");
diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx
index 154ba39..7be75fa 100644
--- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx
+++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx
@@ -613,6 +613,42 @@ export default function EventBracket({
)}
+ {/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else
+ can rewrite a match's participants, so a wrong seeding has to be torn down
+ and rebuilt via the setup form below, which reappears once this runs. */}
+ {matches.length > 0 && (
+
+
+ Clear Bracket
+
+ Delete every match in this bracket so it can be set up again from
+ scratch. Use this when the wrong participants were seeded. This also
+ clears the season's placements and the points derived from them.
+
+
+
+
+
+
+ )}
+
{/* ====== SETUP PHASE ====== */}
{showSetup && (
diff --git a/app/test/fixtures/llws-bracket.ts b/app/test/fixtures/llws-bracket.ts
new file mode 100644
index 0000000..6d0e254
--- /dev/null
+++ b/app/test/fixtures/llws-bracket.ts
@@ -0,0 +1,122 @@
+/**
+ * The official 2026 LLBWS bracket (Williamsport, Aug 19–30), transcribed from the PDF.
+ *
+ * The printed bracket numbers its games 1–38. Both the routing tests and the layout
+ * tests check themselves against these numbers, so the transcription lives here rather
+ * than in either one.
+ */
+
+// ── PDF game number ↔ (round, match number) ──────────────────────────────────
+//
+// Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match
+// numbers in each round, International the high ones.
+export const GAME_TO_MATCH: Record = {
+ // Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8)
+ 2: { round: "Opening Round", matchNumber: 1 },
+ 4: { round: "Opening Round", matchNumber: 2 },
+ 6: { round: "Opening Round", matchNumber: 3 },
+ 8: { round: "Opening Round", matchNumber: 4 },
+ 1: { round: "Opening Round", matchNumber: 5 },
+ 3: { round: "Opening Round", matchNumber: 6 },
+ 5: { round: "Opening Round", matchNumber: 7 },
+ 7: { round: "Opening Round", matchNumber: 8 },
+ // Winners Round 2 — U.S. G10,12; Intl G9,11
+ 10: { round: "Winners Round 2", matchNumber: 1 },
+ 12: { round: "Winners Round 2", matchNumber: 2 },
+ 9: { round: "Winners Round 2", matchNumber: 3 },
+ 11: { round: "Winners Round 2", matchNumber: 4 },
+ // Elimination Round 1 — U.S. G14,16; Intl G13,15
+ 14: { round: "Elimination Round 1", matchNumber: 1 },
+ 16: { round: "Elimination Round 1", matchNumber: 2 },
+ 13: { round: "Elimination Round 1", matchNumber: 3 },
+ 15: { round: "Elimination Round 1", matchNumber: 4 },
+ // Winners Semifinals — U.S. G17,19; Intl G18,20
+ 17: { round: "Winners Semifinals", matchNumber: 1 },
+ 19: { round: "Winners Semifinals", matchNumber: 2 },
+ 18: { round: "Winners Semifinals", matchNumber: 3 },
+ 20: { round: "Winners Semifinals", matchNumber: 4 },
+ // Elimination Round 2 — U.S. G22,24; Intl G21,23
+ 22: { round: "Elimination Round 2", matchNumber: 1 },
+ 24: { round: "Elimination Round 2", matchNumber: 2 },
+ 21: { round: "Elimination Round 2", matchNumber: 3 },
+ 23: { round: "Elimination Round 2", matchNumber: 4 },
+ // Elimination Round 3 — U.S. G26,28; Intl G25,27
+ 26: { round: "Elimination Round 3", matchNumber: 1 },
+ 28: { round: "Elimination Round 3", matchNumber: 2 },
+ 25: { round: "Elimination Round 3", matchNumber: 3 },
+ 27: { round: "Elimination Round 3", matchNumber: 4 },
+ // Winners Final — U.S. G30; Intl G29
+ 30: { round: "Winners Final", matchNumber: 1 },
+ 29: { round: "Winners Final", matchNumber: 2 },
+ // Elimination Round 4 — U.S. G32; Intl G31
+ 32: { round: "Elimination Round 4", matchNumber: 1 },
+ 31: { round: "Elimination Round 4", matchNumber: 2 },
+ // Elimination Final — U.S. G34; Intl G33
+ 34: { round: "Elimination Final", matchNumber: 1 },
+ 33: { round: "Elimination Final", matchNumber: 2 },
+ // Bracket Championship — U.S. G36; Intl G35
+ 36: { round: "Bracket Championship", matchNumber: 1 },
+ 35: { round: "Bracket Championship", matchNumber: 2 },
+ // Finals
+ 37: { round: "Consolation Third Place", matchNumber: 1 },
+ 38: { round: "World Championship", matchNumber: 1 },
+};
+
+export const MATCH_TO_GAME = new Map(
+ Object.entries(GAME_TO_MATCH).map(([game, m]) => [
+ `${m.round}#${m.matchNumber}`,
+ Number(game),
+ ])
+);
+
+export function gameNumberFor(round: string, matchNumber: number): number {
+ const game = MATCH_TO_GAME.get(`${round}#${matchNumber}`);
+ if (game === undefined) throw new Error(`No PDF game for ${round} #${matchNumber}`);
+ return game;
+}
+
+/** Narrows a destination that the test expects to exist. */
+export function required(destination: T | null): T {
+ if (destination === null) throw new Error("Expected a destination, got null");
+ return destination;
+}
+
+/** PDF game number a destination points at. */
+export function destinationGame(
+ destination: { round: string; matchNumber: number } | null
+): number {
+ const d = required(destination);
+ return gameNumberFor(d.round, d.matchNumber);
+}
+
+/**
+ * The official bracket printed as feed labels: for each game, which prior game's
+ * winner (W) or loser (L) fills each slot. `null` = a team seeded in directly.
+ *
+ * Transcribed from the PDF. This is the source of truth the routing must reproduce.
+ */
+export const EXPECTED_SLOTS: Record = {
+ // Opening Round — all directly seeded
+ 1: [null, null], 2: [null, null], 3: [null, null], 4: [null, null],
+ 5: [null, null], 6: [null, null], 7: [null, null], 8: [null, null],
+ // Winners Round 2 — bye team, then an Opening Round winner
+ 9: [null, "W1"], 10: [null, "W2"], 11: [null, "W3"], 12: [null, "W4"],
+ // Elimination Round 1
+ 13: ["L3", "L5"], 14: ["L4", "L6"], 15: ["L1", "L7"], 16: ["L2", "L8"],
+ // Winners Semifinals
+ 17: ["W6", "W10"], 18: ["W5", "W9"], 19: ["W12", "W8"], 20: ["W11", "W7"],
+ // Elimination Round 2
+ 21: ["L9", "W13"], 22: ["L10", "W14"], 23: ["L11", "W15"], 24: ["L12", "W16"],
+ // Elimination Round 3 — cross-over
+ 25: ["L18", "W23"], 26: ["L17", "W24"], 27: ["L20", "W21"], 28: ["L19", "W22"],
+ // Winners Final
+ 29: ["W18", "W20"], 30: ["W17", "W19"],
+ // Elimination Round 4
+ 31: ["W27", "W25"], 32: ["W28", "W26"],
+ // Elimination Final
+ 33: ["L29", "W31"], 34: ["L30", "W32"],
+ // Bracket Championship
+ 35: ["W29", "W33"], 36: ["W30", "W34"],
+ // Finals
+ 37: ["L36", "L35"], 38: ["W36", "W35"],
+};
diff --git a/tsconfig.node.json b/tsconfig.node.json
index 549fec1..6f814ca 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -7,6 +7,7 @@
"app/models/**/*.ts",
"app/services/**/*.ts",
"app/lib/**/*.ts",
+ "app/test/fixtures/**/*.ts",
"app/types/**/*.ts",
"vite.config.ts"
],