Compare commits
11 commits
a143df51f6
...
75960a8826
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75960a8826 | ||
|
|
c48d54d873 | ||
|
|
75798d9711 | ||
|
|
430526104c | ||
| 3c7272392e | |||
|
|
3fae78c521 | ||
|
|
d4df0b65fb | ||
| 2356e37163 | |||
|
|
569081fe29 | ||
|
|
89ceee432a | ||
| 8edb4293c5 |
28 changed files with 3687 additions and 801 deletions
|
|
@ -1,13 +1,16 @@
|
|||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useRoundTransition } from "~/hooks/useRoundTransition";
|
||||
import type { FeederMap } from "~/lib/bracket-layout";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
import {
|
||||
TreeColumns,
|
||||
BracketMatchSlot,
|
||||
bracketGeometry,
|
||||
windowGeometry,
|
||||
SLOT_WIDTH,
|
||||
LABEL_HEIGHT,
|
||||
DESIRED_CARD_HEIGHT,
|
||||
CARD_GAP,
|
||||
MAX_CARD_HEIGHT,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
|
|
@ -21,6 +24,8 @@ interface BracketTreePaginatedProps {
|
|||
/** Index of the first scoring round — default page starts here */
|
||||
firstScoringRoundIdx?: number;
|
||||
thirdPlaceRound?: string;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketTreePaginated({
|
||||
|
|
@ -30,63 +35,68 @@ export function BracketTreePaginated({
|
|||
userParticipantIds,
|
||||
firstScoringRoundIdx,
|
||||
thirdPlaceRound,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketTreePaginatedProps) {
|
||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||
|
||||
// Pages are pairs of layout columns, not pairs of rounds: a column can mix rounds
|
||||
// when teams enter the bracket at different points (see computeGroupLayout).
|
||||
const geometry = bracketGeometry(
|
||||
mainRounds,
|
||||
matchesByRound,
|
||||
feeders,
|
||||
template?.rounds.map((r) => r.name) ?? mainRounds
|
||||
);
|
||||
const columns = geometry.layout.columns;
|
||||
const lastPage = Math.max(columns.length - 2, 0);
|
||||
|
||||
const defaultPage = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
firstScoringRoundIdx !== undefined
|
||||
? Math.max(0, firstScoringRoundIdx - 1)
|
||||
: mainRounds.length - 2,
|
||||
mainRounds.length - 2,
|
||||
firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : lastPage,
|
||||
lastPage,
|
||||
),
|
||||
);
|
||||
|
||||
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
||||
mainRounds.length - 2,
|
||||
lastPage,
|
||||
defaultPage,
|
||||
);
|
||||
|
||||
const targetPage = anim ? anim.toPage : page;
|
||||
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
|
||||
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
||||
|
||||
const calcHeight = (p: number) => {
|
||||
const rs = mainRounds.slice(p, p + 2);
|
||||
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
const pageGeometry = (p: number) => windowGeometry(geometry, p, p + 1);
|
||||
const labelFor = (p: number) => {
|
||||
const [a, b] = [columns[p]?.label, columns[p + 1]?.label];
|
||||
return b ? `${a} → ${b}` : (a ?? "");
|
||||
};
|
||||
|
||||
const pageHeight = calcHeight(page);
|
||||
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
||||
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
||||
const label = labelFor(anim ? anim.toPage : page);
|
||||
|
||||
const visibleRounds = mainRounds.slice(page, page + 2);
|
||||
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
||||
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
||||
const pageG = pageGeometry(page);
|
||||
const animFromG = anim ? pageGeometry(anim.fromPage) : pageG;
|
||||
const animToG = anim ? pageGeometry(anim.toPage) : pageG;
|
||||
|
||||
let leftRounds: string[];
|
||||
let rightRounds: string[] = [];
|
||||
let leftHeight: number;
|
||||
let rightHeight = 0;
|
||||
let leftPage: number;
|
||||
let rightPage: number | null = null;
|
||||
let leftG = pageG;
|
||||
let rightG = pageG;
|
||||
let settlingTransition = false;
|
||||
if (anim?.phase === "sliding") {
|
||||
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
||||
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
||||
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
||||
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
||||
leftPage = anim.dir === "right" ? anim.fromPage : anim.toPage;
|
||||
rightPage = anim.dir === "right" ? anim.toPage : anim.fromPage;
|
||||
leftG = anim.dir === "right" ? animFromG : animToG;
|
||||
rightG = anim.dir === "right" ? animToG : animFromG;
|
||||
} else if (anim?.phase === "settling") {
|
||||
leftRounds = toRounds;
|
||||
leftHeight = animToHeight;
|
||||
leftPage = anim.toPage;
|
||||
leftG = animToG;
|
||||
settlingTransition = true;
|
||||
} else {
|
||||
leftRounds = visibleRounds;
|
||||
leftHeight = pageHeight;
|
||||
leftPage = page;
|
||||
}
|
||||
|
||||
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
||||
const containerMinHeight =
|
||||
anim?.phase === "settling" ? animToG.bracketHeight : animFromG.bracketHeight;
|
||||
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
||||
|
||||
return (
|
||||
|
|
@ -109,7 +119,7 @@ export function BracketTreePaginated({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(page + 1)}
|
||||
disabled={page + 2 >= mainRounds.length || !!anim}
|
||||
disabled={page >= lastPage || !!anim}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label="Next rounds"
|
||||
>
|
||||
|
|
@ -129,22 +139,24 @@ export function BracketTreePaginated({
|
|||
>
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
visibleRounds={leftRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
geometry={leftG}
|
||||
columnRange={[leftPage, leftPage + 1]}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={leftHeight}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
transitionDuration={settlingTransition ? 500 : undefined}
|
||||
/>
|
||||
</div>
|
||||
{anim?.phase === "sliding" && (
|
||||
{anim?.phase === "sliding" && rightPage !== null && (
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
visibleRounds={rightRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
geometry={rightG}
|
||||
columnRange={[rightPage, rightPage + 1]}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={rightHeight}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -164,6 +176,8 @@ export function BracketTreePaginated({
|
|||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { avatarColor } from "~/lib/avatar-colors";
|
||||
import { BRACKT_GRADIENT } from "~/lib/brand";
|
||||
import {
|
||||
computeGroupLayout,
|
||||
describeSlotSource,
|
||||
matchKey,
|
||||
type BracketLayout,
|
||||
type FeederMap,
|
||||
} from "~/lib/bracket-layout";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
|
||||
export interface BracketMatch {
|
||||
id: string;
|
||||
|
|
@ -46,6 +54,8 @@ function formatScore(score: string | null): string | null {
|
|||
|
||||
interface ParticipantRowProps {
|
||||
name: string | null;
|
||||
/** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */
|
||||
feedLabel?: string | null;
|
||||
isTbd: boolean;
|
||||
isWinner: boolean;
|
||||
isLoser: boolean;
|
||||
|
|
@ -60,6 +70,7 @@ interface ParticipantRowProps {
|
|||
|
||||
function ParticipantRow({
|
||||
name,
|
||||
feedLabel,
|
||||
isTbd,
|
||||
isWinner,
|
||||
isLoser,
|
||||
|
|
@ -114,7 +125,7 @@ function ParticipantRow({
|
|||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{name ?? "TBD"}
|
||||
{name ?? feedLabel ?? "TBD"}
|
||||
</span>
|
||||
|
||||
{/* Owner name below participant name */}
|
||||
|
|
@ -150,6 +161,8 @@ interface BracketMatchSlotProps {
|
|||
slotHeight: number;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketMatchSlot({
|
||||
|
|
@ -157,6 +170,8 @@ export function BracketMatchSlot({
|
|||
slotHeight,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketMatchSlotProps) {
|
||||
const rowHeight = slotHeight / 2;
|
||||
const showText = rowHeight >= 10;
|
||||
|
|
@ -187,6 +202,13 @@ export function BracketMatchSlot({
|
|||
|
||||
const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20)));
|
||||
|
||||
// An empty slot reads better as "Loser of Winners SF 2" than "TBD" — especially for
|
||||
// the feeds that cross between the winners and elimination brackets, which render as
|
||||
// separate trees and so can never be joined by a line.
|
||||
const slotSources = feeders?.get(matchKey(match.round, match.matchNumber));
|
||||
const feed1 = describeSlotSource(slotSources?.[0], template);
|
||||
const feed2 = describeSlotSource(slotSources?.[1], template);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
|
||||
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
|
||||
|
|
@ -208,6 +230,7 @@ export function BracketMatchSlot({
|
|||
>
|
||||
<ParticipantRow
|
||||
name={match.participant1?.name ?? null}
|
||||
feedLabel={feed1}
|
||||
isTbd={isTbd1}
|
||||
isWinner={p1IsWinner}
|
||||
isLoser={p1IsLoser}
|
||||
|
|
@ -221,6 +244,7 @@ export function BracketMatchSlot({
|
|||
/>
|
||||
<ParticipantRow
|
||||
name={match.participant2?.name ?? null}
|
||||
feedLabel={feed2}
|
||||
isTbd={isTbd2}
|
||||
isWinner={p2IsWinner}
|
||||
isLoser={p2IsLoser}
|
||||
|
|
@ -237,54 +261,45 @@ export function BracketMatchSlot({
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Per-pair connector column ────────────────────────────────────────────────
|
||||
// ─── Connector column ─────────────────────────────────────────────────────────
|
||||
|
||||
interface ConnectorColumnProps {
|
||||
currentMatches: BracketMatch[];
|
||||
nextMatches: BracketMatch[];
|
||||
/** Edges crossing this gutter, in slot units. */
|
||||
edges: { fromCenter: number; toCenter: number }[];
|
||||
rowHeight: number;
|
||||
offset: number;
|
||||
bracketHeight: number;
|
||||
}
|
||||
|
||||
function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: ConnectorColumnProps) {
|
||||
/**
|
||||
* Draws the feeder edges crossing one gutter. Because the layout assigns columns by
|
||||
* depth from the final, every edge spans exactly one gutter — so a card that enters the
|
||||
* bracket late is drawn in the column where it actually plays, and there is never an
|
||||
* edge to route across a skipped column.
|
||||
*/
|
||||
function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorColumnProps) {
|
||||
const mid = CONNECTOR_WIDTH / 2;
|
||||
|
||||
// Merge the two edges feeding one card into a single elbow, so a pair reads as one
|
||||
// bracket join rather than two overlapping lines.
|
||||
const byTarget = new Map<number, number[]>();
|
||||
for (const { fromCenter, toCenter } of edges) {
|
||||
const sources = byTarget.get(toCenter) ?? [];
|
||||
sources.push(fromCenter);
|
||||
byTarget.set(toCenter, sources);
|
||||
}
|
||||
|
||||
const paths: string[] = [];
|
||||
|
||||
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
|
||||
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
|
||||
|
||||
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals)
|
||||
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) {
|
||||
// Standard single-elimination halving: U-shape connectors
|
||||
for (let k = 0; k < nextMatches.length; k++) {
|
||||
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
|
||||
const midY = k * nextSlotH + nextSlotH / 2;
|
||||
const botIdx = 2 * k + 1;
|
||||
|
||||
if (botIdx < currentMatches.length) {
|
||||
const botY = botIdx * currentSlotH + currentSlotH / 2;
|
||||
paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`);
|
||||
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
|
||||
} else {
|
||||
paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`);
|
||||
}
|
||||
for (const [toCenter, sources] of byTarget) {
|
||||
const destY = toCenter * rowHeight - offset;
|
||||
const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b);
|
||||
if (ys.length === 1) {
|
||||
paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Non-standard (byes, play-ins, etc.): trace winners by participantId
|
||||
const winnerToIdx = new Map<string, number>();
|
||||
currentMatches.forEach((m, idx) => {
|
||||
if (m.winnerId) winnerToIdx.set(m.winnerId, idx);
|
||||
});
|
||||
|
||||
nextMatches.forEach((nextMatch, nextIdx) => {
|
||||
const destY = nextIdx * nextSlotH + nextSlotH / 2;
|
||||
for (const pId of [nextMatch.participant1Id, nextMatch.participant2Id]) {
|
||||
if (!pId) continue;
|
||||
const srcIdx = winnerToIdx.get(pId);
|
||||
if (srcIdx === undefined) continue;
|
||||
const srcY = srcIdx * currentSlotH + currentSlotH / 2;
|
||||
paths.push(`M 0 ${srcY} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
||||
}
|
||||
});
|
||||
paths.push(`M 0 ${ys[0]} H ${mid} V ${ys[ys.length - 1]} H 0`);
|
||||
for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`);
|
||||
paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -310,52 +325,131 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
|
|||
|
||||
// ─── Tree columns (shared by full + paginated) ───────────────────────────────
|
||||
|
||||
export interface BracketGeometry {
|
||||
layout: BracketLayout<BracketMatch>;
|
||||
/** Height of one leaf row. */
|
||||
rowHeight: number;
|
||||
/** Height of the card area, excluding the round labels. */
|
||||
bracketHeight: number;
|
||||
/** Narrowest the columns and gutters can be drawn without overlapping. */
|
||||
minWidth: number;
|
||||
/** Pixels trimmed off the top, non-zero only for a cropped column window. */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out a group's matches from the feeder graph and derive its pixel geometry.
|
||||
*
|
||||
* Height comes from the number of leaf rows rather than the largest round, so a bracket
|
||||
* whose widest column isn't its first still gets the room it needs.
|
||||
*/
|
||||
export function bracketGeometry(
|
||||
visibleRounds: string[],
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
feeders: FeederMap | undefined,
|
||||
templateRoundOrder: string[]
|
||||
): BracketGeometry {
|
||||
const layout = computeGroupLayout(
|
||||
visibleRounds,
|
||||
matchesByRound,
|
||||
feeders ?? new Map(),
|
||||
templateRoundOrder
|
||||
);
|
||||
const rowHeight = DESIRED_CARD_HEIGHT + CARD_GAP;
|
||||
const columnCount = Math.max(layout.columns.length, 1);
|
||||
return {
|
||||
layout,
|
||||
rowHeight,
|
||||
bracketHeight: Math.max(layout.leafCount, 1) * rowHeight,
|
||||
minWidth: columnCount * COLUMN_WIDTH + (columnCount - 1) * CONNECTOR_WIDTH,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop a layout to a window of columns, as the mobile pager does.
|
||||
*
|
||||
* Card positions are absolute within the whole bracket, so showing a slice of columns
|
||||
* means trimming the empty space above them rather than re-flowing — otherwise a later
|
||||
* page would render its two columns stranded at the bottom of a full-height bracket.
|
||||
*/
|
||||
export function windowGeometry(
|
||||
geometry: BracketGeometry,
|
||||
firstColumn: number,
|
||||
lastColumn: number
|
||||
): BracketGeometry {
|
||||
const centers = geometry.layout.columns
|
||||
.slice(firstColumn, lastColumn + 1)
|
||||
.flatMap((c) => c.matches.map((m) => m.center));
|
||||
if (centers.length === 0) return geometry;
|
||||
|
||||
const min = Math.min(...centers);
|
||||
const max = Math.max(...centers);
|
||||
return {
|
||||
...geometry,
|
||||
bracketHeight: (max - min + 1) * geometry.rowHeight,
|
||||
offset: (min - 0.5) * geometry.rowHeight,
|
||||
};
|
||||
}
|
||||
|
||||
interface TreeColumnsProps {
|
||||
visibleRounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
geometry: BracketGeometry;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
bracketHeight: number;
|
||||
transitionDuration?: number;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
/** Restrict rendering to a window of columns (used by the mobile pager). */
|
||||
columnRange?: [number, number];
|
||||
}
|
||||
|
||||
export function TreeColumns({
|
||||
visibleRounds,
|
||||
matchesByRound,
|
||||
geometry,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
bracketHeight,
|
||||
transitionDuration,
|
||||
feeders,
|
||||
template,
|
||||
columnRange,
|
||||
}: TreeColumnsProps) {
|
||||
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
|
||||
const { layout, rowHeight, bracketHeight, offset } = geometry;
|
||||
|
||||
const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1];
|
||||
const visible = layout.columns.slice(firstColumn, lastColumn + 1);
|
||||
|
||||
// Cards keep a fixed height regardless of how many share a column — stretching a
|
||||
// lone final to fill its column is what made it tower over the rest of the bracket.
|
||||
const cardHeight = Math.min(
|
||||
Math.max(rowHeight - CARD_GAP, 1),
|
||||
MAX_CARD_HEIGHT
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
||||
{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 (
|
||||
<div key={round} style={{ display: "contents" }}>
|
||||
<div key={column.label + ci} style={{ display: "contents" }}>
|
||||
{/* Round column */}
|
||||
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
|
||||
<div
|
||||
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
|
||||
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
{round}
|
||||
{column.label}
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
||||
{roundMatches.map((match, matchIdx) => (
|
||||
{column.matches.map(({ match, center }) => (
|
||||
<div
|
||||
key={match.id}
|
||||
data-match-id={match.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: matchIdx * slotHeight + cardTop,
|
||||
top: center * rowHeight - offset - cardHeight / 2,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: cardHeight,
|
||||
|
|
@ -367,6 +461,8 @@ export function TreeColumns({
|
|||
slotHeight={cardHeight}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -374,10 +470,11 @@ export function TreeColumns({
|
|||
</div>
|
||||
|
||||
{/* Connector between this column and the next */}
|
||||
{nextRound && (
|
||||
{vi < visible.length - 1 && (
|
||||
<ConnectorColumn
|
||||
currentMatches={roundMatches}
|
||||
nextMatches={nextMatches}
|
||||
edges={gutterEdges}
|
||||
rowHeight={rowHeight}
|
||||
offset={offset}
|
||||
bracketHeight={bracketHeight}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -396,6 +493,8 @@ interface BracketTreeViewProps {
|
|||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
thirdPlaceRound?: string;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketTreeView({
|
||||
|
|
@ -404,13 +503,19 @@ export function BracketTreeView({
|
|||
ownershipMap,
|
||||
userParticipantIds,
|
||||
thirdPlaceRound,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketTreeViewProps) {
|
||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||
|
||||
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH;
|
||||
const geometry = bracketGeometry(
|
||||
mainRounds,
|
||||
matchesByRound,
|
||||
feeders,
|
||||
template?.rounds.map((r) => r.name) ?? mainRounds
|
||||
);
|
||||
const { bracketHeight, minWidth } = geometry;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -419,11 +524,11 @@ export function BracketTreeView({
|
|||
>
|
||||
<div style={{ minWidth }}>
|
||||
<TreeColumns
|
||||
visibleRounds={mainRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
geometry={geometry}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={bracketHeight}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
{thirdPlaceMatch && (
|
||||
<div style={{ display: "flex", paddingTop: 20 }}>
|
||||
|
|
@ -441,6 +546,8 @@ export function BracketTreeView({
|
|||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import type { ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import type { BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import type { FeederMap } from "~/lib/bracket-layout";
|
||||
import {
|
||||
TreeColumns,
|
||||
bracketGeometry,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
|
||||
interface NbaBracketLayoutProps {
|
||||
|
|
@ -10,11 +16,10 @@ interface NbaBracketLayoutProps {
|
|||
userParticipantIds: Set<string>;
|
||||
conferenceGroups: ConferenceGroup[];
|
||||
scoringRoundIdx: number;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
const DESIRED_CARD_HEIGHT = 112;
|
||||
const CARD_GAP = 14;
|
||||
|
||||
function splitMatchesByConference(
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
group: ConferenceGroup
|
||||
|
|
@ -28,11 +33,6 @@ function splitMatchesByConference(
|
|||
return result;
|
||||
}
|
||||
|
||||
function bracketHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
|
||||
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
}
|
||||
|
||||
export function NbaBracketLayout({
|
||||
rounds,
|
||||
matchesByRound,
|
||||
|
|
@ -40,7 +40,10 @@ export function NbaBracketLayout({
|
|||
userParticipantIds,
|
||||
conferenceGroups,
|
||||
scoringRoundIdx,
|
||||
feeders,
|
||||
template,
|
||||
}: NbaBracketLayoutProps) {
|
||||
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
||||
// Rounds that belong to any conference group
|
||||
const conferenceRoundSet = new Set(
|
||||
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
|
||||
|
|
@ -57,7 +60,7 @@ export function NbaBracketLayout({
|
|||
const sharedMatches = new Map(
|
||||
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
|
||||
);
|
||||
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
|
||||
const sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -66,7 +69,7 @@ export function NbaBracketLayout({
|
|||
{conferenceGroups.map((group, gi) => {
|
||||
const confRounds = conferenceRounds[gi];
|
||||
const confMatches = splitMatchesByConference(matchesByRound, group);
|
||||
const height = bracketHeight(confMatches, confRounds);
|
||||
const geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder);
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
|
|
@ -74,11 +77,11 @@ export function NbaBracketLayout({
|
|||
{group.name}
|
||||
</p>
|
||||
<TreeColumns
|
||||
visibleRounds={confRounds}
|
||||
matchesByRound={confMatches}
|
||||
geometry={geometry}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={height}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -87,11 +90,11 @@ export function NbaBracketLayout({
|
|||
{sharedRounds.length > 0 && (
|
||||
<div>
|
||||
<TreeColumns
|
||||
visibleRounds={sharedRounds}
|
||||
matchesByRound={sharedMatches}
|
||||
geometry={sharedGeometry}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={sharedHeight}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { RankingsRow } from "./RankingsRow";
|
|||
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
||||
import { buildFeederMap } from "~/lib/bracket-layout";
|
||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||
|
||||
|
|
@ -76,43 +77,6 @@ export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
|
|||
return byRound;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a standard single-elimination bracket, slot p1 of match N in round R
|
||||
* comes from match (2N-1) in the previous round, and slot p2 from match 2N.
|
||||
*/
|
||||
export function buildFeederMap(
|
||||
matchesByRound: Map<string, Match[]>,
|
||||
orderedRounds: string[]
|
||||
): Map<string, { round: string; matchNumber: number }> {
|
||||
const feederMap = new Map<string, { round: string; matchNumber: number }>();
|
||||
|
||||
for (let ri = 1; ri < orderedRounds.length; ri++) {
|
||||
const currentRound = orderedRounds[ri];
|
||||
const prevRound = orderedRounds[ri - 1];
|
||||
const prevMatchNums = new Set(
|
||||
(matchesByRound.get(prevRound) || []).map((m) => m.matchNumber)
|
||||
);
|
||||
for (const match of matchesByRound.get(currentRound) || []) {
|
||||
const p1Src = 2 * (match.matchNumber - 1) + 1;
|
||||
const p2Src = 2 * (match.matchNumber - 1) + 2;
|
||||
if (prevMatchNums.has(p1Src)) {
|
||||
feederMap.set(`${currentRound}:${match.matchNumber}:p1`, {
|
||||
round: prevRound,
|
||||
matchNumber: p1Src,
|
||||
});
|
||||
}
|
||||
if (prevMatchNums.has(p2Src)) {
|
||||
feederMap.set(`${currentRound}:${match.matchNumber}:p2`, {
|
||||
round: prevRound,
|
||||
matchNumber: p2Src,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return feederMap;
|
||||
}
|
||||
|
||||
interface EliminatedEntry {
|
||||
participant: Participant;
|
||||
score: string | null;
|
||||
|
|
@ -391,6 +355,8 @@ export function PlayoffBracket({
|
|||
const matchesByRound = groupMatchesByRound(matches);
|
||||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||
// What fills each slot, used for both card placement and naming empty slots.
|
||||
const feeders = buildFeederMap(template);
|
||||
|
||||
const consolation = findConsolationRound(template);
|
||||
const thirdPlaceRound = consolation?.round;
|
||||
|
|
@ -478,6 +444,8 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
phases={template.phases}
|
||||
scoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
) : template?.conferenceGroups ? (
|
||||
<NbaBracketLayout
|
||||
|
|
@ -488,6 +456,8 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
conferenceGroups={template.conferenceGroups}
|
||||
scoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -499,6 +469,8 @@ export function PlayoffBracket({
|
|||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
thirdPlaceRound={thirdPlaceRound}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -511,6 +483,8 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
thirdPlaceRound={thirdPlaceRound}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import type { BracketPhase, BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import type { FeederMap } from "~/lib/bracket-layout";
|
||||
import {
|
||||
TreeColumns,
|
||||
BracketMatchSlot,
|
||||
bracketGeometry,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
|
||||
/** Card height for the play-in columns, which lay themselves out rather than via TreeColumns. */
|
||||
const CARD_H = 112;
|
||||
|
||||
interface TabbedBracketLayoutProps {
|
||||
rounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
|
|
@ -10,11 +20,10 @@ interface TabbedBracketLayoutProps {
|
|||
userParticipantIds: Set<string>;
|
||||
phases: BracketPhase[];
|
||||
scoringRoundIdx: number;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
const CARD_H = 112;
|
||||
const CARD_GAP = 14;
|
||||
|
||||
function groupMatches(
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
group: ConferenceGroup
|
||||
|
|
@ -29,11 +38,6 @@ function groupMatches(
|
|||
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 {
|
||||
|
|
@ -141,7 +145,10 @@ export function TabbedBracketLayout({
|
|||
userParticipantIds,
|
||||
phases,
|
||||
scoringRoundIdx,
|
||||
feeders,
|
||||
template,
|
||||
}: TabbedBracketLayoutProps) {
|
||||
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{phases.map((phase) => {
|
||||
|
|
@ -194,50 +201,87 @@ export function TabbedBracketLayout({
|
|||
{phase.groups.map((group) => {
|
||||
const gMatches = groupMatches(matchesByRound, group);
|
||||
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
||||
const geometry = bracketGeometry(gRounds, gMatches, feeders, roundOrder);
|
||||
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 className="w-full overflow-x-auto">
|
||||
<div style={{ minWidth: geometry.minWidth }}>
|
||||
<TreeColumns
|
||||
geometry={geometry}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sharedRounds.length > 0 && (
|
||||
<TreeColumns
|
||||
visibleRounds={sharedRounds}
|
||||
matchesByRound={sharedMatchesByRound}
|
||||
geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TreeColumns
|
||||
visibleRounds={phaseRounds}
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="md:hidden">
|
||||
{/* 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. */}
|
||||
<div className="md:hidden space-y-6">
|
||||
{phase.layout === "play-in" ? (
|
||||
<PlayInLayout
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
/>
|
||||
) : phase.groups ? (
|
||||
<>
|
||||
{phase.groups.map((group) => (
|
||||
<div key={group.name}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{group.name}
|
||||
</p>
|
||||
<BracketTreePaginated
|
||||
rounds={groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined)}
|
||||
matchesByRound={groupMatches(matchesByRound, group)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sharedRounds.length > 0 && (
|
||||
<BracketTreePaginated
|
||||
rounds={sharedRounds}
|
||||
matchesByRound={sharedMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<BracketTreePaginated
|
||||
rounds={phaseRounds}
|
||||
|
|
@ -245,6 +289,8 @@ export function TabbedBracketLayout({
|
|||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
|
||||
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<HTMLElement>("[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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
533
app/lib/__tests__/bracket-layout.test.ts
Normal file
533
app/lib/__tests__/bracket-layout.test.ts
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
/**
|
||||
* 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,
|
||||
BRACKET_TEMPLATES,
|
||||
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;
|
||||
/** Only the fallback reads these, to trace edges through an unrecognised shape. */
|
||||
winnerId?: string | null;
|
||||
participant1Id?: string | null;
|
||||
participant2Id?: string | null;
|
||||
}
|
||||
|
||||
/** Every match a template defines, as the renderer would receive them. */
|
||||
function allMatches(template: BracketTemplate): Map<string, TestMatch[]> {
|
||||
const byRound = new Map<string, TestMatch[]>();
|
||||
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<string, TestMatch[]> {
|
||||
const byRound = new Map<string, TestMatch[]>();
|
||||
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<string, TestMatch[]>([
|
||||
["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("buildFeederMap — templates with routing of their own", () => {
|
||||
// The halving rule describes advanceWinnerTemplate, not every bracket. Inventing it
|
||||
// where it doesn't hold draws confident, wrong connectors and mislabels slots, which
|
||||
// is worse than drawing nothing.
|
||||
|
||||
it("follows feedsInto rather than the order rounds are listed in", () => {
|
||||
// AFL's Wildcard Round feeds the Elimination Finals, skipping the round printed
|
||||
// next to it, so array order would fabricate the whole chain.
|
||||
const afl = BRACKET_TEMPLATES.afl_10;
|
||||
const feeders = buildFeederMap(afl);
|
||||
const fed = [...feeders.entries()].filter(([, pair]) =>
|
||||
pair.some((s) => s.kind === "match")
|
||||
);
|
||||
// Only Preliminary Finals → Grand Final actually halves.
|
||||
expect(fed.map(([key]) => key)).toEqual(["Grand Final#1"]);
|
||||
});
|
||||
|
||||
it("leaves a bye round's slots seeded rather than inventing feeds", () => {
|
||||
// CFP's First Round (4) feeds the Quarterfinals (4) — the top seeds have byes.
|
||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.cfp_12);
|
||||
expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
|
||||
{ kind: "seed" },
|
||||
{ kind: "seed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the First Four out of the Round of 64", () => {
|
||||
// advanceFirstFourWinner puts each winner in a specific seed slot, not games 1-2.
|
||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.ncaa_68);
|
||||
expect(feeders.get(matchKey("Round of 64", 1))).toEqual([
|
||||
{ kind: "seed" },
|
||||
{ kind: "seed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the NBA play-in alone, where a loser feeds forward", () => {
|
||||
// Play-In Round 2 pairs the 7v8 loser with the 9v10 winner, so the round sizes
|
||||
// halve but the winners-only rule still doesn't describe it.
|
||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.nba_20);
|
||||
expect(feeders.get(matchKey("Play-In Round 2", 1))).toEqual([
|
||||
{ kind: "seed" },
|
||||
{ kind: "seed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not route the FIFA final through the third place game", () => {
|
||||
// Third Place Game sits between Semifinals and Finals in round order, so array
|
||||
// order made it the Finals' feeder and left the Finals' second slot empty.
|
||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.fifa_48);
|
||||
expect(feeders.get(matchKey("Finals", 1))).toEqual([
|
||||
{ kind: "match", ref: { round: "Semifinals", matchNumber: 1 }, result: "winner" },
|
||||
{ kind: "match", ref: { round: "Semifinals", matchNumber: 2 }, result: "winner" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeGroupLayout — every template still draws connectors", () => {
|
||||
/** Lay a whole template out the way BracketTreeView would. */
|
||||
function layOut(template: BracketTemplate) {
|
||||
const byRound = allMatches(template);
|
||||
const order = template.rounds.map((r) => r.name);
|
||||
// BracketTreeView renders a third place game outside the tree.
|
||||
const rounds = order.filter((r) => r !== "Third Place Game");
|
||||
return computeGroupLayout(rounds, byRound, buildFeederMap(template), order);
|
||||
}
|
||||
|
||||
// A gutter joining a column to one exactly half its size is a plain bracket join and
|
||||
// must always be drawn. Where the sizes don't halve — a bye round, a play-in, the
|
||||
// First Four — the routing is bespoke and nothing is drawn until the games decide it,
|
||||
// which is what these brackets did before.
|
||||
it.each(Object.keys(BRACKET_TEMPLATES).filter((id) => id !== "llws_20"))(
|
||||
"%s draws every gutter that halves",
|
||||
(id) => {
|
||||
const layout = layOut(BRACKET_TEMPLATES[id]);
|
||||
const gutters = new Set(layout.edges.map((e) => e.fromColumn));
|
||||
let halvingGutters = 0;
|
||||
for (let ci = 0; ci < layout.columns.length - 1; ci++) {
|
||||
const from = layout.columns[ci].matches.length;
|
||||
const to = layout.columns[ci + 1].matches.length;
|
||||
if (from !== to * 2) continue;
|
||||
halvingGutters += 1;
|
||||
expect(gutters).toContain(ci);
|
||||
}
|
||||
// Every template has at least one, so a template that lost all its lines fails.
|
||||
expect(halvingGutters).toBeGreaterThan(0);
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps the FIFA bracket a single tree once the third place game is set aside", () => {
|
||||
const layout = layOut(BRACKET_TEMPLATES.fifa_48);
|
||||
expect(layout.columns.map((c) => c.label)).toEqual([
|
||||
"Round of 32",
|
||||
"Round of 16",
|
||||
"Quarterfinals",
|
||||
"Semifinals",
|
||||
"Finals",
|
||||
]);
|
||||
expect(layout.edges).toHaveLength(30);
|
||||
});
|
||||
|
||||
// llws_20 is excluded above because both sides in one group is genuinely not a tree;
|
||||
// it renders per side, which the tests further up cover.
|
||||
});
|
||||
|
||||
describe("computeGroupLayout — fallback keeps the old connectors", () => {
|
||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
const byRound = new Map<string, TestMatch[]>([
|
||||
["Quarterfinals", [1, 2, 3, 4].map((n) => ({ round: "Quarterfinals", matchNumber: n }))],
|
||||
["Semifinals", [1, 2].map((n) => ({ round: "Semifinals", matchNumber: n }))],
|
||||
["Finals", [{ round: "Finals", matchNumber: 1 }]],
|
||||
]);
|
||||
|
||||
it("infers halving edges when there is no feeder map at all", () => {
|
||||
// A bracket with no template id, which SportSeasonDisplay renders.
|
||||
const layout = computeGroupLayout(rounds, byRound, new Map(), rounds);
|
||||
expect(layout.edges).toHaveLength(6);
|
||||
// Quarterfinals 1 and 2 both join Semifinal 1.
|
||||
const intoFirstSemi = layout.edges.filter((e) => e.toCenter === 1);
|
||||
expect(intoFirstSemi.map((e) => e.fromCenter)).toEqual([0.5, 1.5]);
|
||||
});
|
||||
|
||||
it("traces played winners when the shape is not a halving", () => {
|
||||
const irregular = new Map<string, TestMatch[]>([
|
||||
[
|
||||
"Wildcard",
|
||||
[
|
||||
{ round: "Wildcard", matchNumber: 1, winnerId: "a" },
|
||||
{ round: "Wildcard", matchNumber: 2, winnerId: "b" },
|
||||
],
|
||||
],
|
||||
[
|
||||
"Semifinals",
|
||||
[
|
||||
{ round: "Semifinals", matchNumber: 1, participant1Id: "seeded", participant2Id: "b" },
|
||||
{ round: "Semifinals", matchNumber: 2, participant1Id: "seeded2", participant2Id: "a" },
|
||||
],
|
||||
],
|
||||
]);
|
||||
const layout = computeGroupLayout(
|
||||
["Wildcard", "Semifinals"],
|
||||
irregular,
|
||||
new Map(),
|
||||
["Wildcard", "Semifinals"]
|
||||
);
|
||||
// b won Wildcard 2 (centre 1.5) and plays Semifinal 1 (centre 0.5) — a crossing
|
||||
// edge that only the actual result can reveal.
|
||||
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 1.5, toCenter: 0.5 });
|
||||
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 0.5, toCenter: 1.5 });
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
420
app/lib/bracket-layout.ts
Normal file
420
app/lib/bracket-layout.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
/**
|
||||
* 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<string, [SlotSource, SlotSource]>;
|
||||
|
||||
const SEED: SlotSource = { kind: "seed" };
|
||||
|
||||
/**
|
||||
* `template.id:roundName` for transitions routed by a dedicated advancement function
|
||||
* rather than advanceWinnerTemplate's ceil(n/2) rule, and whose round sizes happen to
|
||||
* halve so the check in buildFeederMap can't rule them out on shape alone.
|
||||
*
|
||||
* The NBA play-in is the case: Play-In Round 2 pairs the 7v8 *loser* with the 9v10
|
||||
* winner (advanceNBAPlayInWinner), which no winners-only halving describes.
|
||||
*/
|
||||
const BESPOKE_TRANSITIONS = new Set(["nba_20:Play-In Round 1"]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Follow each round's declared `feedsInto` rather than array order — AFL's Wildcard
|
||||
// Round feeds the Elimination Finals, skipping the round printed next to it.
|
||||
for (const prev of template.rounds) {
|
||||
if (!prev.feedsInto) continue;
|
||||
const round = template.rounds.find((r) => r.name === prev.feedsInto);
|
||||
if (!round) continue;
|
||||
|
||||
// advanceWinnerTemplate sends match n to ceil(n/2) in the next round, slot by
|
||||
// parity. That describes the bracket only where the round halves exactly; a
|
||||
// play-in, a bye round, or a First Four routes by rules of its own, and inventing
|
||||
// a halving there would draw connectors and slot labels that are simply wrong.
|
||||
// Leaving those edges out drops the group to computeGroupLayout's fallback, which
|
||||
// is the geometry these brackets already had.
|
||||
if (prev.matchCount !== round.matchCount * 2) continue;
|
||||
if (BESPOKE_TRANSITIONS.has(`${template.id}:${prev.name}`)) continue;
|
||||
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const pair = slots(matchKey(round.name, n));
|
||||
pair[0] = {
|
||||
kind: "match",
|
||||
ref: { round: prev.name, matchNumber: 2 * n - 1 },
|
||||
result: "winner",
|
||||
};
|
||||
pair[1] = {
|
||||
kind: "match",
|
||||
ref: { round: prev.name, matchNumber: 2 * n },
|
||||
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<string, string> = {
|
||||
"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<M> {
|
||||
match: M;
|
||||
/** Centre of the card, in slot units (1 unit = one leaf row). */
|
||||
center: number;
|
||||
}
|
||||
|
||||
export interface LayoutColumn<M> {
|
||||
label: string;
|
||||
matches: LaidOutMatch<M>[];
|
||||
}
|
||||
|
||||
export interface BracketLayout<M> {
|
||||
columns: LayoutColumn<M>[];
|
||||
/** 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;
|
||||
/** Only read by the fallback, to trace edges through an unrecognised shape. */
|
||||
winnerId?: string | null;
|
||||
participant1Id?: string | null;
|
||||
participant2Id?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<M extends PositionedMatch>(
|
||||
visibleRounds: string[],
|
||||
matchesByRound: Map<string, M[]>,
|
||||
feeders: FeederMap,
|
||||
templateRoundOrder: string[]
|
||||
): BracketLayout<M> {
|
||||
const nodes = new Map<string, M>();
|
||||
const roundOf = new Map<string, string>();
|
||||
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<string, string[]>();
|
||||
const hasParent = new Set<string>();
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
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<M>[] = 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<M>["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, kept for groups whose shape can't be resolved into a single
|
||||
* tree: one column per round, matches spread evenly over it, and edges inferred from the
|
||||
* round sizes. Brackets with bespoke routing (AFL, CFP byes, a bracket with no template)
|
||||
* land here, so it has to keep drawing what they drew before rather than nothing.
|
||||
*/
|
||||
function fallbackLayout<M extends PositionedMatch>(
|
||||
visibleRounds: string[],
|
||||
matchesByRound: Map<string, M[]>
|
||||
): BracketLayout<M> {
|
||||
const leafCount = Math.max(
|
||||
...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0),
|
||||
1
|
||||
);
|
||||
const centersFor = (matches: M[]) => {
|
||||
const span = leafCount / Math.max(matches.length, 1);
|
||||
return matches.map((_, i) => (i + 0.5) * span);
|
||||
};
|
||||
|
||||
const columns = visibleRounds.map((round) => {
|
||||
const matches = matchesByRound.get(round) ?? [];
|
||||
const centers = centersFor(matches);
|
||||
return {
|
||||
label: round,
|
||||
matches: matches.map((match, i) => ({ match, center: centers[i] })),
|
||||
};
|
||||
});
|
||||
|
||||
const edges: BracketLayout<M>["edges"] = [];
|
||||
for (let ci = 0; ci < columns.length - 1; ci++) {
|
||||
const from = columns[ci].matches;
|
||||
const to = columns[ci + 1].matches;
|
||||
|
||||
if (to.length === Math.ceil(from.length / 2) && from.length > 1) {
|
||||
// A halving: matches 2k and 2k+1 feed match k.
|
||||
for (let k = 0; k < to.length; k++) {
|
||||
for (const idx of [2 * k, 2 * k + 1]) {
|
||||
if (idx >= from.length) continue;
|
||||
edges.push({
|
||||
fromColumn: ci,
|
||||
fromCenter: from[idx].center,
|
||||
toCenter: to[k].center,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise the only thing that can be known is where a winner actually went, so
|
||||
// nothing is drawn until the games are played.
|
||||
const winnerToCenter = new Map<string, number>();
|
||||
for (const { match, center } of from) {
|
||||
if (match.winnerId) winnerToCenter.set(match.winnerId, center);
|
||||
}
|
||||
for (const { match, center } of to) {
|
||||
for (const id of [match.participant1Id, match.participant2Id]) {
|
||||
const fromCenter = id ? winnerToCenter.get(id) : undefined;
|
||||
if (fromCenter === undefined) continue;
|
||||
edges.push({ fromColumn: ci, fromCenter, toCenter: center });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { columns, leafCount, edges };
|
||||
}
|
||||
194
app/lib/llws-bracket.ts
Normal file
194
app/lib/llws-bracket.ts
Normal file
|
|
@ -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<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) };
|
||||
}
|
||||
|
|
@ -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<number, { round: string; matchNumber: number }> = {
|
||||
// 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<string, number>(
|
||||
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<T>(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<number, [string | null, string | null]> = {
|
||||
// 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", () => {
|
||||
|
|
|
|||
192
app/models/__tests__/season-races.test.ts
Normal file
192
app/models/__tests__/season-races.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||
import { countSeasonRaces, hasRaceRun } from "../season-races";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
const NOW = new Date("2026-08-17T12:00:00.000Z");
|
||||
const TODAY = "2026-08-17";
|
||||
|
||||
interface EventRow {
|
||||
eventType: string;
|
||||
isComplete: boolean;
|
||||
eventDate: string | null;
|
||||
eventStartsAt: Date | null;
|
||||
}
|
||||
|
||||
function makeEvent(overrides: Partial<EventRow> = {}): EventRow {
|
||||
return {
|
||||
eventType: "schedule_event",
|
||||
isComplete: false,
|
||||
eventDate: null,
|
||||
eventStartsAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function mockEvents(events: EventRow[]) {
|
||||
const { database } = await import("~/database/context");
|
||||
(database as unknown as MockInstance).mockReturnValue({
|
||||
query: {
|
||||
scoringEvents: {
|
||||
findMany: vi.fn().mockResolvedValue(events),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await mockEvents([]);
|
||||
});
|
||||
|
||||
describe("hasRaceRun", () => {
|
||||
it("trusts isComplete when an admin has set it", () => {
|
||||
expect(
|
||||
hasRaceRun(
|
||||
{ isComplete: true, eventDate: "2026-12-31", eventStartsAt: null },
|
||||
NOW,
|
||||
TODAY
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers eventStartsAt over eventDate", () => {
|
||||
// Started yesterday and long finished, even though eventDate is unset.
|
||||
expect(
|
||||
hasRaceRun(
|
||||
{
|
||||
isComplete: false,
|
||||
eventDate: null,
|
||||
eventStartsAt: new Date("2026-08-16T18:00:00.000Z"),
|
||||
},
|
||||
NOW,
|
||||
TODAY
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
hasRaceRun(
|
||||
{
|
||||
isComplete: false,
|
||||
eventDate: TODAY,
|
||||
eventStartsAt: new Date("2026-08-17T18:00:00.000Z"),
|
||||
},
|
||||
NOW,
|
||||
TODAY
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not call a race run the moment it goes green", () => {
|
||||
// Declaring the finale finished at the green flag would publish the
|
||||
// pre-race leader as champion at 100%, from standings without that race.
|
||||
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
|
||||
expect(
|
||||
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("counts a race run once it has had time to finish", () => {
|
||||
const greenFlag = new Date(NOW.getTime() - 7 * 60 * 60 * 1000);
|
||||
expect(
|
||||
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still honours isComplete for a race that just started", () => {
|
||||
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
|
||||
expect(
|
||||
hasRaceRun({ isComplete: true, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a past date as run even when nobody marked it complete", () => {
|
||||
expect(
|
||||
hasRaceRun(
|
||||
{ isComplete: false, eventDate: "2026-08-16", eventStartsAt: null },
|
||||
NOW,
|
||||
TODAY
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a race happening today as still upcoming", () => {
|
||||
expect(
|
||||
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: null }, NOW, TODAY)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an undated row as upcoming", () => {
|
||||
expect(
|
||||
hasRaceRun({ isComplete: false, eventDate: null, eventStartsAt: null }, NOW, TODAY)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countSeasonRaces", () => {
|
||||
it("counts schedule_event rows as races", async () => {
|
||||
// This is the whole bug: a season_standings calendar is stored as
|
||||
// schedule_event rows, and the simulator used to skip them.
|
||||
await mockEvents([
|
||||
makeEvent({ eventDate: "2026-03-01" }),
|
||||
makeEvent({ eventDate: "2026-04-01" }),
|
||||
makeEvent({ eventDate: "2026-09-01" }),
|
||||
]);
|
||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
||||
completed: 2,
|
||||
remaining: 1,
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes the final_standings scoring row", async () => {
|
||||
await mockEvents([
|
||||
makeEvent({ eventDate: "2026-03-01" }),
|
||||
makeEvent({ eventType: "final_standings", eventDate: "2026-11-01" }),
|
||||
]);
|
||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
||||
completed: 1,
|
||||
remaining: 0,
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts other event types too, whichever type the admin used", async () => {
|
||||
await mockEvents([
|
||||
makeEvent({ eventType: "major_tournament", eventDate: "2026-03-01" }),
|
||||
makeEvent({ eventType: "playoff_game", eventDate: "2026-09-01" }),
|
||||
]);
|
||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
||||
completed: 1,
|
||||
remaining: 1,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns zeroes when the season has no events", async () => {
|
||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
||||
completed: 0,
|
||||
remaining: 0,
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts a realistic late-season IndyCar calendar", async () => {
|
||||
const calendar = [
|
||||
...Array.from({ length: 15 }, (_, i) =>
|
||||
makeEvent({ eventDate: `2026-0${((i % 6) + 3)}-0${(i % 9) + 1}` })
|
||||
),
|
||||
// The next race goes green in a few hours — still remaining.
|
||||
makeEvent({ eventStartsAt: new Date("2026-08-17T18:00:00.000Z") }),
|
||||
makeEvent({ eventStartsAt: new Date("2026-08-30T18:00:00.000Z") }),
|
||||
makeEvent({ eventType: "final_standings" }),
|
||||
];
|
||||
await mockEvents(calendar);
|
||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
||||
completed: 15,
|
||||
remaining: 2,
|
||||
total: 17,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<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. */
|
||||
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).
|
||||
|
|
|
|||
99
app/models/season-races.ts
Normal file
99
app/models/season-races.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Race-calendar state for season-standings sports (F1, IndyCar).
|
||||
*
|
||||
* Kept in its own leaf module rather than in `scoring-event.ts` so that
|
||||
* `simulator.ts` can read it: `scoring-event.ts` pulls in `scoring-calculator`,
|
||||
* which reaches `participant-expected-value` and back into `simulator`. This
|
||||
* file imports nothing but the database.
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
export interface SeasonRaceCounts {
|
||||
completed: number;
|
||||
remaining: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long after the green flag a race is assumed to have finished.
|
||||
*
|
||||
* `event_starts_at` is a start time, so treating it as "already run" would
|
||||
* declare the season over the moment the finale goes green — and the simulator
|
||||
* would publish the pre-race leader as champion at 100%, from standings that do
|
||||
* not yet include the race being run. No race in these series comes close to
|
||||
* six hours, and the standings feed updates within hours of a finish.
|
||||
*/
|
||||
const RACE_DURATION_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Has this race already been run?
|
||||
*
|
||||
* `is_complete` wins when an admin has set it, but a racing calendar is stored
|
||||
* as "Non-Scoring" rows that nobody ever marks complete, so the date is the real
|
||||
* signal. Mirrors the Upcoming / Results Pending badge on the admin events page.
|
||||
* A race happening today is still upcoming, and a row with no date at all counts
|
||||
* as upcoming.
|
||||
*
|
||||
* @param today `now` as a `YYYY-MM-DD` string, to compare against the date-only
|
||||
* `event_date` column.
|
||||
*/
|
||||
export function hasRaceRun(
|
||||
event: {
|
||||
isComplete: boolean;
|
||||
eventDate: string | null;
|
||||
eventStartsAt: Date | string | null;
|
||||
},
|
||||
now: Date,
|
||||
today: string
|
||||
): boolean {
|
||||
if (event.isComplete) return true;
|
||||
if (event.eventStartsAt) {
|
||||
return new Date(event.eventStartsAt).getTime() + RACE_DURATION_MS < now.getTime();
|
||||
}
|
||||
if (event.eventDate) return event.eventDate < today;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the races on a season-standings calendar (F1, IndyCar).
|
||||
*
|
||||
* `event_type` has no race value, so a racing calendar is stored as
|
||||
* `schedule_event` rows — the admin default for the `season_standings` scoring
|
||||
* pattern. The only other row such a season carries is the single
|
||||
* `final_standings` event that assigns fantasy placements once the championship
|
||||
* is settled. A race is therefore "every event except `final_standings`", not
|
||||
* "every event except `schedule_event`" — getting that backwards leaves the
|
||||
* simulator with zero remaining races and no idea the season is in progress.
|
||||
*/
|
||||
export async function countSeasonRaces(
|
||||
sportsSeasonId: string,
|
||||
now: Date = new Date(),
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<SeasonRaceCounts> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
columns: {
|
||||
eventType: true,
|
||||
isComplete: true,
|
||||
eventDate: true,
|
||||
eventStartsAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const today = now.toISOString().split("T")[0];
|
||||
let completed = 0;
|
||||
let remaining = 0;
|
||||
|
||||
for (const event of events) {
|
||||
if (event.eventType === "final_standings") continue;
|
||||
if (hasRaceRun(event, now, today)) completed++;
|
||||
else remaining++;
|
||||
}
|
||||
|
||||
return { completed, remaining, total: completed + remaining };
|
||||
}
|
||||
|
|
@ -15,6 +15,10 @@ import {
|
|||
sourceEloRequirementLabel,
|
||||
} from "~/services/simulations/input-policy";
|
||||
import { SIMULATOR_TYPES, type SimulatorType } from "~/services/simulations/registry";
|
||||
import { countSeasonRaces } from "~/models/season-races";
|
||||
|
||||
/** Simulator types driven by a race calendar plus championship standings. */
|
||||
const RACE_CALENDAR_SIMULATORS: SimulatorType[] = ["f1_standings", "indycar_standings"];
|
||||
|
||||
export interface SimulatorProfile extends SimulatorManifestProfile {
|
||||
isActive: boolean;
|
||||
|
|
@ -488,6 +492,19 @@ export async function validateSimulatorReadiness(
|
|||
}
|
||||
}
|
||||
|
||||
if (RACE_CALENDAR_SIMULATORS.includes(config.simulatorType)) {
|
||||
// Without a calendar the simulator cannot tell how many races are left, so
|
||||
// it falls back to futures odds and ignores the championship standings
|
||||
// entirely. A warning, not a blocker — a season drafted before the schedule
|
||||
// is published still needs to run.
|
||||
const races = await countSeasonRaces(sportsSeasonId);
|
||||
if (races.total === 0) {
|
||||
warnings.push(
|
||||
"No race calendar found for this season. Add the schedule on the events page — until then the simulation uses futures odds only and ignores championship standings."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.profile.setupSections.includes("regularStandings")) {
|
||||
warnings.push("Regular-season standings may be needed for in-season accuracy.");
|
||||
}
|
||||
|
|
|
|||
130
app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts
Normal file
130
app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* 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<object>()),
|
||||
getScoringEventById: vi.fn(),
|
||||
updateScoringEvent: vi.fn(),
|
||||
isReadOnlySibling: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findPlayoffMatchesByEventId: vi.fn(),
|
||||
deletePlayoffMatchesByEventId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
deleteParticipantResultsBySportsSeasonId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
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<ReturnType<typeof getScoringEventById>>
|
||||
);
|
||||
vi.mocked(deletePlayoffMatchesByEventId).mockResolvedValue(undefined);
|
||||
vi.mocked(deleteParticipantResultsBySportsSeasonId).mockResolvedValue(undefined);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes the matches", async () => {
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||
match(false),
|
||||
match(false),
|
||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||
|
||||
const result = await run(clearRequest());
|
||||
|
||||
expect(result.success).toContain("2 match(es) removed");
|
||||
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
|
||||
});
|
||||
|
||||
it("leaves placements alone — they belong to the whole season, not this event", async () => {
|
||||
// seasonParticipantResults is keyed by sports season, so deleting here would wipe
|
||||
// every other event's placements with nothing to rebuild them. Reprocess Bracket is
|
||||
// the tool that rebuilds them correctly.
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||
match(true),
|
||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||
|
||||
const result = await run(clearRequest("true"));
|
||||
|
||||
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||||
expect(result.success).toContain("Reprocess Bracket");
|
||||
});
|
||||
|
||||
it("refuses to discard completed matches without confirmation", async () => {
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||
match(true),
|
||||
match(false),
|
||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||
|
||||
const result = await run(clearRequest());
|
||||
|
||||
expect(result.error).toContain("1 completed match(es)");
|
||||
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards completed matches once confirmed", async () => {
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||
match(true),
|
||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||
|
||||
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<ReturnType<typeof findPlayoffMatchesByEventId>>
|
||||
);
|
||||
|
||||
const result = await run(clearRequest("true"));
|
||||
|
||||
expect(result.error).toContain("no bracket to clear");
|
||||
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
|
||||
import {
|
||||
findPlayoffMatchesByEventId,
|
||||
deletePlayoffMatchesByEventId,
|
||||
generateBracketFromTemplate,
|
||||
setMatchWinner,
|
||||
advanceWinnerTemplate,
|
||||
|
|
@ -288,6 +289,50 @@ 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 are deliberately left alone. seasonParticipantResults is keyed by
|
||||
// sports season, not by event, so a season-wide delete here would wipe the
|
||||
// placements of every other event in the season with nothing to rebuild them —
|
||||
// and on a finalized qualifying season that means permanently zeroed standings.
|
||||
// Reprocess Bracket already rebuilds placements correctly, qualifying path
|
||||
// included, so point the admin at it once the new bracket is in place.
|
||||
const note =
|
||||
completed > 0
|
||||
? " Run Reprocess Bracket after rebuilding to clear the placements those results produced."
|
||||
: "";
|
||||
|
||||
return {
|
||||
success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.${note}`,
|
||||
};
|
||||
} 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");
|
||||
|
||||
|
|
|
|||
|
|
@ -613,6 +613,60 @@ export default function EventBracket({
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle>Clear Bracket</CardTitle>
|
||||
<CardDescription>
|
||||
Delete every match in this bracket so it can be set up again from
|
||||
scratch. Use this when the wrong participants were seeded. Placements
|
||||
are left alone — run Reprocess Bracket after rebuilding to clear any
|
||||
that the discarded results produced.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
method="post"
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Delete all ${matches.length} match(es) in this bracket? Recorded results will be lost.`
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="intent" value="clear-bracket" />
|
||||
{/* The server refuses to discard completed matches unless this is
|
||||
checked. Sending it unconditionally from a hidden field would make
|
||||
that guard unreachable, including for a submit without JS. */}
|
||||
{matches.some((m: { isComplete: boolean }) => m.isComplete) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="confirm-clear-bracket"
|
||||
name="confirm"
|
||||
value="true"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="confirm-clear-bracket" className="font-normal">
|
||||
Yes, discard the results already recorded in this bracket
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" variant="destructive">
|
||||
Clear Bracket
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ====== SETUP PHASE ====== */}
|
||||
{showSetup && (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
convertAmericanOddsToProbability,
|
||||
convertDecimalOddsToProbability,
|
||||
normalizeProbabilities,
|
||||
devigPower,
|
||||
decompressProbability,
|
||||
mapToElo,
|
||||
eloWinProbability,
|
||||
|
|
@ -94,6 +95,64 @@ describe('probability-engine', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('devigPower', () => {
|
||||
/** 27-driver championship market: one -300 favourite and a long tail. */
|
||||
const CHAMPIONSHIP_MARKET = [
|
||||
-300, 450, 700, 1200, 1800, 2500, 4000, 5000, 6000, 8000, 10000, 12000,
|
||||
15000, 20000, 25000, 30000, 40000, 50000, 50000, 50000, 50000, 50000,
|
||||
50000, 50000, 50000, 50000, 50000,
|
||||
].map(convertAmericanOddsToProbability);
|
||||
|
||||
it('sums to exactly 1.0', () => {
|
||||
const devigged = devigPower(CHAMPIONSHIP_MARKET);
|
||||
expect(devigged.reduce((sum, p) => sum + p, 0)).toBeCloseTo(1.0, 10);
|
||||
});
|
||||
|
||||
it('preserves a heavy favourite that proportional devig would gut', () => {
|
||||
const proportional = normalizeProbabilities(CHAMPIONSHIP_MARKET);
|
||||
const devigged = devigPower(CHAMPIONSHIP_MARKET);
|
||||
|
||||
// -300 is 75.0% implied. The book sums to ~1.36, so dividing everyone by
|
||||
// the same overround drops the favourite to ~55%.
|
||||
expect(CHAMPIONSHIP_MARKET[0]).toBeCloseTo(0.75, 4);
|
||||
expect(proportional[0]).toBeCloseTo(0.553, 2);
|
||||
expect(devigged[0]).toBeCloseTo(0.695, 2);
|
||||
expect(devigged[0]).toBeGreaterThan(proportional[0]);
|
||||
});
|
||||
|
||||
it('keeps a near-lock near-certain', () => {
|
||||
const market = [-20000, ...Array(26).fill(50000)].map(convertAmericanOddsToProbability);
|
||||
expect(normalizeProbabilities(market)[0]).toBeCloseTo(0.950, 2);
|
||||
expect(devigPower(market)[0]).toBeCloseTo(0.993, 2);
|
||||
});
|
||||
|
||||
it('preserves the ordering of the field', () => {
|
||||
const devigged = devigPower(CHAMPIONSHIP_MARKET);
|
||||
for (let i = 1; i < devigged.length; i++) {
|
||||
expect(devigged[i]).toBeLessThanOrEqual(devigged[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes a book that is already vig-free', () => {
|
||||
const devigged = devigPower([0.5, 0.3, 0.2]);
|
||||
expect(devigged[0]).toBeCloseTo(0.5, 6);
|
||||
expect(devigged[1]).toBeCloseTo(0.3, 6);
|
||||
expect(devigged[2]).toBeCloseTo(0.2, 6);
|
||||
});
|
||||
|
||||
it('scales a single runner to certainty', () => {
|
||||
expect(devigPower([0.8])).toEqual([1]);
|
||||
});
|
||||
|
||||
it('returns an empty array for an empty market', () => {
|
||||
expect(devigPower([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a uniform field for an all-zero market', () => {
|
||||
devigPower([0, 0, 0]).forEach(p => expect(p).toBeCloseTo(1 / 3, 6));
|
||||
});
|
||||
});
|
||||
|
||||
describe('decompressProbability', () => {
|
||||
it('decompresses championship probabilities with default exponent', () => {
|
||||
expect(decompressProbability(0.154)).toBeCloseTo(2.465, 2); // Colorado 15.4%
|
||||
|
|
|
|||
|
|
@ -109,6 +109,65 @@ export function normalizeProbabilities(probabilities: number[]): number[] {
|
|||
return probabilities.map(p => p / sum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove vig with a power transform instead of proportional division.
|
||||
*
|
||||
* `normalizeProbabilities` divides every runner by the same book sum, which
|
||||
* assumes the overround is spread evenly across the field. In a large futures
|
||||
* market it is not — the juice is concentrated in the longshots, so dividing
|
||||
* proportionally guts the favourite. In a 27-driver championship market with a
|
||||
* book sum of 1.36, a 75.0% implied favourite comes out at 55.3%; with a book
|
||||
* sum of 1.05, a -20000 near-lock comes out at 95.0%.
|
||||
*
|
||||
* The power method instead solves for the exponent `k` where `Σ pᵢ^k = 1`. Since
|
||||
* `p^k` shrinks small probabilities much harder than large ones, the favourite
|
||||
* keeps its shape: the same two markets give 69.5% and 99.3%.
|
||||
*
|
||||
* Solved by bisection — `Σ pᵢ^k` is monotonically decreasing in `k` for
|
||||
* `pᵢ ∈ (0, 1)`, so 60 halvings of `[0.01, 10]` converge well past float
|
||||
* precision.
|
||||
*
|
||||
* @param impliedProbs Raw implied probabilities (as decimals 0-1), vig included
|
||||
* @returns Vig-free probabilities summing to 1.0
|
||||
*
|
||||
* @example
|
||||
* devigPower([0.75, 0.18, 0.12, 0.09]) // favourite stays ~0.70, not ~0.65
|
||||
*/
|
||||
export function devigPower(impliedProbs: number[]): number[] {
|
||||
if (impliedProbs.length === 0) return [];
|
||||
|
||||
// Clamp into the open interval: p^k is only monotonic in k for 0 < p < 1, and
|
||||
// an exact 0 or 1 pins the bisection regardless of the rest of the field.
|
||||
// Clamping also means an all-zero market cannot divide by zero: every runner
|
||||
// ends up at the floor and the field comes back uniform.
|
||||
const clamped = impliedProbs.map((p) =>
|
||||
Math.min(1 - 1e-9, Math.max(1e-9, p))
|
||||
);
|
||||
const sum = clamped.reduce((acc, p) => acc + p, 0);
|
||||
|
||||
// A single runner, or a book with no overround to strip, has no exponent to
|
||||
// find — fall through to proportional scaling.
|
||||
if (clamped.length === 1 || sum <= 1) {
|
||||
return normalizeProbabilities(clamped);
|
||||
}
|
||||
|
||||
let low = 0.01;
|
||||
let high = 10;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const mid = (low + high) / 2;
|
||||
const total = clamped.reduce((acc, p) => acc + Math.pow(p, mid), 0);
|
||||
if (total > 1) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
|
||||
const k = (low + high) / 2;
|
||||
// Renormalize: bisection lands within float noise of 1.0, not exactly on it.
|
||||
return normalizeProbabilities(clamped.map((p) => Math.pow(p, k)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress championship probability to single-game strength
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||
import { AutoRacingSimulator } from "../auto-racing-simulator";
|
||||
import { F1_RACE_POINTS, INDYCAR_RACE_POINTS } from "../race-points";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
|
|
@ -13,18 +14,18 @@ vi.mock("~/models/participant-expected-value", () => ({
|
|||
getAllParticipantEVsForSeason: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── F1 race points (positions 1–10) ─────────────────────────────────────────
|
||||
const F1_RACE_POINTS: Record<number, number> = {
|
||||
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1,
|
||||
};
|
||||
vi.mock("~/models/season-races", () => ({
|
||||
countSeasonRaces: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const DRIVERS = ["d1", "d2", "d3", "d4", "d5"].map((id) => ({ id }));
|
||||
|
||||
function makeEvent(isComplete: boolean, eventType = "race") {
|
||||
return { isComplete, eventType };
|
||||
}
|
||||
const PROB_KEYS = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
] as const;
|
||||
|
||||
function makeSeasonResult(participantId: string, currentPoints: string) {
|
||||
return { participant: { id: participantId }, currentPoints };
|
||||
|
|
@ -34,53 +35,66 @@ function makeEv(participantId: string, sourceOdds: number | null) {
|
|||
return { participantId, sourceOdds };
|
||||
}
|
||||
|
||||
function mockDb(events: ReturnType<typeof makeEvent>[]) {
|
||||
function mockDb(drivers: { id: string }[] = DRIVERS) {
|
||||
return {
|
||||
query: {
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue(DRIVERS),
|
||||
},
|
||||
scoringEvents: {
|
||||
findMany: vi.fn().mockResolvedValue(events),
|
||||
findMany: vi.fn().mockResolvedValue(drivers),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Setup ────────────────────────────────────────────────────────────────────
|
||||
/** Set the race counts the simulator reads from the calendar. */
|
||||
async function setRaceCounts(completed: number, remaining: number) {
|
||||
const { countSeasonRaces } = await import("~/models/season-races");
|
||||
(countSeasonRaces as unknown as MockInstance).mockResolvedValue({
|
||||
completed,
|
||||
remaining,
|
||||
total: completed + remaining,
|
||||
});
|
||||
}
|
||||
|
||||
let db: ReturnType<typeof mockDb>;
|
||||
async function setStandings(results: ReturnType<typeof makeSeasonResult>[]) {
|
||||
const { getSeasonResults } = await import("~/models/participant-season-result");
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue(results);
|
||||
}
|
||||
|
||||
async function setOdds(evs: ReturnType<typeof makeEv>[]) {
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue(evs);
|
||||
}
|
||||
|
||||
async function useDrivers(drivers: { id: string }[]) {
|
||||
const { database } = await import("~/database/context");
|
||||
(database as unknown as MockInstance).mockReturnValue(mockDb(drivers));
|
||||
}
|
||||
|
||||
// ─── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(async () => {
|
||||
const { database } = await import("~/database/context");
|
||||
const { getSeasonResults } = await import("~/models/participant-season-result");
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
|
||||
db = mockDb([]);
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue([]);
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([]);
|
||||
(database as unknown as MockInstance).mockReturnValue(mockDb());
|
||||
await setStandings([]);
|
||||
await setOdds([]);
|
||||
await setRaceCounts(0, 0);
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AutoRacingSimulator", () => {
|
||||
it("throws when no participants are found", async () => {
|
||||
db.query.seasonParticipants.findMany.mockResolvedValue([]);
|
||||
await useDrivers([]);
|
||||
await expect(
|
||||
new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1")
|
||||
).rejects.toThrow(/No participants found/);
|
||||
});
|
||||
|
||||
describe("pre-season path (remainingRaces === 0)", () => {
|
||||
describe("pre-season path (no races run, none remaining)", () => {
|
||||
beforeEach(async () => {
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
// No scoring events → remainingRaces = 0
|
||||
db = mockDb([]);
|
||||
const { database } = await import("~/database/context");
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
await setRaceCounts(0, 0);
|
||||
// Heavy favourite: d1 at −500, all others at +1000
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
|
||||
await setOdds([
|
||||
makeEv("d1", -500),
|
||||
makeEv("d2", 1000),
|
||||
makeEv("d3", 1000),
|
||||
|
|
@ -96,11 +110,7 @@ describe("AutoRacingSimulator", () => {
|
|||
|
||||
it("normalizes each position column to sum to 1.0", async () => {
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
const keys = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
] as const;
|
||||
for (const key of keys) {
|
||||
for (const key of PROB_KEYS) {
|
||||
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
|
||||
}
|
||||
|
|
@ -117,8 +127,7 @@ describe("AutoRacingSimulator", () => {
|
|||
});
|
||||
|
||||
it("drivers without odds get equal fallback probability", async () => {
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([]);
|
||||
await setOdds([]);
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
// With equal weights all 5 drivers should finish 1st roughly equally
|
||||
for (const r of results) {
|
||||
|
|
@ -126,30 +135,145 @@ describe("AutoRacingSimulator", () => {
|
|||
expect(r.probabilities.probFirst).toBeLessThan(0.3);
|
||||
}
|
||||
});
|
||||
|
||||
it("prices an unpriced driver at the longest price in the book", async () => {
|
||||
// d5 has no odds; d2–d4 are +1000 long shots. An unpriced driver used to
|
||||
// be handed 1/N, which rated them above most of the priced field.
|
||||
await setOdds([
|
||||
makeEv("d1", -500),
|
||||
makeEv("d2", 1000),
|
||||
makeEv("d3", 1000),
|
||||
makeEv("d4", 1000),
|
||||
]);
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
const byId = new Map(results.map((r) => [r.participantId, r.probabilities.probFirst]));
|
||||
const unpriced = byId.get("d5") ?? 0;
|
||||
const longShot = byId.get("d2") ?? 0;
|
||||
expect(unpriced).toBeCloseTo(longShot, 1);
|
||||
expect(byId.get("d1") ?? 0).toBeGreaterThan(longShot * 3);
|
||||
});
|
||||
|
||||
it("does not let a thinly priced book flatten the favourite", async () => {
|
||||
// Only one driver is priced. Anchoring the rest to "the longest price"
|
||||
// would make that price the whole book and hand out a uniform field, so
|
||||
// a single-price book keeps the 1/N fallback for the others.
|
||||
// (Readiness requires odds for every participant, so this is a fallback
|
||||
// path rather than a supported configuration.)
|
||||
await setOdds([makeEv("d1", -500)]);
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
const byId = new Map(results.map((r) => [r.participantId, r.probabilities.probFirst]));
|
||||
expect(byId.get("d1") ?? 0).toBeGreaterThan(0.4);
|
||||
expect(byId.get("d2") ?? 0).toBeLessThan(0.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in-season path (remainingRaces > 0)", () => {
|
||||
beforeEach(async () => {
|
||||
const { database } = await import("~/database/context");
|
||||
// 10 completed races, 5 remaining
|
||||
db = mockDb([
|
||||
...Array.from({ length: 10 }, () => makeEvent(true)),
|
||||
...Array.from({ length: 5 }, () => makeEvent(false)),
|
||||
describe("season complete (races run, none remaining)", () => {
|
||||
it("returns the final standings order deterministically", async () => {
|
||||
await setRaceCounts(17, 0);
|
||||
// getSeasonResults returns rows already sorted by championship position.
|
||||
await setStandings([
|
||||
makeSeasonResult("d3", "601"),
|
||||
makeSeasonResult("d1", "480"),
|
||||
makeSeasonResult("d5", "446"),
|
||||
makeSeasonResult("d2", "420"),
|
||||
makeSeasonResult("d4", "398"),
|
||||
]);
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
// Futures odds disagree entirely — they must be ignored once it is over.
|
||||
await setOdds([makeEv("d1", -10000), makeEv("d3", 20000)]);
|
||||
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
const byId = new Map(results.map((r) => [r.participantId, r.probabilities]));
|
||||
|
||||
expect(byId.get("d3")?.probFirst).toBe(1);
|
||||
expect(byId.get("d1")?.probFirst).toBe(0);
|
||||
expect(byId.get("d1")?.probSecond).toBe(1);
|
||||
expect(byId.get("d5")?.probThird).toBe(1);
|
||||
expect(byId.get("d2")?.probFourth).toBe(1);
|
||||
expect(byId.get("d4")?.probFifth).toBe(1);
|
||||
});
|
||||
|
||||
it("ranks the whole field, not just the drivers with standings rows", async () => {
|
||||
// The settled season still has to fill all eight placement columns. Only
|
||||
// ranking the drivers who have a standings row leaves the trailing
|
||||
// columns empty, and the residual normalization then dumps a full 1.0
|
||||
// onto whichever driver happens to be first in the list.
|
||||
await setRaceCounts(17, 0);
|
||||
await useDrivers(Array.from({ length: 10 }, (_, i) => ({ id: `d${i + 1}` })));
|
||||
await setStandings([
|
||||
makeSeasonResult("d3", "601"),
|
||||
makeSeasonResult("d1", "480"),
|
||||
makeSeasonResult("d5", "446"),
|
||||
]);
|
||||
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1", {
|
||||
iterations: 500,
|
||||
});
|
||||
const byId = new Map(results.map((r) => [r.participantId, r.probabilities]));
|
||||
|
||||
expect(byId.get("d3")?.probFirst).toBe(1);
|
||||
expect(byId.get("d1")?.probSecond).toBe(1);
|
||||
expect(byId.get("d5")?.probThird).toBe(1);
|
||||
// No driver may hold two placements at once.
|
||||
for (const probs of byId.values()) {
|
||||
const held = PROB_KEYS.filter((key) => probs[key] > 0.5);
|
||||
expect(held.length).toBeLessThanOrEqual(1);
|
||||
}
|
||||
for (const key of PROB_KEYS) {
|
||||
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to a points-ranked field when there are no standings rows", async () => {
|
||||
await setRaceCounts(17, 0);
|
||||
await setStandings([]);
|
||||
await setOdds([makeEv("d1", -500), makeEv("d2", 1000)]);
|
||||
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1", {
|
||||
iterations: 500,
|
||||
});
|
||||
|
||||
// Still produces a usable distribution rather than all zeroes.
|
||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(total).toBeCloseTo(1.0, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no race calendar", () => {
|
||||
it("warns when the season has championship points but no events", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await setRaceCounts(0, 0);
|
||||
await setStandings([makeSeasonResult("d1", "400"), makeSeasonResult("d2", "300")]);
|
||||
|
||||
await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("championship points but no race calendar")
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("stays quiet for a genuine pre-season with no points yet", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await setRaceCounts(0, 0);
|
||||
await setStandings([]);
|
||||
|
||||
await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("in-season path (races remaining)", () => {
|
||||
beforeEach(async () => {
|
||||
await setRaceCounts(10, 5);
|
||||
});
|
||||
|
||||
it("normalizes each position column to sum to 1.0", async () => {
|
||||
const { getSeasonResults } = await import("~/models/participant-season-result");
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue(
|
||||
DRIVERS.map((d, i) => makeSeasonResult(d.id, String((5 - i) * 50)))
|
||||
);
|
||||
await setStandings(DRIVERS.map((d, i) => makeSeasonResult(d.id, String((5 - i) * 50))));
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
const keys = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
] as const;
|
||||
for (const key of keys) {
|
||||
for (const key of PROB_KEYS) {
|
||||
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
|
||||
}
|
||||
|
|
@ -157,17 +281,9 @@ describe("AutoRacingSimulator", () => {
|
|||
|
||||
it("standings leader ranks higher than a driver far behind when standings dominate", async () => {
|
||||
// 20/25 races done → seasonProgress = 0.8 → standings weighted 80%
|
||||
const { database } = await import("~/database/context");
|
||||
db = mockDb([
|
||||
...Array.from({ length: 20 }, () => makeEvent(true)),
|
||||
...Array.from({ length: 5 }, () => makeEvent(false)),
|
||||
]);
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
|
||||
const { getSeasonResults } = await import("~/models/participant-season-result");
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
await setRaceCounts(20, 5);
|
||||
// d1 leads with 400 pts; d2 is a distant 2nd with 50 pts
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
|
||||
await setStandings([
|
||||
makeSeasonResult("d1", "400"),
|
||||
makeSeasonResult("d2", "50"),
|
||||
makeSeasonResult("d3", "40"),
|
||||
|
|
@ -175,7 +291,7 @@ describe("AutoRacingSimulator", () => {
|
|||
makeSeasonResult("d5", "20"),
|
||||
]);
|
||||
// Futures odds heavily favour d2 (pretend markets disagree)
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
|
||||
await setOdds([
|
||||
makeEv("d1", 5000), // very long shot per futures
|
||||
makeEv("d2", -500), // heavy favourite per futures
|
||||
]);
|
||||
|
|
@ -192,11 +308,7 @@ describe("AutoRacingSimulator", () => {
|
|||
|
||||
it("falls back to odds for all drivers when no standings data exists", async () => {
|
||||
// totalCurrentPoints = 0 → standings signal disabled, odds take over
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
|
||||
makeEv("d1", -500),
|
||||
makeEv("d2", 1000),
|
||||
]);
|
||||
await setOdds([makeEv("d1", -500), makeEv("d2", 1000)]);
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
const fav = results.find((r) => r.participantId === "d1");
|
||||
const longShot = results.find((r) => r.participantId === "d2");
|
||||
|
|
@ -208,27 +320,17 @@ describe("AutoRacingSimulator", () => {
|
|||
});
|
||||
|
||||
it("a driver with 0 points mid-season is not penalized beyond their odds weight", async () => {
|
||||
// Use 2 completed / 20 remaining → early season, standings gap is small
|
||||
const { database } = await import("~/database/context");
|
||||
db = mockDb([
|
||||
...Array.from({ length: 2 }, () => makeEvent(true)),
|
||||
...Array.from({ length: 20 }, () => makeEvent(false)),
|
||||
]);
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
|
||||
const { getSeasonResults } = await import("~/models/participant-season-result");
|
||||
// Early season → standings gap is small
|
||||
await setRaceCounts(2, 20);
|
||||
// d1-d4 have a modest lead; d5 is absent (0 pts, new entry)
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
|
||||
await setStandings([
|
||||
makeSeasonResult("d1", "10"),
|
||||
makeSeasonResult("d2", "8"),
|
||||
makeSeasonResult("d3", "6"),
|
||||
makeSeasonResult("d4", "4"),
|
||||
// d5 intentionally absent → falls back to odds weight
|
||||
]);
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
|
||||
makeEv("d5", -500), // strong odds favourite despite 0 pts
|
||||
]);
|
||||
await setOdds([makeEv("d5", -500)]); // strong odds favourite despite 0 pts
|
||||
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
// d5 should win championships at a non-trivial rate given their strong odds weight
|
||||
|
|
@ -240,9 +342,8 @@ describe("AutoRacingSimulator", () => {
|
|||
|
||||
it("emits a warning when participants are missing from standings", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { getSeasonResults } = await import("~/models/participant-season-result");
|
||||
// Only 3 of 5 drivers have standings rows
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
|
||||
await setStandings([
|
||||
makeSeasonResult("d1", "100"),
|
||||
makeSeasonResult("d2", "80"),
|
||||
makeSeasonResult("d3", "60"),
|
||||
|
|
@ -253,19 +354,97 @@ describe("AutoRacingSimulator", () => {
|
|||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it("schedule_event entries are excluded from race counts", async () => {
|
||||
const { database } = await import("~/database/context");
|
||||
// 5 real races + 3 schedule_events (should be ignored)
|
||||
db = mockDb([
|
||||
...Array.from({ length: 5 }, () => makeEvent(true)),
|
||||
...Array.from({ length: 3 }, () => makeEvent(false, "schedule_event")),
|
||||
makeEvent(false), // 1 real remaining
|
||||
]);
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
// Should not throw and should use seasonProgress = 5/6
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
expect(results).toHaveLength(5);
|
||||
describe("IndyCar regression: near-clinched championship leader", () => {
|
||||
// The reported bug. A 121-point lead with 2 races left is arithmetically
|
||||
// unassailable (max 100 available, and the leader banks at least 10), but
|
||||
// the simulator skipped `schedule_event` rows, saw zero remaining races,
|
||||
// took the pre-season branch and echoed stale futures odds at ~55%.
|
||||
const POINTS = [
|
||||
601, 480, 446, 420, 398, 372, 350, 331, 315, 300, 288, 270, 255, 240,
|
||||
228, 215, 200, 188, 175, 160, 148, 135, 120, 105, 90, 70, 55,
|
||||
];
|
||||
const ODDS = [
|
||||
-300, 450, 700, 1200, 1800, 2500, 4000, 5000, 6000, 8000, 10000, 12000,
|
||||
15000, 20000, 25000, 30000, 40000, 50000, 50000, 50000, 50000, 50000,
|
||||
50000, 50000, 50000, 50000, 50000,
|
||||
];
|
||||
const FIELD = POINTS.map((_, i) => ({ id: `driver${i}` }));
|
||||
|
||||
beforeEach(async () => {
|
||||
await useDrivers(FIELD);
|
||||
await setStandings(FIELD.map((d, i) => makeSeasonResult(d.id, String(POINTS[i]))));
|
||||
await setOdds(FIELD.map((d, i) => makeEv(d.id, ODDS[i])));
|
||||
});
|
||||
|
||||
it("gives the leader ~100% with 2 of 17 races left", async () => {
|
||||
await setRaceCounts(15, 2);
|
||||
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
|
||||
iterations: 2000,
|
||||
});
|
||||
const leader = results.find((r) => r.participantId === "driver0");
|
||||
expect(leader).toBeDefined();
|
||||
if (!leader) return;
|
||||
expect(leader.probabilities.probFirst).toBeGreaterThan(0.99);
|
||||
});
|
||||
|
||||
it("without a calendar it can only echo the stale odds — the shape of the bug", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await setRaceCounts(0, 0);
|
||||
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
|
||||
iterations: 2000,
|
||||
});
|
||||
const leader = results.find((r) => r.participantId === "driver0");
|
||||
expect(leader).toBeDefined();
|
||||
if (!leader) return;
|
||||
// Nowhere near the truth, which is exactly why the no-calendar warning
|
||||
// above exists. Power devig keeps the -300 favourite well clear of the
|
||||
// 55% that proportional devig produced, but odds alone cannot see a
|
||||
// 121-point lead.
|
||||
expect(leader.probabilities.probFirst).toBeLessThan(0.9);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("championship points but no race calendar")
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("still gives the leader a commanding lead with 5 races left", async () => {
|
||||
await setRaceCounts(12, 5);
|
||||
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
|
||||
iterations: 2000,
|
||||
});
|
||||
const leader = results.find((r) => r.participantId === "driver0");
|
||||
expect(leader).toBeDefined();
|
||||
if (!leader) return;
|
||||
expect(leader.probabilities.probFirst).toBeGreaterThan(0.9);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("race points tables", () => {
|
||||
it("IndyCar pays 50 for a win and scores down to P26", () => {
|
||||
expect(INDYCAR_RACE_POINTS[1]).toBe(50);
|
||||
expect(INDYCAR_RACE_POINTS[2]).toBe(40);
|
||||
expect(INDYCAR_RACE_POINTS[25]).toBe(5);
|
||||
expect(INDYCAR_RACE_POINTS[26]).toBe(5);
|
||||
expect(INDYCAR_RACE_POINTS[27]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("F1 pays 25 for a win and scores down to P10", () => {
|
||||
expect(F1_RACE_POINTS[1]).toBe(25);
|
||||
expect(F1_RACE_POINTS[10]).toBe(1);
|
||||
expect(F1_RACE_POINTS[11]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("both tables decrease monotonically so the points loop never truncates early", () => {
|
||||
for (const table of [F1_RACE_POINTS, INDYCAR_RACE_POINTS]) {
|
||||
const positions = Object.keys(table).map(Number).toSorted((a, b) => a - b);
|
||||
// Contiguous from P1, no gaps — the award loop breaks at the first 0.
|
||||
positions.forEach((pos, i) => expect(pos).toBe(i + 1));
|
||||
for (let i = 1; i < positions.length; i++) {
|
||||
expect(table[positions[i]]).toBeLessThanOrEqual(table[positions[i - 1]]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||
import { LLWSSimulator } from "../llws-simulator";
|
||||
import {
|
||||
LLWSSimulator,
|
||||
makePlayGame,
|
||||
playCrossoverGame,
|
||||
readBracketSlots,
|
||||
} from "../llws-simulator";
|
||||
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
|
||||
import type { SimulationResult } from "../types";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
|
|
@ -27,28 +34,168 @@ function makeEvRows(ids: string[], opts: { includeOdds?: boolean } = {}) {
|
|||
}));
|
||||
}
|
||||
|
||||
// ─── Bracket fixtures ─────────────────────────────────────────────────────────
|
||||
|
||||
/** The subset of playoff_matches columns the simulator reads. */
|
||||
type PlayoffMatchRow = {
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
isComplete: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_MATCH: PlayoffMatchRow = {
|
||||
round: "",
|
||||
matchNumber: 0,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* A freshly generated, fully seeded llws_20 bracket with no results recorded.
|
||||
*
|
||||
* Mirrors generateLLWS20Bracket: U.S. matches take the low match numbers
|
||||
* (Opening Round 1–4, Winners Round 2 1–2), International the high ones
|
||||
* (Opening Round 5–8, Winners Round 2 3–4). Byes sit at participant1 of
|
||||
* Winners Round 2. Slot order per side is ids[0..7] opening, ids[8..9] byes.
|
||||
*/
|
||||
function seededBracket(): PlayoffMatchRow[] {
|
||||
const matches: PlayoffMatchRow[] = [];
|
||||
const sides = [
|
||||
{ ids: US_IDS, openingOffset: 0, wr2Offset: 0 },
|
||||
{ ids: INTL_IDS, openingOffset: 4, wr2Offset: 2 },
|
||||
];
|
||||
|
||||
for (const { ids, openingOffset, wr2Offset } of sides) {
|
||||
for (let local = 1; local <= 4; local++) {
|
||||
matches.push({
|
||||
...EMPTY_MATCH,
|
||||
round: "Opening Round",
|
||||
matchNumber: local + openingOffset,
|
||||
participant1Id: ids[(local - 1) * 2],
|
||||
participant2Id: ids[(local - 1) * 2 + 1],
|
||||
});
|
||||
}
|
||||
for (let local = 1; local <= 2; local++) {
|
||||
matches.push({
|
||||
...EMPTY_MATCH,
|
||||
round: "Winners Round 2",
|
||||
matchNumber: local + wr2Offset,
|
||||
participant1Id: ids[8 + (local - 1)],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a bracket match complete, the way the scoring flow would once the game is
|
||||
* played. `loserId` is passed explicitly for matches whose second slot is filled by
|
||||
* advancement rather than by the initial seeding.
|
||||
*/
|
||||
function completeMatch(
|
||||
matches: PlayoffMatchRow[],
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
winnerId: string,
|
||||
loserId: string
|
||||
): PlayoffMatchRow[] {
|
||||
const existing = matches.find((m) => m.round === round && m.matchNumber === matchNumber);
|
||||
const filled: PlayoffMatchRow = {
|
||||
...(existing ?? { ...EMPTY_MATCH, round, matchNumber }),
|
||||
participant1Id: existing?.participant1Id ?? winnerId,
|
||||
participant2Id: existing?.participant2Id ?? loserId,
|
||||
winnerId,
|
||||
loserId,
|
||||
isComplete: true,
|
||||
};
|
||||
return [...matches.filter((m) => m !== existing), filled];
|
||||
}
|
||||
|
||||
/** Normalized (vig-removed) market probability for each team in an odds board. */
|
||||
function marketProbabilities(odds: number[]): number[] {
|
||||
const raw = odds.map(convertAmericanOddsToProbability);
|
||||
const sum = raw.reduce((a, b) => a + b, 0);
|
||||
return raw.map((p) => p / sum);
|
||||
}
|
||||
|
||||
/** Look up one participant's simulated probabilities, failing loudly if absent. */
|
||||
function probsFor(results: SimulationResult[], participantId: string) {
|
||||
const match = results.find((r) => r.participantId === participantId);
|
||||
if (!match) throw new Error(`No simulation result for ${participantId}`);
|
||||
return match.probabilities;
|
||||
}
|
||||
|
||||
/** Equal-strength Team records for direct (non-Monte-Carlo) helper tests. */
|
||||
const TEST_TEAMS = new Map(
|
||||
ALL_IDS.map((id) => [
|
||||
id,
|
||||
{
|
||||
participantId: id,
|
||||
side: id.startsWith("us") ? ("US" as const) : ("Intl" as const),
|
||||
elo: 1500,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
function team(participantId: string) {
|
||||
const found = TEST_TEAMS.get(participantId);
|
||||
if (!found) throw new Error(`No test team for ${participantId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("LLWSSimulator", () => {
|
||||
let mockDb: { select: MockInstance };
|
||||
let mockDb: {
|
||||
select: MockInstance;
|
||||
query: {
|
||||
scoringEvents: { findMany: MockInstance };
|
||||
playoffMatches: { findMany: MockInstance };
|
||||
};
|
||||
};
|
||||
let selectCallCount: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
selectCallCount = 0;
|
||||
const { database } = await import("~/database/context");
|
||||
mockDb = { select: vi.fn() };
|
||||
mockDb = {
|
||||
select: vi.fn(),
|
||||
query: {
|
||||
scoringEvents: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
};
|
||||
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
||||
});
|
||||
|
||||
function setupMockDb(
|
||||
participants: { id: string; name?: string; externalId: string | null }[],
|
||||
evRows: { participantId: string; sourceOdds: number | null }[]
|
||||
evRows: { participantId: string; sourceOdds: number | null }[],
|
||||
bracketMatches?: Partial<PlayoffMatchRow>[]
|
||||
) {
|
||||
selectCallCount = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
const callIndex = selectCallCount++;
|
||||
const data = callIndex === 0 ? participants : evRows;
|
||||
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
|
||||
});
|
||||
|
||||
if (bracketMatches) {
|
||||
mockDb.query.scoringEvents.findMany.mockResolvedValue([
|
||||
{ id: "event-1", createdAt: new Date("2026-08-01") },
|
||||
]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(
|
||||
bracketMatches.map((m) => ({ ...EMPTY_MATCH, ...m }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultParticipants(mode: "randomized" | "fixed" = "randomized") {
|
||||
|
|
@ -311,4 +458,424 @@ describe("LLWSSimulator", () => {
|
|||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Futures calibration ───────────────────────────────────────────────────
|
||||
//
|
||||
// A championship future already contains the ~6 wins needed to lift the trophy.
|
||||
// Feeding it straight into a single game (p1 / (p1 + p2)) makes every game as
|
||||
// lopsided as the whole tournament and compounds the favorite's edge round after
|
||||
// round, which inflated favorites badly. The simulator decompresses futures to Elo
|
||||
// first, so re-simulating a random draw should hand back roughly the prices it was
|
||||
// given rather than a much more extreme distribution.
|
||||
|
||||
describe("futures calibration", () => {
|
||||
// A representative LLWS board: a clear favorite, a long tail.
|
||||
const BOARD = [
|
||||
200, 750, 900, 1200, 1600, 2000, 2500, 3000, 4000, 6000,
|
||||
350, 800, 1000, 1400, 1800, 2200, 2800, 3500, 5000, 8000,
|
||||
];
|
||||
|
||||
function boardEvRows() {
|
||||
return ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: BOARD[i] }));
|
||||
}
|
||||
|
||||
it("reproduces the favorite's championship price instead of inflating it", async () => {
|
||||
setupMockDb(defaultParticipants(), boardEvRows());
|
||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
||||
|
||||
const market = marketProbabilities(BOARD);
|
||||
const simulated = probsFor(results, "us-1").probFirst;
|
||||
|
||||
// The favorite prices around 22%. The old raw-futures model simulated ~45%.
|
||||
expect(simulated).toBeCloseTo(market[0], 1);
|
||||
expect(simulated).toBeLessThan(market[0] + 0.06);
|
||||
});
|
||||
|
||||
it("keeps the whole field close to its priced championship probability", async () => {
|
||||
setupMockDb(defaultParticipants(), boardEvRows());
|
||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
||||
|
||||
const market = marketProbabilities(BOARD);
|
||||
const errors = ALL_IDS.map((id, i) => probsFor(results, id).probFirst - market[i]);
|
||||
const rmse = Math.sqrt(errors.reduce((s, e) => s + e * e, 0) / errors.length);
|
||||
|
||||
// Calibrated RMSE is ~0.003; the old model sat around 0.06.
|
||||
expect(rmse).toBeLessThan(0.02);
|
||||
});
|
||||
|
||||
// Regression: the previous mapping rescaled every field onto a fixed 1250–1750
|
||||
// Elo span, which discarded how spread out the board actually was and pulled a
|
||||
// nearly flat field apart into contenders and no-hopers the market never implied.
|
||||
const TIGHT_BOARD = Array.from({ length: 20 }, (_, i) => 1500 + i * 35);
|
||||
|
||||
it("does not inflate the favorite on a tightly priced board", async () => {
|
||||
setupMockDb(
|
||||
defaultParticipants(),
|
||||
ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: TIGHT_BOARD[i] }))
|
||||
);
|
||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
||||
|
||||
const market = marketProbabilities(TIGHT_BOARD);
|
||||
const simulated = probsFor(results, "us-1").probFirst;
|
||||
|
||||
// The favorite prices near 6%. A fixed-span mapping simulated it around 13%,
|
||||
// so the band is wide enough for Monte Carlo noise but nowhere near that.
|
||||
expect(Math.abs(simulated - market[0])).toBeLessThan(0.015);
|
||||
});
|
||||
|
||||
it("keeps a tightly priced field tight", async () => {
|
||||
setupMockDb(
|
||||
defaultParticipants(),
|
||||
ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: TIGHT_BOARD[i] }))
|
||||
);
|
||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
||||
const probs = ALL_IDS.map((id) => probsFor(results, id).probFirst);
|
||||
|
||||
// Every team prices between roughly 4% and 6%, so nobody should run away with
|
||||
// it and nobody should be written off.
|
||||
expect(Math.max(...probs)).toBeLessThan(0.09);
|
||||
expect(Math.min(...probs)).toBeGreaterThan(0.02);
|
||||
});
|
||||
|
||||
it("rates a team with no odds entered around the middle of the field", async () => {
|
||||
// us-5 is priced mid-board; blanking its odds should not move it far. The old
|
||||
// 1500 fallback was the centre of the Elo scale rather than of the field, which
|
||||
// promoted an unpriced team to roughly 6th of 20.
|
||||
const priced = ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: BOARD[i] }));
|
||||
setupMockDb(defaultParticipants(), priced);
|
||||
const withOdds = probsFor(
|
||||
await new LLWSSimulator(20_000).simulate("season-1"), "us-5"
|
||||
).probFirst;
|
||||
|
||||
const blanked = priced.map((row) =>
|
||||
row.participantId === "us-5" ? { ...row, sourceOdds: null } : row
|
||||
);
|
||||
setupMockDb(defaultParticipants(), blanked);
|
||||
const withoutOdds = probsFor(
|
||||
await new LLWSSimulator(20_000).simulate("season-1"), "us-5"
|
||||
).probFirst;
|
||||
|
||||
// Priced 5th of 20, so the median rating should land it in the same territory.
|
||||
expect(withoutOdds).toBeGreaterThan(withOdds / 2);
|
||||
expect(withoutOdds).toBeLessThan(withOdds * 2);
|
||||
});
|
||||
|
||||
it("does not starve longshots of championship probability", async () => {
|
||||
setupMockDb(defaultParticipants(), boardEvRows());
|
||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
||||
|
||||
// The longest shot on the board prices near 0.8%. Compounding raw futures drove
|
||||
// teams like this to essentially zero.
|
||||
const longshot = probsFor(results, "intl-10").probFirst;
|
||||
expect(longshot).toBeGreaterThan(0.002);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bracket-aware mode ────────────────────────────────────────────────────
|
||||
|
||||
describe("bracket-aware mode", () => {
|
||||
it("uses the real draw rather than shuffling when a bracket is seeded", async () => {
|
||||
// With no odds every team is equally strong, so the only edge is structural:
|
||||
// the two bye teams skip the Opening Round. Under a randomized draw every team
|
||||
// gets a bye equally often and this difference disappears.
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), seededBracket());
|
||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
||||
|
||||
const byeTeam = probsFor(results, "us-9").probFirst;
|
||||
const openingTeam = probsFor(results, "us-1").probFirst;
|
||||
expect(byeTeam).toBeGreaterThan(openingTeam);
|
||||
});
|
||||
|
||||
it("still returns a full, normalized distribution in bracket mode", async () => {
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), seededBracket());
|
||||
const results = await new LLWSSimulator(5_000).simulate("season-1");
|
||||
|
||||
expect(results).toHaveLength(20);
|
||||
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
|
||||
expect(results.reduce((s, r) => s + r.probabilities.probThird, 0)).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("falls back to a randomized draw when the bracket has no participants seeded", async () => {
|
||||
const unseeded = seededBracket().map((m) => ({
|
||||
...m,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
}));
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), unseeded);
|
||||
const results = await new LLWSSimulator(5_000).simulate("season-1");
|
||||
|
||||
expect(results).toHaveLength(20);
|
||||
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("uses the most recent bracket event when several exist", async () => {
|
||||
// A stale event's matches would carry no draw, silently reverting to a
|
||||
// randomized one and discarding every recorded result.
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), seededBracket());
|
||||
mockDb.query.scoringEvents.findMany.mockResolvedValue([
|
||||
{ id: "stale-event", createdAt: new Date("2026-07-01") },
|
||||
{ id: "event-1", createdAt: new Date("2026-08-01") },
|
||||
]);
|
||||
|
||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
||||
|
||||
// Bracket mode is in force, so the fixed bye slots still show their advantage.
|
||||
expect(probsFor(results, "us-9").probFirst).toBeGreaterThan(
|
||||
probsFor(results, "us-1").probFirst
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the bracket is only partially seeded", async () => {
|
||||
// participant1Id/participant2Id are ON DELETE SET NULL, so removing and
|
||||
// re-adding one participant mid-tournament empties a single slot. Falling back
|
||||
// to a randomized draw there would put eliminated teams back in contention.
|
||||
const holed = seededBracket().map((m) =>
|
||||
m.round === "Opening Round" && m.matchNumber === 3
|
||||
? { ...m, participant2Id: null }
|
||||
: m
|
||||
);
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), holed);
|
||||
await expect(new LLWSSimulator(100).simulate("season-1")).rejects.toThrow(
|
||||
/partially seeded \(19 of 20/
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the bracket seeds the same team into two slots", async () => {
|
||||
const duplicated = seededBracket().map((m) =>
|
||||
m.round === "Opening Round" && m.matchNumber === 2
|
||||
? { ...m, participant1Id: "us-1" } // us-1 already opens match 1
|
||||
: m
|
||||
);
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), duplicated);
|
||||
await expect(new LLWSSimulator(100).simulate("season-1")).rejects.toThrow(
|
||||
/more than one slot/
|
||||
);
|
||||
});
|
||||
|
||||
it("takes sides from the bracket, not externalId, once a bracket is seeded", async () => {
|
||||
// The bracket is authoritative about the draw, so an externalId the pre-bracket
|
||||
// path would reject must not block a season that already has a real bracket.
|
||||
const participants = [
|
||||
...US_IDS.map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
|
||||
...INTL_IDS.slice(0, 9).map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||
{ id: "intl-10", name: "Team intl-10", externalId: "CANADA" },
|
||||
];
|
||||
setupMockDb(participants, makeEvRows(ALL_IDS), seededBracket());
|
||||
const results = await new LLWSSimulator(5_000).simulate("season-1");
|
||||
|
||||
expect(results).toHaveLength(20);
|
||||
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("throws when the bracket is seeded with a participant outside the season", async () => {
|
||||
const foreign = seededBracket().map((m) =>
|
||||
m.round === "Opening Round" && m.matchNumber === 1
|
||||
? { ...m, participant1Id: "stranger-1" }
|
||||
: m
|
||||
);
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), foreign);
|
||||
await expect(new LLWSSimulator(100).simulate("season-1")).rejects.toThrow(
|
||||
/not in this sports season/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Completed results ─────────────────────────────────────────────────────
|
||||
//
|
||||
// The core of the fix: games already played must stick across every iteration
|
||||
// instead of being re-simulated from scratch.
|
||||
|
||||
describe("completed results", () => {
|
||||
// us-1 is a strong favorite, so a recorded loss should visibly move its number.
|
||||
const favouredEvRows = ALL_IDS.map((participantId, i) => ({
|
||||
participantId,
|
||||
sourceOdds: participantId === "us-1" ? 200 : 1000 + i * 200,
|
||||
}));
|
||||
|
||||
async function probFirstFor(id: string, matches: PlayoffMatchRow[]): Promise<number> {
|
||||
setupMockDb(defaultParticipants(), favouredEvRows, matches);
|
||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
||||
return probsFor(results, id).probFirst;
|
||||
}
|
||||
|
||||
it("drops a favorite's championship probability after a recorded loss", async () => {
|
||||
const before = await probFirstFor("us-1", seededBracket());
|
||||
|
||||
// us-1 loses its Opening Round game. In double elimination that is not an
|
||||
// elimination — it drops to the elimination bracket — but it now needs a much
|
||||
// longer path, so its title probability must fall.
|
||||
const afterLoss = completeMatch(seededBracket(), "Opening Round", 1, "us-2", "us-1");
|
||||
const after = await probFirstFor("us-1", afterLoss);
|
||||
|
||||
expect(after).toBeLessThan(before);
|
||||
// Not merely noise: a first-round loss is a real blow to a favorite.
|
||||
expect(after).toBeLessThan(before * 0.8);
|
||||
// But not elimination either — the elimination bracket still reaches the final.
|
||||
expect(after).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("raises the opponent's championship probability after that same win", async () => {
|
||||
const before = await probFirstFor("us-2", seededBracket());
|
||||
const afterWin = completeMatch(seededBracket(), "Opening Round", 1, "us-2", "us-1");
|
||||
const after = await probFirstFor("us-2", afterWin);
|
||||
|
||||
expect(after).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it("zeroes out a team that has been eliminated (two recorded losses)", async () => {
|
||||
// Fill the elimination-bracket game the way advancement would: the Opening
|
||||
// Round 1 and Opening Round 4 losers meet in Elimination Round 1 match 2.
|
||||
let matches = seededBracket();
|
||||
matches = completeMatch(matches, "Opening Round", 1, "us-2", "us-1");
|
||||
matches = completeMatch(matches, "Opening Round", 4, "us-7", "us-8");
|
||||
matches = completeMatch(matches, "Elimination Round 1", 2, "us-8", "us-1");
|
||||
|
||||
setupMockDb(defaultParticipants(), favouredEvRows, matches);
|
||||
const results = await new LLWSSimulator(5_000).simulate("season-1");
|
||||
const eliminated = probsFor(results, "us-1");
|
||||
|
||||
// A second loss is final — every placement tier must be exactly zero.
|
||||
for (const value of Object.values(eliminated)) {
|
||||
expect(value).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the distribution normalized once results have been recorded", async () => {
|
||||
let matches = seededBracket();
|
||||
matches = completeMatch(matches, "Opening Round", 1, "us-2", "us-1");
|
||||
matches = completeMatch(matches, "Opening Round", 5, "intl-2", "intl-1");
|
||||
|
||||
setupMockDb(defaultParticipants(), favouredEvRows, matches);
|
||||
const results = await new LLWSSimulator(5_000).simulate("season-1");
|
||||
|
||||
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
|
||||
expect(results.reduce((s, r) => s + r.probabilities.probSecond, 0)).toBeCloseTo(1.0, 1);
|
||||
expect(results.reduce((s, r) => s + r.probabilities.probFifth, 0)).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("ignores a completed result whose participants never reach that game", async () => {
|
||||
// A corrupt row: Elimination Round 1 match 2 takes the Opening Round 1 and 4
|
||||
// losers, so a team from Opening Round 3 can never appear there. The game must
|
||||
// be simulated instead of desynchronising the rest of the bracket.
|
||||
const matches = completeMatch(
|
||||
seededBracket(), "Elimination Round 1", 2, "us-5", "us-6"
|
||||
);
|
||||
setupMockDb(defaultParticipants(), favouredEvRows, matches);
|
||||
const results = await new LLWSSimulator(5_000).simulate("season-1");
|
||||
|
||||
expect(results).toHaveLength(20);
|
||||
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── Result-honoring rules ─────────────────────────────────────────────────
|
||||
//
|
||||
// Tested directly rather than through the Monte Carlo output: the aggregate only
|
||||
// shows these effects diluted by how often a given pairing occurs, which is too
|
||||
// noisy to assert on.
|
||||
|
||||
describe("result-honoring rules", () => {
|
||||
function bracketOf(matches: PlayoffMatchRow[]) {
|
||||
const bracket = readBracketSlots(matches, TEST_TEAMS);
|
||||
if (!bracket) throw new Error("Expected the seeded bracket to be readable");
|
||||
return bracket;
|
||||
}
|
||||
|
||||
const us1 = team("us-1");
|
||||
const us2 = team("us-2");
|
||||
const us5 = team("us-5");
|
||||
|
||||
it("replays a completed game from its recorded result", () => {
|
||||
const bracket = bracketOf(
|
||||
completeMatch(seededBracket(), "Opening Round", 1, "us-2", "us-1")
|
||||
);
|
||||
const play = makePlayGame(0, bracket, 1_000);
|
||||
|
||||
// Deterministic across repeats — no coin flip is involved any more.
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const result = play("Opening Round", 1, us1, us2);
|
||||
expect(result.winner.participantId).toBe("us-2");
|
||||
expect(result.loser.participantId).toBe("us-1");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the recorded winner regardless of which slot it arrives in", () => {
|
||||
const bracket = bracketOf(
|
||||
completeMatch(seededBracket(), "Opening Round", 1, "us-1", "us-2")
|
||||
);
|
||||
const play = makePlayGame(0, bracket, 1_000);
|
||||
// Same game, arguments swapped.
|
||||
expect(play("Opening Round", 1, us2, us1).winner.participantId).toBe("us-1");
|
||||
});
|
||||
|
||||
it("simulates a game that has not been played yet", () => {
|
||||
const play = makePlayGame(0, bracketOf(seededBracket()), 1_000);
|
||||
const winners = new Set(
|
||||
Array.from({ length: 200 }, () => play("Opening Round", 1, us1, us2).winner.participantId)
|
||||
);
|
||||
// Equal Elo, so both outcomes must show up.
|
||||
expect(winners).toEqual(new Set(["us-1", "us-2"]));
|
||||
});
|
||||
|
||||
it("ignores a recorded result between teams that did not arrive at the game", () => {
|
||||
const bracket = bracketOf(
|
||||
completeMatch(seededBracket(), "Opening Round", 1, "us-5", "us-2")
|
||||
);
|
||||
const play = makePlayGame(0, bracket, 1_000);
|
||||
// us-5 belongs to a different Opening Round game, so this row cannot apply to
|
||||
// the us-1 v us-2 pairing — it must be simulated instead.
|
||||
const winners = new Set(
|
||||
Array.from({ length: 200 }, () => play("Opening Round", 1, us1, us2).winner.participantId)
|
||||
);
|
||||
expect(winners).toEqual(new Set(["us-1", "us-2"]));
|
||||
});
|
||||
|
||||
it("reads U.S. and International games from their own match numbers", () => {
|
||||
// The same side-local game number maps to different global matches per side:
|
||||
// U.S. Opening Round 1 is match 1, International Opening Round 1 is match 5.
|
||||
const bracket = bracketOf(
|
||||
completeMatch(seededBracket(), "Opening Round", 5, "intl-2", "intl-1")
|
||||
);
|
||||
const intl1 = team("intl-1");
|
||||
const intl2 = team("intl-2");
|
||||
|
||||
expect(makePlayGame(1, bracket, 1_000)("Opening Round", 1, intl1, intl2).winner.participantId)
|
||||
.toBe("intl-2");
|
||||
|
||||
// The U.S. side's Opening Round 1 is untouched by that result.
|
||||
const usWinners = new Set(
|
||||
Array.from({ length: 200 }, () =>
|
||||
makePlayGame(0, bracket, 1_000)("Opening Round", 1, us1, us2).winner.participantId
|
||||
)
|
||||
);
|
||||
expect(usWinners).toEqual(new Set(["us-1", "us-2"]));
|
||||
});
|
||||
|
||||
it("honors a completed World Championship", () => {
|
||||
// The two crossover games are single shared matches, numbered 1.
|
||||
const bracket = bracketOf(
|
||||
completeMatch(seededBracket(), "World Championship", 1, "us-3", "intl-4")
|
||||
);
|
||||
const us3 = team("us-3");
|
||||
const intl4 = team("intl-4");
|
||||
|
||||
const result = playCrossoverGame("World Championship", bracket, 1_000, us3, intl4);
|
||||
expect(result.winner.participantId).toBe("us-3");
|
||||
expect(result.loser.participantId).toBe("intl-4");
|
||||
});
|
||||
|
||||
it("simulates the crossover game when different finalists arrive", () => {
|
||||
const bracket = bracketOf(
|
||||
completeMatch(seededBracket(), "World Championship", 1, "us-3", "intl-4")
|
||||
);
|
||||
const intl5 = team("intl-5");
|
||||
const winners = new Set(
|
||||
Array.from({ length: 200 }, () =>
|
||||
playCrossoverGame("World Championship", bracket, 1_000, us5, intl5).winner.participantId
|
||||
)
|
||||
);
|
||||
expect(winners).toEqual(new Set(["us-5", "intl-5"]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,16 +7,17 @@
|
|||
*
|
||||
* Algorithm:
|
||||
* 1. Load participants + current championship points from DB
|
||||
* 2. Count remaining races (incomplete non-schedule scoring events)
|
||||
* 2. Count completed/remaining races (see `countSeasonRaces`)
|
||||
* 3. Convert sourceOdds → vig-removed probability weights
|
||||
* 4. Two simulation paths:
|
||||
* a. remainingRaces === 0 (pre-season): pure weighted draws from odds
|
||||
* b. remainingRaces > 0 (in-season): simulate each remaining race,
|
||||
* starting from real standings, awarding series-specific points per finish
|
||||
* 4. Two paths:
|
||||
* a. pre-season (no races run yet): pure weighted draws from odds
|
||||
* b. otherwise: simulate each remaining race, starting from real standings,
|
||||
* awarding series-specific points per finish. With zero races left this
|
||||
* awards nothing and simply ranks the final standings.
|
||||
* 5. Convert finish counts → probability distributions + normalize columns
|
||||
*
|
||||
* Notes:
|
||||
* - Drivers without odds fall back to uniform probability (1/N)
|
||||
* - Drivers without odds are priced at the longest price in the book
|
||||
* - PARTICIPANT_VOLATILITY and RACE_NOISE only apply to the in-season path
|
||||
*/
|
||||
|
||||
|
|
@ -25,6 +26,8 @@ import { eq } from "drizzle-orm";
|
|||
import * as schema from "~/database/schema";
|
||||
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
||||
import { getSeasonResults } from "~/models/participant-season-result";
|
||||
import { countSeasonRaces } from "~/models/season-races";
|
||||
import { devigPower } from "~/services/probability-engine";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
import { positiveConfigNumber } from "./config-access";
|
||||
|
||||
|
|
@ -126,20 +129,24 @@ export class AutoRacingSimulator implements Simulator {
|
|||
const currentPointsMap = new Map<string, number>(
|
||||
seasonResults.map((r) => [r.participant.id, parseFloat(r.currentPoints ?? "0")])
|
||||
);
|
||||
const totalCurrentPoints = [...currentPointsMap.values()].reduce((a, b) => a + b, 0);
|
||||
|
||||
// 3. Count remaining and completed races in a single pass (exclude schedule_event entries)
|
||||
const allEvents = await db.query.scoringEvents.findMany({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
let remainingRaces = 0;
|
||||
let completedRaces = 0;
|
||||
for (const e of allEvents) {
|
||||
if (e.eventType === "schedule_event") continue;
|
||||
if (e.isComplete) completedRaces++;
|
||||
else remainingRaces++;
|
||||
// 3. Count remaining and completed races
|
||||
const { completed: completedRaces, remaining: remainingRaces, total: totalRaces } =
|
||||
await countSeasonRaces(sportsSeasonId);
|
||||
|
||||
// A season with championship points but no calendar cannot be simulated
|
||||
// forward — it silently degrades into "whatever the futures odds said",
|
||||
// which ignores a runaway leader's points lead entirely.
|
||||
if (totalRaces === 0 && totalCurrentPoints > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[AutoRacingSimulator] Season ${sportsSeasonId} has championship points but no race calendar — ` +
|
||||
`add the schedule on the admin events page. Falling back to futures odds, which ignores the standings.`
|
||||
);
|
||||
}
|
||||
|
||||
// 0.0 = pre-season, 1.0 = all races done
|
||||
const totalRaces = completedRaces + remainingRaces;
|
||||
const seasonProgress = totalRaces > 0 ? completedRaces / totalRaces : 0;
|
||||
|
||||
// 4. Load EV data for championship win probabilities
|
||||
|
|
@ -149,22 +156,33 @@ export class AutoRacingSimulator implements Simulator {
|
|||
const ids = participants.map((p) => p.id);
|
||||
|
||||
// 5. Build raw implied championship win probabilities from odds.
|
||||
// americanToImpliedProb includes vig (sum > 1.0), so we normalize to sum = 1.0
|
||||
// before using as weights. This is standard "vig removal" and ensures a driver
|
||||
// with -200 odds (~66.7% implied) gets ~55% weight when the total vig is ~1.2.
|
||||
// americanToImpliedProb includes vig (the field sums well over 1.0), so the
|
||||
// field is devigged with a power transform rather than proportional division
|
||||
// — see devigPower.
|
||||
//
|
||||
// Unpriced drivers are priced at the longest price in the book before the
|
||||
// devig, not at 1/N: the market left them out because it did not rate them,
|
||||
// and in a 27-car field 1/N (3.7%) rates them above most of the real
|
||||
// longshots (+50000 is 0.2%). A book with a single price has no tail to
|
||||
// anchor to, so that case keeps the 1/N fallback.
|
||||
const fallbackProb = 1 / participants.length;
|
||||
const rawProbs = new Map<string, number>();
|
||||
const pricedImplied = new Map<string, number>();
|
||||
|
||||
for (const p of participants) {
|
||||
const ev = evMap.get(p.id);
|
||||
rawProbs.set(p.id, ev !== undefined && ev.sourceOdds !== null && ev.sourceOdds !== undefined ? americanToImpliedProb(ev.sourceOdds) : fallbackProb);
|
||||
const odds = evMap.get(p.id)?.sourceOdds;
|
||||
if (odds !== null && odds !== undefined) {
|
||||
pricedImplied.set(p.id, americanToImpliedProb(odds));
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to remove vig
|
||||
const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0);
|
||||
for (const [id, prob] of rawProbs) {
|
||||
rawProbs.set(id, prob / rawSum);
|
||||
}
|
||||
const unpricedImplied =
|
||||
pricedImplied.size > 1 ? Math.min(...pricedImplied.values()) : fallbackProb;
|
||||
const devigged = devigPower(
|
||||
participants.map((p) => pricedImplied.get(p.id) ?? unpricedImplied)
|
||||
);
|
||||
const rawProbs = new Map<string, number>(
|
||||
participants.map((p, i) => [p.id, devigged[i]])
|
||||
);
|
||||
|
||||
// 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0)
|
||||
const baseProbs = new Map<string, number>();
|
||||
|
|
@ -184,9 +202,12 @@ export class AutoRacingSimulator implements Simulator {
|
|||
rankCounts.set(id, Array.from({ length: 8 }, () => 0));
|
||||
}
|
||||
|
||||
if (remainingRaces === 0) {
|
||||
// Pre-season: no races to simulate, derive placement probabilities
|
||||
// from sourceOdds via pure weighted draws.
|
||||
// Pre-season only: no races run *and* none left, so there are no standings
|
||||
// to build on and the odds are all there is. When races have already been
|
||||
// run the in-season path below handles it — with zero races left it awards
|
||||
// no points, so it just ranks the current standings, which is exactly the
|
||||
// right answer for a finished season.
|
||||
if (totalRaces === 0 || completedRaces === 0) {
|
||||
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
|
||||
for (let sim = 0; sim < numSimulations; sim++) {
|
||||
const finishOrder = weightedDrawWithoutReplacement(ids, weights);
|
||||
|
|
@ -214,7 +235,6 @@ export class AutoRacingSimulator implements Simulator {
|
|||
// - Mid/late season: standings dominate, reducing the distortion from
|
||||
// championship futures (which penalize 2nd-place drivers whose odds of
|
||||
// *winning* the title are weak, even though they'll likely finish top 3)
|
||||
const totalCurrentPoints = [...currentPointsMap.values()].reduce((a, b) => a + b, 0);
|
||||
const blendedProbs = new Map<string, number>();
|
||||
for (const id of ids) {
|
||||
const oddsW = baseProbs.get(id) ?? fallbackProb;
|
||||
|
|
|
|||
|
|
@ -9,28 +9,48 @@
|
|||
* pool play. This mirrors the llws_20 bracket template so simulated placements line
|
||||
* up with the bracket admins actually score.
|
||||
*
|
||||
* Two modes:
|
||||
* 1. Pre-bracket mode: no llws_20 bracket exists yet (or it has no participants
|
||||
* seeded). Each side is shuffled into the 10 bracket slots every iteration, so
|
||||
* the draw is modelled as random.
|
||||
* 2. Bracket-aware mode: a seeded llws_20 bracket exists. Teams sit in their real
|
||||
* slots and completed match results are honored rather than re-simulated, so a
|
||||
* team that has already lost carries that loss into every iteration.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Load all 20 participants for the sports season from DB
|
||||
* (must be exactly 10 US + 10 International, identified by externalId)
|
||||
* 2. Load championship futures odds from participantExpectedValues.sourceOdds
|
||||
* 2. Load the llws_20 playoff bracket, if one exists, to get the real draw and
|
||||
* whatever results have been recorded so far
|
||||
* 3. Load championship futures odds from participantExpectedValues.sourceOdds
|
||||
* (entered via Admin → Futures Odds; American format)
|
||||
* 3. Convert odds to normalized championship probabilities (vig removed).
|
||||
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50.
|
||||
* 4. Per simulation:
|
||||
* a. Shuffle each side's 10 teams into the 10 bracket slots (8 opening-round
|
||||
* teams + 2 byes). The draw is modelled as random — a specific known draw
|
||||
* is not yet expressible in participant config.
|
||||
* b. Simulate the 10-team double-elimination bracket for each side
|
||||
* (see simulateSideBracket for the exact game-by-game structure)
|
||||
* 4. Convert those futures to Elo via the shared probability engine, then drive
|
||||
* each game with the Elo win probability (see "Why Elo" below)
|
||||
* 5. Per simulation:
|
||||
* a. Place each side's 10 teams into the bracket slots (real draw when known,
|
||||
* otherwise shuffled)
|
||||
* b. Simulate the 10-team double-elimination bracket for each side, replaying
|
||||
* completed games from their recorded result (see simulateSideBracket)
|
||||
* c. Consolation game: US side loser vs Intl side loser → 3rd / 4th
|
||||
* d. World Championship: US champion vs Intl champion → 1st / 2nd
|
||||
* 5. Track placement counts across all simulations.
|
||||
* 6. Convert counts to probability distributions.
|
||||
* 6. Track placement counts across all simulations
|
||||
* 7. Convert counts to probability distributions
|
||||
*
|
||||
* Why Elo rather than raw futures:
|
||||
* A championship future already bakes in the ~6 wins needed to lift the trophy, so
|
||||
* using it directly as a single-game strength (p1 / (p1 + p2)) makes every
|
||||
* individual game as lopsided as the whole tournament and compounds the favorite's
|
||||
* edge over and over. buildLLWSElos undoes that compression first (the empirically
|
||||
* calibrated cube-root step in decompressProbability) before mapping to an Elo
|
||||
* scale, and LLWS_PARITY_FACTOR then widens the Elo curve to reflect how much
|
||||
* single-game variance there is in six-inning Little League baseball. Unlike the
|
||||
* shared convertFuturesToElo helper, the mapping preserves how spread out the board
|
||||
* actually is — see buildLLWSElos for why that matters.
|
||||
*
|
||||
* Side assignment (externalId): "US" or "Intl". The legacy pool suffixes
|
||||
* ("US:A", "US:B", "Intl:A", "Intl:B") are still accepted and read as the side
|
||||
* alone, so seasons configured for the old pool-play format keep working — pools
|
||||
* no longer exist, so the suffix has no effect.
|
||||
* no longer exist, so the suffix has no effect. When a seeded bracket exists the
|
||||
* bracket's own slots decide the sides and externalId is not consulted.
|
||||
*
|
||||
* Placement tiers → SimulationProbabilities mapping (matches llws_20's scoring):
|
||||
* probFirst = World Championship winner (1 per sim)
|
||||
|
|
@ -45,15 +65,21 @@
|
|||
* 1. Create a Sport with simulatorType = "llws_bracket"
|
||||
* 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International)
|
||||
* 3. Set externalId on each participant via Admin → Manage Participants to "US" or
|
||||
* "Intl" (optional — names starting with "US " infer US, all others infer Intl)
|
||||
* "Intl" (optional — names starting with "US " infer US, all others infer Intl).
|
||||
* Once the bracket is generated and seeded this is no longer used.
|
||||
* 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds)
|
||||
* 5. Run simulation via Admin → Simulate
|
||||
*/
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
|
||||
import {
|
||||
convertAmericanOddsToProbability,
|
||||
decompressProbability,
|
||||
eloWinProbabilityWithParity,
|
||||
} from "~/services/probability-engine";
|
||||
import { llwsMatchNumber } from "~/lib/bracket-templates";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
import { positiveConfigNumber } from "./config-access";
|
||||
|
||||
|
|
@ -62,16 +88,61 @@ import { positiveConfigNumber } from "./config-access";
|
|||
const NUM_SIMULATIONS = 50_000;
|
||||
const US_TEAM_COUNT = 10;
|
||||
const INTL_TEAM_COUNT = 10;
|
||||
const DEFAULT_ELO = 1500;
|
||||
const LLWS_TEMPLATE_ID = "llws_20";
|
||||
|
||||
/**
|
||||
* Elo scaling for a single LLWS game.
|
||||
*
|
||||
* Higher than the 400-point standard because a six-inning Little League game between
|
||||
* 12-year-olds is far closer to a coin flip than a pro game: one pitcher, one big
|
||||
* inning, and the mercy rule all compress the gap.
|
||||
*
|
||||
* Calibrated by sweeping this value until a randomized-draw simulation reproduces the
|
||||
* championship futures it was fed, across boards of different shape (see
|
||||
* LLWS_ELO_SPREAD for why the shape matters). Total RMSE over a wide board, a
|
||||
* top-heavy board, and a nearly flat one:
|
||||
* parity 450 → 0.028
|
||||
* parity 550 → 0.016 ← chosen
|
||||
* parity 750 → 0.040
|
||||
* parity 1000 → 0.061
|
||||
* Overridable per season via the `parityFactor` simulator config.
|
||||
*/
|
||||
const LLWS_PARITY_FACTOR = 550;
|
||||
|
||||
/**
|
||||
* Elo points per natural-log unit of relative team strength.
|
||||
*
|
||||
* Only the ratio LLWS_ELO_SPREAD / parityFactor affects the simulation, so this fixes
|
||||
* the readable scale of the ratings and LLWS_PARITY_FACTOR does the calibrating. 300
|
||||
* puts a typical 20-team board in the familiar ~1350–1700 range.
|
||||
*/
|
||||
const LLWS_ELO_SPREAD = 300;
|
||||
|
||||
/**
|
||||
* Power transform undoing the compounding baked into a championship future.
|
||||
* Matches DEFAULT_CALIBRATION.exponent in the probability engine.
|
||||
*/
|
||||
const LLWS_DECOMPRESSION_EXPONENT = 0.33;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Side = "US" | "Intl";
|
||||
|
||||
/** Bracket-template side index: U.S. matches take the low match numbers. */
|
||||
const SIDE_INDEX: Record<Side, 0 | 1> = { US: 0, Intl: 1 };
|
||||
|
||||
/** The playoff_matches columns the simulator actually reads. */
|
||||
export type BracketMatch = Pick<
|
||||
typeof schema.playoffMatches.$inferSelect,
|
||||
"round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete"
|
||||
>;
|
||||
|
||||
interface Team {
|
||||
participantId: string;
|
||||
side: Side;
|
||||
/** Normalized championship win probability (0–1, vig removed). */
|
||||
oddsProb: number;
|
||||
/** Single-game strength on an Elo scale, decompressed from championship futures. */
|
||||
elo: number;
|
||||
}
|
||||
|
||||
interface PlacementCounts {
|
||||
|
|
@ -85,6 +156,25 @@ interface PlacementCounts {
|
|||
elimRound4Loser: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays one bracket game. `round`/`localMatch` identify the game within its side so a
|
||||
* completed result can be looked up; `t1`/`t2` are the teams the simulation has
|
||||
* routed into it.
|
||||
*/
|
||||
type PlayGame = (
|
||||
round: string,
|
||||
localMatch: number,
|
||||
t1: Team,
|
||||
t2: Team
|
||||
) => { winner: Team; loser: Team };
|
||||
|
||||
interface LoadedBracket {
|
||||
/** Each side's 10 teams in bracket slot order (8 opening-round, then 2 byes). */
|
||||
slots: Record<Side, Team[]>;
|
||||
/** All bracket matches, keyed by `${round}#${globalMatchNumber}`. */
|
||||
matches: Map<string, BracketMatch>;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function zeroCounts(): PlacementCounts {
|
||||
|
|
@ -94,16 +184,12 @@ function zeroCounts(): PlacementCounts {
|
|||
};
|
||||
}
|
||||
|
||||
function simGame(t1: Team, t2: Team): { winner: Team; loser: Team } {
|
||||
// If either team has no odds entered, treat the game as a coin flip.
|
||||
// The 50/50 fallback must cover the one-sided case (one team known, one not)
|
||||
// because oddsProb=0 would otherwise give the unknown team a 0% win rate.
|
||||
let p1Win: number;
|
||||
if (t1.oddsProb === 0 || t2.oddsProb === 0) {
|
||||
p1Win = 0.5;
|
||||
} else {
|
||||
p1Win = t1.oddsProb / (t1.oddsProb + t2.oddsProb);
|
||||
}
|
||||
function matchKey(round: string, matchNumber: number): string {
|
||||
return `${round}#${matchNumber}`;
|
||||
}
|
||||
|
||||
function simGame(t1: Team, t2: Team, parityFactor: number): { winner: Team; loser: Team } {
|
||||
const p1Win = eloWinProbabilityWithParity(t1.elo, t2.elo, parityFactor);
|
||||
return Math.random() < p1Win ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +204,76 @@ function shuffle<T>(arr: T[]): T[] {
|
|||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded loser of a completed match. loserId is written by the scoring flow,
|
||||
* but fall back to "whichever slot isn't the winner" for older rows.
|
||||
*/
|
||||
function completedLoser(match: BracketMatch): string | null {
|
||||
if (match.loserId) return match.loserId;
|
||||
if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id;
|
||||
if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the game-playing function for one side.
|
||||
*
|
||||
* When the bracket has a completed result for a game AND that result is between the
|
||||
* two teams the simulation routed into it, the recorded winner is used verbatim —
|
||||
* that is what makes an already-played loss stick across all iterations. Anything
|
||||
* else is simulated. The pair check keeps a corrupt or out-of-order row from
|
||||
* desynchronising the rest of the bracket.
|
||||
*/
|
||||
export function makePlayGame(
|
||||
sideIndex: 0 | 1,
|
||||
bracket: LoadedBracket | null,
|
||||
parityFactor: number
|
||||
): PlayGame {
|
||||
if (!bracket) {
|
||||
return (_round, _localMatch, t1, t2) => simGame(t1, t2, parityFactor);
|
||||
}
|
||||
|
||||
return (round, localMatch, t1, t2) => {
|
||||
const match = bracket.matches.get(
|
||||
matchKey(round, llwsMatchNumber(round, sideIndex, localMatch))
|
||||
);
|
||||
if (match?.isComplete && match.winnerId) {
|
||||
const loserId = completedLoser(match);
|
||||
const arrived = [t1.participantId, t2.participantId];
|
||||
if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) {
|
||||
return match.winnerId === t1.participantId
|
||||
? { winner: t1, loser: t2 }
|
||||
: { winner: t2, loser: t1 };
|
||||
}
|
||||
}
|
||||
return simGame(t1, t2, parityFactor);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Play one of the two cross-side games (Consolation, World Championship). Both are a
|
||||
* single shared match numbered 1, so they don't go through the side-local mapping.
|
||||
*/
|
||||
export function playCrossoverGame(
|
||||
round: string,
|
||||
bracket: LoadedBracket | null,
|
||||
parityFactor: number,
|
||||
t1: Team,
|
||||
t2: Team
|
||||
): { winner: Team; loser: Team } {
|
||||
const match = bracket?.matches.get(matchKey(round, 1));
|
||||
if (match?.isComplete && match.winnerId) {
|
||||
const loserId = completedLoser(match);
|
||||
const arrived = [t1.participantId, t2.participantId];
|
||||
if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) {
|
||||
return match.winnerId === t1.participantId
|
||||
? { winner: t1, loser: t2 }
|
||||
: { winner: t2, loser: t1 };
|
||||
}
|
||||
}
|
||||
return simGame(t1, t2, parityFactor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate one side's 10-team double-elimination bracket.
|
||||
*
|
||||
|
|
@ -125,7 +281,7 @@ function shuffle<T>(arr: T[]): T[] {
|
|||
* layout: slots[0..7] are the four opening-round games (two teams each) and
|
||||
* slots[8], slots[9] are the two bye teams entering Winners Round 2.
|
||||
*
|
||||
* Structure (side-local, mirroring LLWS_ADVANCEMENT in models/playoff-match):
|
||||
* Structure (side-local, mirroring LLWS_ADVANCEMENT in lib/llws-bracket):
|
||||
* Winners bracket
|
||||
* OP1 s0 v s1 OP2 s2 v s3 OP3 s4 v s5 OP4 s6 v s7
|
||||
* WR2-1 s8 v OP1w WR2-2 s9 v OP2w
|
||||
|
|
@ -143,47 +299,51 @@ function shuffle<T>(arr: T[]): T[] {
|
|||
* Elimination Final. There is no "if necessary" game, so the side championship is
|
||||
* decided in one game.
|
||||
*
|
||||
* The team order passed to `play` matches each match's participant1 / participant2
|
||||
* slots in the generated bracket, so recorded results line up game for game.
|
||||
*
|
||||
* Returns { sideChampion, sideLoser }; the two scoring elimination losers are
|
||||
* bumped into the counts directly.
|
||||
*/
|
||||
function simulateSideBracket(
|
||||
slots: Team[],
|
||||
bump: (id: string, key: keyof PlacementCounts) => void
|
||||
bump: (id: string, key: keyof PlacementCounts) => void,
|
||||
play: PlayGame
|
||||
): { sideChampion: Team; sideLoser: Team } {
|
||||
// ── Winners bracket ────────────────────────────────────────────────────────
|
||||
const op1 = simGame(slots[0], slots[1]);
|
||||
const op2 = simGame(slots[2], slots[3]);
|
||||
const op3 = simGame(slots[4], slots[5]);
|
||||
const op4 = simGame(slots[6], slots[7]);
|
||||
const op1 = play("Opening Round", 1, slots[0], slots[1]);
|
||||
const op2 = play("Opening Round", 2, slots[2], slots[3]);
|
||||
const op3 = play("Opening Round", 3, slots[4], slots[5]);
|
||||
const op4 = play("Opening Round", 4, slots[6], slots[7]);
|
||||
|
||||
const wr21 = simGame(slots[8], op1.winner);
|
||||
const wr22 = simGame(slots[9], op2.winner);
|
||||
const wr21 = play("Winners Round 2", 1, slots[8], op1.winner);
|
||||
const wr22 = play("Winners Round 2", 2, slots[9], op2.winner);
|
||||
|
||||
const wsf1 = simGame(op3.winner, wr21.winner);
|
||||
const wsf2 = simGame(wr22.winner, op4.winner);
|
||||
const wsf1 = play("Winners Semifinals", 1, op3.winner, wr21.winner);
|
||||
const wsf2 = play("Winners Semifinals", 2, wr22.winner, op4.winner);
|
||||
|
||||
const wf = simGame(wsf1.winner, wsf2.winner);
|
||||
const wf = play("Winners Final", 1, wsf1.winner, wsf2.winner);
|
||||
|
||||
// ── Elimination bracket ────────────────────────────────────────────────────
|
||||
const er11 = simGame(op2.loser, op3.loser);
|
||||
const er12 = simGame(op1.loser, op4.loser);
|
||||
const er11 = play("Elimination Round 1", 1, op2.loser, op3.loser);
|
||||
const er12 = play("Elimination Round 1", 2, op1.loser, op4.loser);
|
||||
|
||||
const er21 = simGame(wr21.loser, er11.winner);
|
||||
const er22 = simGame(wr22.loser, er12.winner);
|
||||
const er21 = play("Elimination Round 2", 1, wr21.loser, er11.winner);
|
||||
const er22 = play("Elimination Round 2", 2, wr22.loser, er12.winner);
|
||||
|
||||
// Cross-over: each semifinal loser meets the winner from the opposite half.
|
||||
const er31 = simGame(wsf1.loser, er22.winner);
|
||||
const er32 = simGame(wsf2.loser, er21.winner);
|
||||
const er31 = play("Elimination Round 3", 1, wsf1.loser, er22.winner);
|
||||
const er32 = play("Elimination Round 3", 2, wsf2.loser, er21.winner);
|
||||
|
||||
const er4 = simGame(er32.winner, er31.winner);
|
||||
const er4 = play("Elimination Round 4", 1, er32.winner, er31.winner);
|
||||
bump(er4.loser.participantId, "elimRound4Loser"); // 7th–8th tier
|
||||
|
||||
// The Winners Final loser gets its second chance here.
|
||||
const ef = simGame(wf.loser, er4.winner);
|
||||
const ef = play("Elimination Final", 1, wf.loser, er4.winner);
|
||||
bump(ef.loser.participantId, "elimFinalLoser"); // 5th–6th tier
|
||||
|
||||
// ── Side championship ──────────────────────────────────────────────────────
|
||||
const sideChampionship = simGame(wf.winner, ef.winner);
|
||||
const sideChampionship = play("Bracket Championship", 1, wf.winner, ef.winner);
|
||||
|
||||
return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser };
|
||||
}
|
||||
|
|
@ -209,13 +369,157 @@ function parseExternalId(raw: string | null): { side: Side } | null {
|
|||
* Infer an externalId from a participant name when none is stored.
|
||||
* Teams whose name is exactly "US" or starts with "US " (case-insensitive)
|
||||
* are assigned to the US side; all others are assigned to Intl.
|
||||
* The inferred value never has a pool suffix, so pools will be randomized.
|
||||
*/
|
||||
function inferExternalIdFromName(name: string): string {
|
||||
const upper = name.trim().toUpperCase();
|
||||
return upper === "US" || upper.startsWith("US ") ? "US" : "Intl";
|
||||
}
|
||||
|
||||
// ─── Elo construction ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map participants to single-game Elo ratings from their championship futures.
|
||||
*
|
||||
* Deliberately NOT convertFuturesToElo. That helper finishes by rescaling the field
|
||||
* onto a fixed 1250–1750 span (mapToElo), which throws away how spread out the board
|
||||
* actually is: a board whose favorite is priced at 22% and one whose favorite is
|
||||
* priced at 6% both come out 500 Elo wide, so the tight board's field gets pulled
|
||||
* apart into contenders and no-hopers that the market never implied. On such a board
|
||||
* that inflated the favorite from 6% to 13%.
|
||||
*
|
||||
* Instead the decompressed strengths are mapped by their log-ratio to the field's
|
||||
* geometric mean, which preserves dispersion: a tight board yields a narrow Elo span
|
||||
* and a top-heavy one a wide span, both centred on DEFAULT_ELO.
|
||||
*
|
||||
* Returns the ratings alongside the rating to use for a team with no odds entered —
|
||||
* the median of the priced field, so leaving odds blank neither promotes nor buries a
|
||||
* team. (DEFAULT_ELO is the centre of the scale, but futures fields are skewed, so on
|
||||
* a typical board it would rank a team around 6th of 20.)
|
||||
*/
|
||||
export function buildLLWSElos(
|
||||
evRows: Array<{ participantId: string; sourceOdds: number | null }>
|
||||
): { elos: Map<string, number>; unpricedElo: number } {
|
||||
const priced = evRows.filter((row) => row.sourceOdds !== null);
|
||||
|
||||
// A single priced team carries no information about the rest of the field, so
|
||||
// there is nothing to normalise against — treat the season as unpriced.
|
||||
if (priced.length < 2) return { elos: new Map(), unpricedElo: DEFAULT_ELO };
|
||||
|
||||
const rawProbs = priced.map((row) => convertAmericanOddsToProbability(row.sourceOdds ?? 0));
|
||||
const rawSum = rawProbs.reduce((a, b) => a + b, 0);
|
||||
if (rawSum <= 0) return { elos: new Map(), unpricedElo: DEFAULT_ELO };
|
||||
|
||||
// Vig-removed championship probability → single-game strength.
|
||||
const logStrengths = rawProbs.map((prob) =>
|
||||
Math.log(
|
||||
Math.max(decompressProbability(prob / rawSum, LLWS_DECOMPRESSION_EXPONENT), Number.MIN_VALUE)
|
||||
)
|
||||
);
|
||||
const meanLog = logStrengths.reduce((a, b) => a + b, 0) / logStrengths.length;
|
||||
|
||||
const elos = new Map<string, number>(
|
||||
priced.map((row, i) => [
|
||||
row.participantId,
|
||||
DEFAULT_ELO + LLWS_ELO_SPREAD * (logStrengths[i] - meanLog),
|
||||
])
|
||||
);
|
||||
|
||||
return { elos, unpricedElo: median([...elos.values()]) };
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return DEFAULT_ELO;
|
||||
const sorted = values.toSorted((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
||||
}
|
||||
|
||||
// ─── Bracket loading ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the seeded llws_20 bracket for this season, if there is one.
|
||||
*
|
||||
* Returns null only when the bracket carries no draw at all — no matches, or a
|
||||
* freshly generated bracket with every slot still empty — in which case the caller
|
||||
* falls back to a randomized draw.
|
||||
*
|
||||
* A *partially* seeded bracket is an error rather than a fallback. Silently falling
|
||||
* back there would throw away the real draw and every recorded result along with it,
|
||||
* putting eliminated teams back in contention; and it is reachable in practice,
|
||||
* because playoff_matches.participant1Id/participant2Id are ON DELETE SET NULL, so
|
||||
* removing and re-adding a single participant mid-tournament empties a slot.
|
||||
* Likewise, a bracket seeded with unknown or duplicated participants fails loudly.
|
||||
*/
|
||||
export function readBracketSlots(
|
||||
matches: BracketMatch[],
|
||||
teamsById: Map<string, Team>
|
||||
): LoadedBracket | null {
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m]));
|
||||
|
||||
// Collect both sides' draws before deciding, so "nothing seeded" is judged over the
|
||||
// whole bracket rather than one side at a time.
|
||||
const draw: Record<Side, (string | null)[]> = { US: [], Intl: [] };
|
||||
|
||||
for (const side of ["US", "Intl"] as const) {
|
||||
const sideIndex = SIDE_INDEX[side];
|
||||
|
||||
for (let local = 1; local <= 4; local++) {
|
||||
const match = byKey.get(
|
||||
matchKey("Opening Round", llwsMatchNumber("Opening Round", sideIndex, local))
|
||||
);
|
||||
draw[side].push(match?.participant1Id ?? null, match?.participant2Id ?? null);
|
||||
}
|
||||
for (let local = 1; local <= 2; local++) {
|
||||
const match = byKey.get(
|
||||
matchKey("Winners Round 2", llwsMatchNumber("Winners Round 2", sideIndex, local))
|
||||
);
|
||||
draw[side].push(match?.participant1Id ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
const allSlots = [...draw.US, ...draw.Intl];
|
||||
const seededCount = allSlots.filter((id) => id !== null).length;
|
||||
|
||||
// Generated but not yet filled in — no draw to honor.
|
||||
if (seededCount === 0) return null;
|
||||
|
||||
if (seededCount < allSlots.length) {
|
||||
throw new Error(
|
||||
`LLWS bracket is only partially seeded (${seededCount} of ${allSlots.length} slots ` +
|
||||
`filled). Re-seed the bracket in Admin → Bracket before simulating; simulating ` +
|
||||
`around the gap would discard the draw and every recorded result.`
|
||||
);
|
||||
}
|
||||
|
||||
const slots: Record<Side, Team[]> = { US: [], Intl: [] };
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const side of ["US", "Intl"] as const) {
|
||||
for (const id of draw[side]) {
|
||||
const participantId = id as string;
|
||||
if (seen.has(participantId)) {
|
||||
throw new Error(
|
||||
`LLWS bracket seeds participant ${participantId} into more than one slot.`
|
||||
);
|
||||
}
|
||||
seen.add(participantId);
|
||||
|
||||
const team = teamsById.get(participantId);
|
||||
if (!team) {
|
||||
throw new Error(
|
||||
`LLWS bracket references participant ${participantId}, which is not in this sports season.`
|
||||
);
|
||||
}
|
||||
// The bracket is authoritative about which side a team is on.
|
||||
slots[side].push({ ...team, side });
|
||||
}
|
||||
}
|
||||
|
||||
return { slots, matches: byKey };
|
||||
}
|
||||
|
||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class LLWSSimulator implements Simulator {
|
||||
|
|
@ -223,6 +527,7 @@ export class LLWSSimulator implements Simulator {
|
|||
|
||||
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
|
||||
const parityFactor = positiveConfigNumber(config, "parityFactor", LLWS_PARITY_FACTOR);
|
||||
const db = database();
|
||||
|
||||
// 1. Load all participants.
|
||||
|
|
@ -247,52 +552,80 @@ export class LLWSSimulator implements Simulator {
|
|||
.from(schema.seasonParticipantExpectedValues)
|
||||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||||
|
||||
const rawOddsMap = new Map<string, number>();
|
||||
for (const row of evRows) {
|
||||
if (row.sourceOdds !== null) {
|
||||
rawOddsMap.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Normalize odds (remove vig) to get championship probability per team.
|
||||
const normalizedOddsMap = new Map<string, number>();
|
||||
if (rawOddsMap.size > 0) {
|
||||
const rawSum = [...rawOddsMap.values()].reduce((a, b) => a + b, 0);
|
||||
for (const [id, prob] of rawOddsMap) {
|
||||
normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0);
|
||||
}
|
||||
}
|
||||
// 3. Decompress the futures into single-game Elo ratings.
|
||||
const { elos, unpricedElo } = buildLLWSElos(evRows);
|
||||
|
||||
// 4. Parse externalId for each participant to determine which side they're on.
|
||||
// A seeded bracket overrides this below, but the field still has to be a legal
|
||||
// 10/10 split before we know whether a bracket exists.
|
||||
const teams: Team[] = [];
|
||||
const unparseableSides: Array<{ id: string; externalId: string | null }> = [];
|
||||
for (const p of participants) {
|
||||
const raw = p.externalId ?? inferExternalIdFromName(p.name);
|
||||
const parsed = parseExternalId(raw);
|
||||
if (!parsed) {
|
||||
throw new Error(
|
||||
`Participant ${p.id} has invalid externalId "${p.externalId}". ` +
|
||||
`Expected: "US" or "Intl".`
|
||||
);
|
||||
}
|
||||
if (!parsed) unparseableSides.push({ id: p.id, externalId: p.externalId });
|
||||
teams.push({
|
||||
// Provisional: a seeded bracket overwrites this below.
|
||||
participantId: p.id,
|
||||
side: parsed.side,
|
||||
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
|
||||
side: parsed?.side ?? "Intl",
|
||||
elo: elos.get(p.id) ?? unpricedElo,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate team counts per side.
|
||||
const usTeams = teams.filter((t) => t.side === "US");
|
||||
const intlTeams = teams.filter((t) => t.side === "Intl");
|
||||
const teamsById = new Map(teams.map((t) => [t.participantId, t]));
|
||||
|
||||
if (usTeams.length !== US_TEAM_COUNT) {
|
||||
throw new Error(`Expected ${US_TEAM_COUNT} US teams, found ${usTeams.length}.`);
|
||||
}
|
||||
if (intlTeams.length !== INTL_TEAM_COUNT) {
|
||||
throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`);
|
||||
// 5. Load the real bracket (draw + results so far), if one has been generated.
|
||||
// If several llws_20 playoff events exist, take the most recent so a re-created
|
||||
// event wins over a stale one — landing on the stale row would silently discard
|
||||
// the real draw and every recorded result.
|
||||
const playoffEvents = await db.query.scoringEvents.findMany({
|
||||
where: and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.scoringEvents.eventType, "playoff_game"),
|
||||
eq(schema.scoringEvents.bracketTemplateId, LLWS_TEMPLATE_ID)
|
||||
),
|
||||
});
|
||||
const bracketEvent = playoffEvents.toSorted(
|
||||
(a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0)
|
||||
)[0];
|
||||
|
||||
const bracketMatches = bracketEvent
|
||||
? await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||||
})
|
||||
: [];
|
||||
|
||||
const bracket = readBracketSlots(bracketMatches, teamsById);
|
||||
|
||||
// Validate sides. A seeded bracket already fixes the draw and an even 10/10 split,
|
||||
// so externalId only has to be usable on the randomized pre-bracket path.
|
||||
if (!bracket) {
|
||||
const [firstBad] = unparseableSides;
|
||||
if (firstBad) {
|
||||
throw new Error(
|
||||
`Participant ${firstBad.id} has invalid externalId "${firstBad.externalId}". ` +
|
||||
`Expected: "US" or "Intl".`
|
||||
);
|
||||
}
|
||||
|
||||
const usTeams = teams.filter((t) => t.side === "US");
|
||||
const intlTeams = teams.filter((t) => t.side === "Intl");
|
||||
|
||||
if (usTeams.length !== US_TEAM_COUNT) {
|
||||
throw new Error(`Expected ${US_TEAM_COUNT} US teams, found ${usTeams.length}.`);
|
||||
}
|
||||
if (intlTeams.length !== INTL_TEAM_COUNT) {
|
||||
throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Initialise placement count accumulators for all participants.
|
||||
const usPool = bracket ? bracket.slots.US : teams.filter((t) => t.side === "US");
|
||||
const intlPool = bracket ? bracket.slots.Intl : teams.filter((t) => t.side === "Intl");
|
||||
|
||||
const playUS = makePlayGame(SIDE_INDEX.US, bracket, parityFactor);
|
||||
const playIntl = makePlayGame(SIDE_INDEX.Intl, bracket, parityFactor);
|
||||
|
||||
// 6. Initialise placement count accumulators for all participants.
|
||||
const allIds = participants.map((p) => p.id);
|
||||
const counts = new Map<string, PlacementCounts>(allIds.map((id) => [id, zeroCounts()]));
|
||||
const bump = (id: string, key: keyof PlacementCounts) => {
|
||||
|
|
@ -300,27 +633,33 @@ export class LLWSSimulator implements Simulator {
|
|||
if (entry) entry[key]++;
|
||||
};
|
||||
|
||||
// 6. Run Monte Carlo simulations.
|
||||
// 7. Run Monte Carlo simulations.
|
||||
for (let s = 0; s < numSimulations; s++) {
|
||||
// The draw is modelled as random: shuffle each side into the 10 bracket slots
|
||||
// (8 opening-round teams, then the 2 bye teams).
|
||||
// With a real bracket the draw is fixed; without one it is modelled as random.
|
||||
const usSlots = bracket ? usPool : shuffle([...usPool]);
|
||||
const intlSlots = bracket ? intlPool : shuffle([...intlPool]);
|
||||
|
||||
const { sideChampion: usChamp, sideLoser: usLose } =
|
||||
simulateSideBracket(shuffle([...usTeams]), bump);
|
||||
simulateSideBracket(usSlots, bump, playUS);
|
||||
const { sideChampion: intlChamp, sideLoser: intlLose } =
|
||||
simulateSideBracket(shuffle([...intlTeams]), bump);
|
||||
simulateSideBracket(intlSlots, bump, playIntl);
|
||||
|
||||
// Consolation game: 3rd / 4th place.
|
||||
const consolation = simGame(usLose, intlLose);
|
||||
const consolation = playCrossoverGame(
|
||||
"Consolation Third Place", bracket, parityFactor, usLose, intlLose
|
||||
);
|
||||
bump(consolation.winner.participantId, "thirdPlace");
|
||||
bump(consolation.loser.participantId, "fourthPlace");
|
||||
|
||||
// World Championship: 1st / 2nd place.
|
||||
const ws = simGame(usChamp, intlChamp);
|
||||
const ws = playCrossoverGame(
|
||||
"World Championship", bracket, parityFactor, usChamp, intlChamp
|
||||
);
|
||||
bump(ws.winner.participantId, "champion");
|
||||
bump(ws.loser.participantId, "finalist");
|
||||
}
|
||||
|
||||
// 7. Convert counts to probability distributions.
|
||||
// 8. Convert counts to probability distributions.
|
||||
// Each of the two 5–8 tiers takes exactly 2 teams per sim (one per side), and
|
||||
// the teams within a tier are tied, so the tier probability is split across
|
||||
// its two positions.
|
||||
|
|
|
|||
|
|
@ -183,10 +183,12 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
|
||||
},
|
||||
llws_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10 },
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 550, usTeamCount: 10, internationalTeamCount: 10 },
|
||||
requiredInputs: ["sourceOdds"],
|
||||
optionalInputs: ["metadata"],
|
||||
setupSections: ["participants", "futuresOdds"],
|
||||
// The bracket is optional — without one the draw is randomized — but once it
|
||||
// exists the simulator reads the real draw and honors completed results from it.
|
||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||
},
|
||||
college_hockey_bracket: {
|
||||
// College hockey blends odds into Elo internally (and also uses NPI rank,
|
||||
|
|
|
|||
23
app/services/simulations/race-points.ts
Normal file
23
app/services/simulations/race-points.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Championship points tables for auto racing series.
|
||||
*
|
||||
* Stable series rules, not refreshable season data, so they live in code (see
|
||||
* the hardcoding rules in docs/agents/simulators.md). Kept out of `registry.ts`
|
||||
* so tests and callers can read a table without pulling in every simulator —
|
||||
* importing the registry first also trips the manifest/registry import cycle.
|
||||
*
|
||||
* Each table must be contiguous from P1 and monotonically decreasing: the
|
||||
* simulator's award loop stops at the first unscored position.
|
||||
*/
|
||||
|
||||
/** F1 points: positions 1–10. */
|
||||
export const F1_RACE_POINTS: Record<number, number> = {
|
||||
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1,
|
||||
};
|
||||
|
||||
/** IndyCar standard race points: positions 1–26. */
|
||||
export const INDYCAR_RACE_POINTS: Record<number, number> = {
|
||||
1: 50, 2: 40, 3: 35, 4: 32, 5: 30, 6: 28, 7: 26, 8: 24, 9: 22, 10: 20,
|
||||
11: 19, 12: 18, 13: 17, 14: 16, 15: 15, 16: 14, 17: 13, 18: 12, 19: 11, 20: 10,
|
||||
21: 9, 22: 8, 23: 7, 24: 6, 25: 5, 26: 5,
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
import type { Simulator } from "./types";
|
||||
import { BracketSimulator } from "./bracket-simulator";
|
||||
import { AutoRacingSimulator } from "./auto-racing-simulator";
|
||||
import { F1_RACE_POINTS, INDYCAR_RACE_POINTS } from "./race-points";
|
||||
import { GolfSimulator } from "./golf-simulator";
|
||||
import { UCLSimulator } from "./ucl-simulator";
|
||||
import { NCAAMSimulator } from "./ncaam-simulator";
|
||||
|
|
@ -62,20 +63,6 @@ export const SIMULATOR_TYPES = [
|
|||
|
||||
export type SimulatorType = typeof SIMULATOR_TYPES[number];
|
||||
|
||||
// ─── Race points tables ───────────────────────────────────────────────────────
|
||||
|
||||
/** F1 points: positions 1–10. */
|
||||
const F1_RACE_POINTS: Record<number, number> = {
|
||||
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1,
|
||||
};
|
||||
|
||||
/** IndyCar standard race points: positions 1–26. */
|
||||
const INDYCAR_RACE_POINTS: Record<number, number> = {
|
||||
1: 50, 2: 40, 3: 35, 4: 32, 5: 30, 6: 28, 7: 26, 8: 24, 9: 22, 10: 20,
|
||||
11: 19, 12: 18, 13: 17, 14: 16, 15: 15, 16: 14, 17: 13, 18: 12, 19: 11, 20: 10,
|
||||
21: 9, 22: 8, 23: 7, 24: 6, 25: 5, 26: 5,
|
||||
};
|
||||
|
||||
export interface SimulatorInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
|
|
@ -168,7 +155,7 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
|
|||
llws_bracket: {
|
||||
info: {
|
||||
name: "LLWS Bracket Monte Carlo",
|
||||
description: "Simulates the 20-team Little League World Series: a 10-team double-elimination bracket per side (US & International), each producing a side champion, then the consolation game (3rd/4th) and the World Championship (1st/2nd). Uses championship futures odds for all win probabilities. Set externalId to 'US' or 'Intl'.",
|
||||
description: "Simulates the 20-team Little League World Series: a 10-team double-elimination bracket per side (US & International), each producing a side champion, then the consolation game (3rd/4th) and the World Championship (1st/2nd). Championship futures odds are decompressed to single-game Elo. When an llws_20 bracket exists it simulates the real draw and honors completed results; otherwise the draw is randomized and externalId ('US' or 'Intl') sets the sides.",
|
||||
},
|
||||
create: () => new LLWSSimulator(),
|
||||
},
|
||||
|
|
|
|||
122
app/test/fixtures/llws-bracket.ts
vendored
Normal file
122
app/test/fixtures/llws-bracket.ts
vendored
Normal file
|
|
@ -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<number, { round: string; matchNumber: number }> = {
|
||||
// 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<string, number>(
|
||||
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<T>(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<number, [string | null, string | null]> = {
|
||||
// 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"],
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
"app/models/**/*.ts",
|
||||
"app/services/**/*.ts",
|
||||
"app/lib/**/*.ts",
|
||||
"app/test/fixtures/**/*.ts",
|
||||
"app/types/**/*.ts",
|
||||
"vite.config.ts"
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue