brackt/app/lib/llws-bracket.ts
Claude 89ceee432a
Lay out brackets from the feeder graph
The LLWS bracket didn't read as a bracket: cards sat above games that
don't feed them, connectors joined the wrong pairs, and several games had
no line at all.

The stored data was correct — LLWS_ADVANCEMENT already matches the
official 2026 LLBWS bracket game for game. The renderer was the problem.
TreeColumns placed cards at `index * (height / roundSize)` and
ConnectorColumn assumed matches 2k and 2k+1 feed match k, which holds
only for an exact halving. The LLWS winners bracket is not one: two of
the four Opening Round games skip Winners Round 2 and go straight to the
semifinals, so those two got stranded in column one with nothing beside
them, and the halving branch drew confident, wrong connectors for the
rest.

Lay out from the graph instead. app/lib/bracket-layout.ts inverts a
template's advancement into "what fills each slot", then assigns columns
by depth from the group's final, orders each column by the parent's slot
order, and centres each card on its feeders. Counting back from the final
is what makes a printed bracket line up: a team entering late is drawn in
the column where it actually plays. This reproduces the official
International bracket exactly, and fixes Elimination Round 3, where the
official bracket prints the later game on top but match-number sort put
it below.

Because column is depth, every in-group edge spans exactly one gutter, so
connectors now draw for unplayed games too. Cards also take a fixed
height rather than stretching to fill their column, which is what made a
lone final tower over the rest.

Empty slots name their source — "Loser of Winners SF 1" rather than
"TBD". That is the only way to show the feeds crossing between the
winners and elimination brackets, which render as separate trees.

Also:
- Move the LLWS routing table to app/lib/llws-bracket.ts so the renderer
  can import it without pulling the database context into the browser
  bundle; models/playoff-match re-exports it.
- Page the mobile view one group at a time, matching desktop. A whole
  double-elimination phase is a DAG, not a tree, so its columns would be
  arbitrary.
- Add a clear-bracket admin action. Nothing else could rewrite a match's
  participants, so a mis-seeded bracket had no repair path at all.
- Lift the PDF transcription into app/test/fixtures/llws-bracket.ts so the
  routing and layout tests check against one copy of the official bracket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 17:50:59 +00:00

194 lines
7.2 KiB
TypeScript

/**
* 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<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
> = {
"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) };
}