The Little League Baseball World Series runs two independent 10-team double-elimination brackets — United States and International — each producing a side champion, then a World Championship game and a Consolation Third Place game between the side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
256 lines
9.3 KiB
TypeScript
256 lines
9.3 KiB
TypeScript
import { cn } from "~/lib/utils";
|
|
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
|
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
|
import { BracketTreePaginated } from "./BracketTreePaginated";
|
|
|
|
interface TabbedBracketLayoutProps {
|
|
rounds: string[];
|
|
matchesByRound: Map<string, BracketMatch[]>;
|
|
ownershipMap: Map<string, BracketOwnership>;
|
|
userParticipantIds: Set<string>;
|
|
phases: BracketPhase[];
|
|
scoringRoundIdx: number;
|
|
}
|
|
|
|
const CARD_H = 112;
|
|
const CARD_GAP = 14;
|
|
|
|
function groupMatches(
|
|
matchesByRound: Map<string, BracketMatch[]>,
|
|
group: ConferenceGroup
|
|
): Map<string, BracketMatch[]> {
|
|
const out = new Map<string, BracketMatch[]>();
|
|
for (const [round, allowed] of Object.entries(group.roundMatchNumbers)) {
|
|
const filtered = (matchesByRound.get(round) ?? []).filter((m) =>
|
|
allowed.includes(m.matchNumber)
|
|
);
|
|
if (filtered.length > 0) out.set(round, filtered);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
|
|
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
|
return max * (CARD_H + CARD_GAP);
|
|
}
|
|
|
|
// ─── Play-In Layout ───────────────────────────────────────────────────────────
|
|
|
|
interface PlayInColumnProps {
|
|
label: string;
|
|
description: string;
|
|
match: BracketMatch | undefined;
|
|
ownershipMap: Map<string, BracketOwnership>;
|
|
userParticipantIds: Set<string>;
|
|
}
|
|
|
|
function PlayInColumn({
|
|
label,
|
|
description,
|
|
match,
|
|
ownershipMap,
|
|
userParticipantIds,
|
|
}: PlayInColumnProps) {
|
|
return (
|
|
<div>
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center mb-2">
|
|
{label}
|
|
</p>
|
|
{match ? (
|
|
<BracketMatchSlot
|
|
match={match}
|
|
slotHeight={CARD_H}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
/>
|
|
) : (
|
|
<div style={{ height: CARD_H }} />
|
|
)}
|
|
<p className="text-[10px] text-muted-foreground text-center mt-2">{description}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface PlayInLayoutProps {
|
|
matchesByRound: Map<string, BracketMatch[]>;
|
|
ownershipMap: Map<string, BracketOwnership>;
|
|
userParticipantIds: Set<string>;
|
|
}
|
|
|
|
function PlayInLayout({ matchesByRound, ownershipMap, userParticipantIds }: PlayInLayoutProps) {
|
|
const pir1 = matchesByRound.get("Play-In Round 1") ?? [];
|
|
const pir2 = matchesByRound.get("Play-In Round 2") ?? [];
|
|
|
|
const conferences = [
|
|
{
|
|
name: "Eastern Conference",
|
|
match78: pir1.find((m) => m.matchNumber === 1),
|
|
match910: pir1.find((m) => m.matchNumber === 2),
|
|
matchFor8: pir2.find((m) => m.matchNumber === 1),
|
|
},
|
|
{
|
|
name: "Western Conference",
|
|
match78: pir1.find((m) => m.matchNumber === 3),
|
|
match910: pir1.find((m) => m.matchNumber === 4),
|
|
matchFor8: pir2.find((m) => m.matchNumber === 2),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{conferences.map((conf) => (
|
|
<div key={conf.name}>
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
|
|
{conf.name}
|
|
</p>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<PlayInColumn
|
|
label="7 VS 8"
|
|
description="Winner → 7 seed · Loser plays again"
|
|
match={conf.match78}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
/>
|
|
<PlayInColumn
|
|
label="9 VS 10"
|
|
description="Winner plays again · Loser eliminated"
|
|
match={conf.match910}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
/>
|
|
<PlayInColumn
|
|
label="For 8 Seed"
|
|
description="Winner → 8 seed · Loser eliminated"
|
|
match={conf.matchFor8}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main component ───────────────────────────────────────────────────────────
|
|
|
|
export function TabbedBracketLayout({
|
|
rounds,
|
|
matchesByRound,
|
|
ownershipMap,
|
|
userParticipantIds,
|
|
phases,
|
|
scoringRoundIdx,
|
|
}: TabbedBracketLayoutProps) {
|
|
return (
|
|
<div className="space-y-10">
|
|
{phases.map((phase) => {
|
|
const groupRounds = phase.groups
|
|
? rounds.filter((r) => phase.groups?.some((g) => g.roundMatchNumbers[r] !== undefined))
|
|
: [];
|
|
const sharedRounds = (phase.sharedRounds ?? []).filter((r) => rounds.includes(r));
|
|
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
|
|
|
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
|
|
// Restrict each round to the match numbers this phase's groups actually claim.
|
|
// Rounds can be shared across phases (LLWS runs U.S. and International through
|
|
// the same rounds), so without this the mobile view would merge both sides into
|
|
// one column. No-op where a phase's groups already cover every match in the
|
|
// round (NCAA regions, NBA conferences) and for sharedRounds, which have no
|
|
// group filter.
|
|
const phaseMatchesByRound = new Map(
|
|
phaseRounds.map((r) => {
|
|
const all = matchesByRound.get(r) ?? [];
|
|
if (!phase.groups || sharedRounds.includes(r)) return [r, all] as const;
|
|
const allowed = new Set(
|
|
phase.groups.flatMap((g) => g.roundMatchNumbers[r] ?? [])
|
|
);
|
|
return [r, allowed.size > 0 ? all.filter((m) => allowed.has(m.matchNumber)) : all] as const;
|
|
})
|
|
);
|
|
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
|
|
|
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
|
|
|
return (
|
|
<div key={phase.name}>
|
|
<p className={cn(
|
|
"text-sm font-semibold uppercase tracking-wider mb-4",
|
|
phases.length > 1 ? "text-muted-foreground" : "hidden"
|
|
)}>
|
|
{phase.name}
|
|
</p>
|
|
|
|
{/* Desktop */}
|
|
<div className="hidden md:block">
|
|
{phase.layout === "play-in" ? (
|
|
<PlayInLayout
|
|
matchesByRound={phaseMatchesByRound}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
/>
|
|
) : phase.groups ? (
|
|
<div className="space-y-8">
|
|
{phase.groups.map((group) => {
|
|
const gMatches = groupMatches(matchesByRound, group);
|
|
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
|
return (
|
|
<div key={group.name}>
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
|
{group.name}
|
|
</p>
|
|
<TreeColumns
|
|
visibleRounds={gRounds}
|
|
matchesByRound={gMatches}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
bracketHeight={phaseHeight(gMatches, gRounds)}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
{sharedRounds.length > 0 && (
|
|
<TreeColumns
|
|
visibleRounds={sharedRounds}
|
|
matchesByRound={sharedMatchesByRound}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<TreeColumns
|
|
visibleRounds={phaseRounds}
|
|
matchesByRound={phaseMatchesByRound}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mobile */}
|
|
<div className="md:hidden">
|
|
{phase.layout === "play-in" ? (
|
|
<PlayInLayout
|
|
matchesByRound={phaseMatchesByRound}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
/>
|
|
) : (
|
|
<BracketTreePaginated
|
|
rounds={phaseRounds}
|
|
matchesByRound={phaseMatchesByRound}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|