Compare commits
No commits in common. "main" and "claude/qp-scoreboard-all-participants" have entirely different histories.
main
...
claude/qp-
81 changed files with 1209 additions and 11106 deletions
|
|
@ -1,16 +1,13 @@
|
|||
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,
|
||||
|
|
@ -24,8 +21,6 @@ interface BracketTreePaginatedProps {
|
|||
/** Index of the first scoring round — default page starts here */
|
||||
firstScoringRoundIdx?: number;
|
||||
thirdPlaceRound?: string;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketTreePaginated({
|
||||
|
|
@ -35,68 +30,63 @@ 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) : lastPage,
|
||||
lastPage,
|
||||
firstScoringRoundIdx !== undefined
|
||||
? Math.max(0, firstScoringRoundIdx - 1)
|
||||
: mainRounds.length - 2,
|
||||
mainRounds.length - 2,
|
||||
),
|
||||
);
|
||||
|
||||
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
||||
lastPage,
|
||||
mainRounds.length - 2,
|
||||
defaultPage,
|
||||
);
|
||||
|
||||
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 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 label = labelFor(anim ? anim.toPage : page);
|
||||
const pageHeight = calcHeight(page);
|
||||
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
||||
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
||||
|
||||
const pageG = pageGeometry(page);
|
||||
const animFromG = anim ? pageGeometry(anim.fromPage) : pageG;
|
||||
const animToG = anim ? pageGeometry(anim.toPage) : pageG;
|
||||
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;
|
||||
|
||||
let leftPage: number;
|
||||
let rightPage: number | null = null;
|
||||
let leftG = pageG;
|
||||
let rightG = pageG;
|
||||
let leftRounds: string[];
|
||||
let rightRounds: string[] = [];
|
||||
let leftHeight: number;
|
||||
let rightHeight = 0;
|
||||
let settlingTransition = false;
|
||||
if (anim?.phase === "sliding") {
|
||||
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;
|
||||
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
||||
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
||||
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
||||
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
||||
} else if (anim?.phase === "settling") {
|
||||
leftPage = anim.toPage;
|
||||
leftG = animToG;
|
||||
leftRounds = toRounds;
|
||||
leftHeight = animToHeight;
|
||||
settlingTransition = true;
|
||||
} else {
|
||||
leftPage = page;
|
||||
leftRounds = visibleRounds;
|
||||
leftHeight = pageHeight;
|
||||
}
|
||||
|
||||
const containerMinHeight =
|
||||
anim?.phase === "settling" ? animToG.bracketHeight : animFromG.bracketHeight;
|
||||
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
||||
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
||||
|
||||
return (
|
||||
|
|
@ -119,7 +109,7 @@ export function BracketTreePaginated({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(page + 1)}
|
||||
disabled={page >= lastPage || !!anim}
|
||||
disabled={page + 2 >= mainRounds.length || !!anim}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label="Next rounds"
|
||||
>
|
||||
|
|
@ -139,24 +129,22 @@ export function BracketTreePaginated({
|
|||
>
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
geometry={leftG}
|
||||
columnRange={[leftPage, leftPage + 1]}
|
||||
visibleRounds={leftRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={leftHeight}
|
||||
transitionDuration={settlingTransition ? 500 : undefined}
|
||||
/>
|
||||
</div>
|
||||
{anim?.phase === "sliding" && rightPage !== null && (
|
||||
{anim?.phase === "sliding" && (
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
geometry={rightG}
|
||||
columnRange={[rightPage, rightPage + 1]}
|
||||
visibleRounds={rightRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={rightHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -176,8 +164,6 @@ export function BracketTreePaginated({
|
|||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
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;
|
||||
|
|
@ -54,8 +46,6 @@ 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;
|
||||
|
|
@ -70,7 +60,6 @@ interface ParticipantRowProps {
|
|||
|
||||
function ParticipantRow({
|
||||
name,
|
||||
feedLabel,
|
||||
isTbd,
|
||||
isWinner,
|
||||
isLoser,
|
||||
|
|
@ -125,7 +114,7 @@ function ParticipantRow({
|
|||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{name ?? feedLabel ?? "TBD"}
|
||||
{name ?? "TBD"}
|
||||
</span>
|
||||
|
||||
{/* Owner name below participant name */}
|
||||
|
|
@ -161,8 +150,6 @@ interface BracketMatchSlotProps {
|
|||
slotHeight: number;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketMatchSlot({
|
||||
|
|
@ -170,8 +157,6 @@ export function BracketMatchSlot({
|
|||
slotHeight,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketMatchSlotProps) {
|
||||
const rowHeight = slotHeight / 2;
|
||||
const showText = rowHeight >= 10;
|
||||
|
|
@ -202,13 +187,6 @@ 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 */}
|
||||
|
|
@ -230,7 +208,6 @@ export function BracketMatchSlot({
|
|||
>
|
||||
<ParticipantRow
|
||||
name={match.participant1?.name ?? null}
|
||||
feedLabel={feed1}
|
||||
isTbd={isTbd1}
|
||||
isWinner={p1IsWinner}
|
||||
isLoser={p1IsLoser}
|
||||
|
|
@ -244,7 +221,6 @@ export function BracketMatchSlot({
|
|||
/>
|
||||
<ParticipantRow
|
||||
name={match.participant2?.name ?? null}
|
||||
feedLabel={feed2}
|
||||
isTbd={isTbd2}
|
||||
isWinner={p2IsWinner}
|
||||
isLoser={p2IsLoser}
|
||||
|
|
@ -261,45 +237,54 @@ export function BracketMatchSlot({
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Connector column ─────────────────────────────────────────────────────────
|
||||
// ─── Per-pair connector column ────────────────────────────────────────────────
|
||||
|
||||
interface ConnectorColumnProps {
|
||||
/** Edges crossing this gutter, in slot units. */
|
||||
edges: { fromCenter: number; toCenter: number }[];
|
||||
rowHeight: number;
|
||||
offset: number;
|
||||
currentMatches: BracketMatch[];
|
||||
nextMatches: BracketMatch[];
|
||||
bracketHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
function ConnectorColumn({ currentMatches, nextMatches, 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[] = [];
|
||||
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;
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
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}`);
|
||||
} 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -325,131 +310,52 @@ function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorC
|
|||
|
||||
// ─── 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 {
|
||||
geometry: BracketGeometry;
|
||||
visibleRounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
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({
|
||||
geometry,
|
||||
visibleRounds,
|
||||
matchesByRound,
|
||||
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 }}>
|
||||
{visible.map((column, vi) => {
|
||||
const ci = firstColumn + vi;
|
||||
const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci);
|
||||
{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) ?? []) : [];
|
||||
|
||||
return (
|
||||
<div key={column.label + ci} style={{ display: "contents" }}>
|
||||
<div key={round} 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" }}
|
||||
>
|
||||
{column.label}
|
||||
{round}
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
||||
{column.matches.map(({ match, center }) => (
|
||||
{roundMatches.map((match, matchIdx) => (
|
||||
<div
|
||||
key={match.id}
|
||||
data-match-id={match.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: center * rowHeight - offset - cardHeight / 2,
|
||||
top: matchIdx * slotHeight + cardTop,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: cardHeight,
|
||||
|
|
@ -461,8 +367,6 @@ export function TreeColumns({
|
|||
slotHeight={cardHeight}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -470,11 +374,10 @@ export function TreeColumns({
|
|||
</div>
|
||||
|
||||
{/* Connector between this column and the next */}
|
||||
{vi < visible.length - 1 && (
|
||||
{nextRound && (
|
||||
<ConnectorColumn
|
||||
edges={gutterEdges}
|
||||
rowHeight={rowHeight}
|
||||
offset={offset}
|
||||
currentMatches={roundMatches}
|
||||
nextMatches={nextMatches}
|
||||
bracketHeight={bracketHeight}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -493,8 +396,6 @@ interface BracketTreeViewProps {
|
|||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
thirdPlaceRound?: string;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketTreeView({
|
||||
|
|
@ -503,19 +404,13 @@ 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 geometry = bracketGeometry(
|
||||
mainRounds,
|
||||
matchesByRound,
|
||||
feeders,
|
||||
template?.rounds.map((r) => r.name) ?? mainRounds
|
||||
);
|
||||
const { bracketHeight, minWidth } = geometry;
|
||||
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;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -524,11 +419,11 @@ export function BracketTreeView({
|
|||
>
|
||||
<div style={{ minWidth }}>
|
||||
<TreeColumns
|
||||
geometry={geometry}
|
||||
visibleRounds={mainRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={bracketHeight}
|
||||
/>
|
||||
{thirdPlaceMatch && (
|
||||
<div style={{ display: "flex", paddingTop: 20 }}>
|
||||
|
|
@ -546,8 +441,6 @@ export function BracketTreeView({
|
|||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
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 type { ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
|
||||
interface NbaBracketLayoutProps {
|
||||
|
|
@ -16,10 +10,11 @@ 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
|
||||
|
|
@ -33,6 +28,11 @@ 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,10 +40,7 @@ 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))
|
||||
|
|
@ -60,7 +57,7 @@ export function NbaBracketLayout({
|
|||
const sharedMatches = new Map(
|
||||
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
|
||||
);
|
||||
const sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder);
|
||||
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -69,7 +66,7 @@ export function NbaBracketLayout({
|
|||
{conferenceGroups.map((group, gi) => {
|
||||
const confRounds = conferenceRounds[gi];
|
||||
const confMatches = splitMatchesByConference(matchesByRound, group);
|
||||
const geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder);
|
||||
const height = bracketHeight(confMatches, confRounds);
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
|
|
@ -77,11 +74,11 @@ export function NbaBracketLayout({
|
|||
{group.name}
|
||||
</p>
|
||||
<TreeColumns
|
||||
geometry={geometry}
|
||||
visibleRounds={confRounds}
|
||||
matchesByRound={confMatches}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={height}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -90,11 +87,11 @@ export function NbaBracketLayout({
|
|||
{sharedRounds.length > 0 && (
|
||||
<div>
|
||||
<TreeColumns
|
||||
geometry={sharedGeometry}
|
||||
visibleRounds={sharedRounds}
|
||||
matchesByRound={sharedMatches}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={sharedHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -108,8 +105,6 @@ export function NbaBracketLayout({
|
|||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@ import { GradientIcon } from "~/components/ui/GradientIcon";
|
|||
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 { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||
|
||||
|
|
@ -77,6 +76,43 @@ 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;
|
||||
|
|
@ -138,182 +174,6 @@ export function computeEliminatedByRound(
|
|||
return result;
|
||||
}
|
||||
|
||||
/** The score recorded for one participant in a match, or null if they didn't play in it. */
|
||||
function participantScore(match: Match, participantId: string | null): string | null {
|
||||
if (!participantId) return null;
|
||||
if (participantId === match.participant1Id) return match.participant1Score;
|
||||
if (participantId === match.participant2Id) return match.participant2Score;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A consolation final: a round contested by the losers of an earlier round, which
|
||||
* splits the positions those losers would otherwise share. FIFA's "Third Place Game"
|
||||
* (fed by the Semifinals) is the only one in the templates today.
|
||||
*/
|
||||
export interface ConsolationRound {
|
||||
/** The consolation round itself, e.g. "Third Place Game". */
|
||||
round: string;
|
||||
/** The round whose losers contest it, e.g. "Semifinals". */
|
||||
feederRound: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the template's consolation round, if it has one.
|
||||
*
|
||||
* A consolation round must be TERMINAL — its winner plays no further game, which is
|
||||
* what lets its result split two exact positions. `loserFeedsInto` alone is not
|
||||
* enough: a double-elimination bracket (llws_20) uses it on every winners-bracket
|
||||
* round to route losers into the elimination bracket, and those targets are ordinary
|
||||
* rounds that feed onward. Picking the first `loserFeedsInto` there would mistake
|
||||
* "Elimination Round 1" for a third-place game and corrupt the final rankings.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function findConsolationRound(
|
||||
template: BracketTemplate | undefined
|
||||
): ConsolationRound | undefined {
|
||||
const isTerminal = (roundName: string) =>
|
||||
template?.rounds.find((r) => r.name === roundName)?.feedsInto === null;
|
||||
|
||||
const feeder = template?.rounds.find(
|
||||
(r) => r.loserFeedsInto && isTerminal(r.loserFeedsInto)
|
||||
);
|
||||
if (!feeder?.loserFeedsInto) return undefined;
|
||||
return { round: feeder.loserFeedsInto, feederRound: feeder.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Round names whose losers are placed by some LATER round rather than finishing where
|
||||
* they lost — i.e. double-elimination winners-bracket rounds, whose losers drop into
|
||||
* the elimination bracket.
|
||||
*
|
||||
* The consolation feeder is deliberately excluded: its losers do finish at that tier
|
||||
* (the consolation game splits their two positions), so it still consumes them.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function roundsWithLosersPlacedLater(
|
||||
template: BracketTemplate | undefined,
|
||||
consolation: ConsolationRound | undefined
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
(template?.rounds ?? [])
|
||||
.filter((r) => r.loserFeedsInto && r.name !== consolation?.feederRound)
|
||||
.map((r) => r.name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ordered final-rankings list from completed matches.
|
||||
*
|
||||
* Ranks are derived by walking rounds latest-first: the final's loser is 2nd, the
|
||||
* previous round's losers share the next tier, and so on — each round consuming as
|
||||
* many positions as it has matches.
|
||||
*
|
||||
* A consolation round needs different handling, because its winner never loses a
|
||||
* match and so the loser-driven walk above would leave them unranked and "in
|
||||
* contention" forever. Its two places are exactly the top of the tier its feeder
|
||||
* round's losers would otherwise share, so it is resolved *at the feeder round* —
|
||||
* the winner takes that tier's first position and the loser the second — and the
|
||||
* consolation round itself consumes no positions. Positions are derived rather than
|
||||
* hardcoded, so a consolation round hanging off a different feeder still lands right.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function computeRankedEntries(
|
||||
matches: Match[],
|
||||
rounds: string[],
|
||||
matchesByRound: Map<string, Match[]>,
|
||||
consolation: ConsolationRound | undefined,
|
||||
ownershipMap: Map<string, TeamOwnership>,
|
||||
/** See roundsWithLosersPlacedLater. Empty for single-elimination brackets. */
|
||||
losersPlacedLater: Set<string> = new Set()
|
||||
): EliminatedEntry[] {
|
||||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||
|
||||
// Only take the consolation path when both rounds actually have matches; otherwise
|
||||
// fall through to the loser-driven walk so nothing is dropped.
|
||||
const consolationActive =
|
||||
consolation &&
|
||||
rounds.includes(consolation.round) &&
|
||||
rounds.includes(consolation.feederRound);
|
||||
|
||||
// Consolation matches we can place exactly. Anything else in that round (still in
|
||||
// progress, or missing its hydrated winner/loser) deliberately stays eligible for
|
||||
// the loser-driven walk rather than being silently dropped.
|
||||
const consolationMatches =
|
||||
consolationActive && consolation
|
||||
? (matchesByRound.get(consolation.round) ?? []).filter(
|
||||
(m): m is Match & { winner: Participant; loser: Participant } =>
|
||||
m.isComplete && !!m.winner && !!m.loser
|
||||
)
|
||||
: [];
|
||||
const exactlyPlacedMatchIds = new Set(consolationMatches.map((m) => m.id));
|
||||
|
||||
const entryFor = (
|
||||
match: Match,
|
||||
participant: Participant,
|
||||
participantId: string | null
|
||||
): Omit<EliminatedEntry, "rankLabel"> => ({
|
||||
participant,
|
||||
score: participantScore(match, participantId),
|
||||
ownership: ownershipMap.get(participant.id) || null,
|
||||
});
|
||||
|
||||
const losersByRound = new Map<string, Omit<EliminatedEntry, "rankLabel">[]>();
|
||||
for (const match of matches) {
|
||||
if (!match.isComplete || !match.loser) continue;
|
||||
if (exactlyPlacedMatchIds.has(match.id)) continue;
|
||||
if (!eliminatedByRound.get(match.round)?.includes(match.loser.id)) continue;
|
||||
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
||||
losersByRound.get(match.round)?.push(entryFor(match, match.loser, match.loserId));
|
||||
}
|
||||
|
||||
const rankedEntries: EliminatedEntry[] = [];
|
||||
let nextRank = 2;
|
||||
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||||
const roundName = rounds[ri];
|
||||
|
||||
// The consolation match splits the top of its feeder round's tier, so it is
|
||||
// placed first and the round's remaining losers share what's left below it.
|
||||
let tierRank = nextRank;
|
||||
if (consolationActive && roundName === consolation?.feederRound) {
|
||||
for (const match of consolationMatches) {
|
||||
rankedEntries.push({
|
||||
...entryFor(match, match.winner, match.winnerId),
|
||||
rankLabel: `${tierRank}`,
|
||||
});
|
||||
rankedEntries.push({
|
||||
...entryFor(match, match.loser, match.loserId),
|
||||
rankLabel: `${tierRank + 1}`,
|
||||
});
|
||||
tierRank += 2;
|
||||
}
|
||||
}
|
||||
|
||||
for (const loser of losersByRound.get(roundName) ?? []) {
|
||||
rankedEntries.push({ ...loser, rankLabel: `T${tierRank}` });
|
||||
}
|
||||
|
||||
// The consolation round's places belong to its feeder round's tier, so it
|
||||
// consumes none of its own.
|
||||
if (consolationActive && roundName === consolation?.round) continue;
|
||||
|
||||
// A round normally consumes one position per match — its losers finish here,
|
||||
// whether or not the games have been played yet (four semifinalists occupy 1–4
|
||||
// regardless). But in a double-elimination bracket a winners-bracket loss places
|
||||
// nobody: the loser drops into the elimination bracket and is ranked by whatever
|
||||
// knocks them out later. Those rounds must consume nothing, or every position
|
||||
// below inflates (a 20-team llws_20 bracket would end at "T23").
|
||||
if (losersPlacedLater.has(roundName)) continue;
|
||||
|
||||
nextRank += matchesByRound.get(roundName)?.length ?? 0;
|
||||
}
|
||||
|
||||
return rankedEntries;
|
||||
}
|
||||
|
||||
/** Find the index of the first round that has scoring matches. */
|
||||
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
||||
for (let i = 0; i < rounds.length; i++) {
|
||||
|
|
@ -355,13 +215,13 @@ 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;
|
||||
const thirdPlaceRound = template?.rounds
|
||||
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
||||
?.name;
|
||||
|
||||
// Build elimination rankings
|
||||
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
||||
let bracketWinner: Participant | null = null;
|
||||
|
||||
const lastRound = rounds[rounds.length - 1];
|
||||
|
|
@ -370,20 +230,43 @@ export function PlayoffBracket({
|
|||
: null;
|
||||
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
||||
|
||||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||
|
||||
for (const match of matches) {
|
||||
if (!match.isComplete || !match.loser) continue;
|
||||
const eliminatedInRound = eliminatedByRound.get(match.round);
|
||||
if (!eliminatedInRound?.includes(match.loser.id)) continue;
|
||||
const loserScore =
|
||||
match.loserId === match.participant1Id
|
||||
? match.participant1Score
|
||||
: match.participant2Score;
|
||||
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
||||
losersByRound.get(match.round)?.push({
|
||||
participant: match.loser,
|
||||
score: loserScore,
|
||||
ownership: ownershipMap.get(match.loser.id) || null,
|
||||
});
|
||||
}
|
||||
|
||||
const allBracketParticipantIds = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
||||
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
||||
}
|
||||
|
||||
const rankedEntries = computeRankedEntries(
|
||||
matches,
|
||||
rounds,
|
||||
matchesByRound,
|
||||
consolation,
|
||||
ownershipMap,
|
||||
roundsWithLosersPlacedLater(template, consolation)
|
||||
);
|
||||
const rankedEntries: EliminatedEntry[] = [];
|
||||
let nextRank = 2;
|
||||
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||||
const roundName = rounds[ri];
|
||||
const roundLosers = losersByRound.get(roundName) || [];
|
||||
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
|
||||
if (roundLosers.length > 0) {
|
||||
const rankLabel = `T${nextRank}`;
|
||||
for (const loser of roundLosers) {
|
||||
rankedEntries.push({ ...loser, rankLabel });
|
||||
}
|
||||
}
|
||||
nextRank += totalMatchesInRound;
|
||||
}
|
||||
|
||||
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
||||
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
||||
|
|
@ -444,8 +327,6 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
phases={template.phases}
|
||||
scoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
) : template?.conferenceGroups ? (
|
||||
<NbaBracketLayout
|
||||
|
|
@ -456,8 +337,6 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
conferenceGroups={template.conferenceGroups}
|
||||
scoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -469,8 +348,6 @@ export function PlayoffBracket({
|
|||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
thirdPlaceRound={thirdPlaceRound}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -483,8 +360,6 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
thirdPlaceRound={thirdPlaceRound}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,8 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
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 type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import { TreeColumns, BracketMatchSlot, 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[]>;
|
||||
|
|
@ -20,10 +10,11 @@ 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
|
||||
|
|
@ -38,6 +29,11 @@ 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 {
|
||||
|
|
@ -145,10 +141,7 @@ 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) => {
|
||||
|
|
@ -159,22 +152,7 @@ export function TabbedBracketLayout({
|
|||
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
||||
|
||||
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
|
||||
// Restrict each round to the match numbers this phase's groups actually claim.
|
||||
// Rounds can be shared across phases (LLWS runs U.S. and International through
|
||||
// the same rounds), so without this the mobile view would merge both sides into
|
||||
// one column. No-op where a phase's groups already cover every match in the
|
||||
// round (NCAA regions, NBA conferences) and for sharedRounds, which have no
|
||||
// group filter.
|
||||
const phaseMatchesByRound = new Map(
|
||||
phaseRounds.map((r) => {
|
||||
const all = matchesByRound.get(r) ?? [];
|
||||
if (!phase.groups || sharedRounds.includes(r)) return [r, all] as const;
|
||||
const allowed = new Set(
|
||||
phase.groups.flatMap((g) => g.roundMatchNumbers[r] ?? [])
|
||||
);
|
||||
return [r, allowed.size > 0 ? all.filter((m) => allowed.has(m.matchNumber)) : all] as const;
|
||||
})
|
||||
);
|
||||
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||
|
||||
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
||||
|
|
@ -201,87 +179,50 @@ 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>
|
||||
<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>
|
||||
<TreeColumns
|
||||
visibleRounds={gRounds}
|
||||
matchesByRound={gMatches}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={phaseHeight(gMatches, gRounds)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sharedRounds.length > 0 && (
|
||||
<TreeColumns
|
||||
geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
|
||||
visibleRounds={sharedRounds}
|
||||
matchesByRound={sharedMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TreeColumns
|
||||
geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
|
||||
visibleRounds={phaseRounds}
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* Mobile */}
|
||||
<div className="md:hidden">
|
||||
{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}
|
||||
|
|
@ -289,8 +230,6 @@ export function TabbedBracketLayout({
|
|||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, within } from "@testing-library/react";
|
||||
import { NbaBracketLayout } from "../NbaBracketLayout";
|
||||
import { buildFeederMap } from "~/lib/bracket-layout";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
import type { BracketMatch } from "../BracketTreeView";
|
||||
|
||||
/**
|
||||
* NbaBracketLayout renders a desktop view and a mobile pager side by side, hidden from
|
||||
* each other by Tailwind breakpoints. Both need `feeders` and `template` — without them
|
||||
* bracketGeometry falls back to index-derived positions and an unplayed slot reads "TBD"
|
||||
* where the feeder graph would name the game it is waiting on.
|
||||
*/
|
||||
|
||||
const TEMPLATE: BracketTemplate = {
|
||||
id: "test_conf_4",
|
||||
name: "Two-conference test bracket",
|
||||
totalTeams: 4,
|
||||
scoringStartsAtRound: "Final",
|
||||
rounds: [
|
||||
{ name: "Semis", matchCount: 2, feedsInto: "Final", isScoring: false },
|
||||
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true },
|
||||
],
|
||||
conferenceGroups: [
|
||||
{ name: "East", roundMatchNumbers: { Semis: [1] } },
|
||||
{ name: "West", roundMatchNumbers: { Semis: [2] } },
|
||||
],
|
||||
};
|
||||
|
||||
const ROUNDS = ["Semis", "Final"];
|
||||
|
||||
function match(
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
overrides: Partial<BracketMatch> = {}
|
||||
): BracketMatch {
|
||||
return {
|
||||
id: `${round}-${matchNumber}`,
|
||||
round,
|
||||
matchNumber,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
isComplete: false,
|
||||
participant1Score: null,
|
||||
participant2Score: null,
|
||||
...overrides,
|
||||
} as BracketMatch;
|
||||
}
|
||||
|
||||
/** Semis are played; the Final's two slots are still empty. */
|
||||
const MATCHES_BY_ROUND = new Map<string, BracketMatch[]>([
|
||||
[
|
||||
"Semis",
|
||||
[
|
||||
match("Semis", 1, { participant1Id: "p1", participant2Id: "p2" }),
|
||||
match("Semis", 2, { participant1Id: "p3", participant2Id: "p4" }),
|
||||
],
|
||||
],
|
||||
["Final", [match("Final", 1)]],
|
||||
]);
|
||||
|
||||
function renderLayout(withGraph: boolean) {
|
||||
const { container } = render(
|
||||
<NbaBracketLayout
|
||||
matches={[...MATCHES_BY_ROUND.values()].flat()}
|
||||
rounds={ROUNDS}
|
||||
matchesByRound={MATCHES_BY_ROUND}
|
||||
ownershipMap={new Map()}
|
||||
userParticipantIds={new Set()}
|
||||
conferenceGroups={TEMPLATE.conferenceGroups ?? []}
|
||||
scoringRoundIdx={1}
|
||||
feeders={withGraph ? buildFeederMap(TEMPLATE) : undefined}
|
||||
template={withGraph ? TEMPLATE : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
// Both panes render in jsdom — media queries are class-based, not applied — so scope
|
||||
// each assertion to the pane it is about.
|
||||
const mobile = container.querySelector<HTMLElement>(".md\\:hidden");
|
||||
const desktop = container.querySelector<HTMLElement>(".md\\:flex");
|
||||
if (!mobile || !desktop) throw new Error("Expected both a mobile and a desktop pane");
|
||||
return { mobile, desktop };
|
||||
}
|
||||
|
||||
describe("NbaBracketLayout", () => {
|
||||
it("names the feeding game in the mobile pager", () => {
|
||||
// Only slots filled by advancement get a label; a directly seeded slot with no
|
||||
// participant still reads "TBD", which is why this asserts on the Final's slots.
|
||||
const { mobile } = renderLayout(true);
|
||||
|
||||
expect(within(mobile).getAllByText(/Winner of/).length).toBe(2);
|
||||
});
|
||||
|
||||
it("shows the mobile pager the same slot labels as the desktop view", () => {
|
||||
const { mobile, desktop } = renderLayout(true);
|
||||
|
||||
const labels = (pane: HTMLElement) =>
|
||||
within(pane)
|
||||
.getAllByText(/Winner of/)
|
||||
.map((el) => el.textContent)
|
||||
.toSorted();
|
||||
|
||||
expect(labels(mobile)).toEqual(labels(desktop));
|
||||
});
|
||||
|
||||
it("falls back to TBD when the feeder graph is unavailable", () => {
|
||||
// Guards the assertions above: without feeders/template there is nothing to name a
|
||||
// slot with, which is exactly the state the mobile pane was stuck in.
|
||||
const { mobile } = renderLayout(false);
|
||||
|
||||
expect(within(mobile).queryByText(/Winner of/)).toBeNull();
|
||||
expect(within(mobile).getAllByText("TBD").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import {
|
||||
PlayoffBracket,
|
||||
groupMatchesByRound,
|
||||
computeEliminatedByRound,
|
||||
computeRankedEntries,
|
||||
findConsolationRound,
|
||||
roundsWithLosersPlacedLater,
|
||||
type Match,
|
||||
} from "../PlayoffBracket";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import { resolveLLWSAdvancement } from "~/models/playoff-match";
|
||||
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
|
@ -63,72 +52,88 @@ describe("groupMatchesByRound", () => {
|
|||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendered LLWS bracket — geometry and empty-slot labels
|
||||
// buildFeederMap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
it("still shows TBD for a directly seeded slot", () => {
|
||||
render(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
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),
|
||||
];
|
||||
|
||||
// The opening round is seeded, not fed, so it has nothing to name.
|
||||
expect(screen.getAllByText("TBD").length).toBeGreaterThan(0);
|
||||
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 });
|
||||
});
|
||||
|
||||
it("gives every card the same height, including a lone final", () => {
|
||||
const { container } = render(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
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),
|
||||
];
|
||||
|
||||
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);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -286,526 +291,3 @@ describe("computeEliminatedByRound", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computeRankedEntries — consolation ("third place") round handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fifa_48 round order. The Third Place Game sits between the Semifinals (whose
|
||||
// losers feed it) and the Finals.
|
||||
const FIFA_ROUNDS = ["Quarterfinals", "Semifinals", "Third Place Game", "Finals"];
|
||||
|
||||
const FIFA_CONSOLATION = {
|
||||
round: "Third Place Game",
|
||||
feederRound: "Semifinals",
|
||||
};
|
||||
|
||||
type MatchOpts = {
|
||||
/** Which slot the winner occupies. Defaults to 1. */
|
||||
winnerSlot?: 1 | 2;
|
||||
winnerScore?: string;
|
||||
loserScore?: string;
|
||||
/**
|
||||
* Drop the hydrated `winner` relation, keeping `loser` and both ids — the shape a
|
||||
* hand-built match object can arrive in. The loser is still placeable this way.
|
||||
*/
|
||||
missingWinnerRelation?: boolean;
|
||||
};
|
||||
|
||||
/** A completed match. */
|
||||
function makeRankedMatch(
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
winnerId: string,
|
||||
loserId: string,
|
||||
opts: MatchOpts = {}
|
||||
): Match {
|
||||
const winnerIsP1 = (opts.winnerSlot ?? 1) === 1;
|
||||
const p1 = winnerIsP1 ? winnerId : loserId;
|
||||
const p2 = winnerIsP1 ? loserId : winnerId;
|
||||
return {
|
||||
id: `${round}-${matchNumber}`,
|
||||
round,
|
||||
matchNumber,
|
||||
participant1Id: p1,
|
||||
participant2Id: p2,
|
||||
winnerId,
|
||||
loserId,
|
||||
isComplete: true,
|
||||
participant1Score: (winnerIsP1 ? opts.winnerScore : opts.loserScore) ?? null,
|
||||
participant2Score: (winnerIsP1 ? opts.loserScore : opts.winnerScore) ?? null,
|
||||
participant1: { id: p1, name: p1 },
|
||||
participant2: { id: p2, name: p2 },
|
||||
winner: opts.missingWinnerRelation ? null : { id: winnerId, name: winnerId },
|
||||
loser: { id: loserId, name: loserId },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A scheduled-but-unplayed match. Bracket rows are pre-generated with their slots
|
||||
* filled as earlier rounds resolve, so an unplayed 3PG still lists both SF losers.
|
||||
*/
|
||||
function makePendingMatch(
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
participant1Id: string | null,
|
||||
participant2Id: string | null
|
||||
): Match {
|
||||
return {
|
||||
id: `${round}-${matchNumber}`,
|
||||
round,
|
||||
matchNumber,
|
||||
participant1Id,
|
||||
participant2Id,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
isComplete: false,
|
||||
participant1Score: null,
|
||||
participant2Score: null,
|
||||
participant1: participant1Id ? { id: participant1Id, name: participant1Id } : null,
|
||||
participant2: participant2Id ? { id: participant2Id, name: participant2Id } : null,
|
||||
winner: null,
|
||||
loser: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A full fifa_48-shaped knockout tail: 4 QF, 2 SF, the 3PG, and the Final. */
|
||||
function fifaMatches(
|
||||
overrides: { played?: boolean; thirdPlace?: Match } = {}
|
||||
): Match[] {
|
||||
const played = overrides.played ?? true;
|
||||
return [
|
||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||
overrides.thirdPlace ??
|
||||
(played
|
||||
? makeRankedMatch("Third Place Game", 1, "sfB", "sfD")
|
||||
: makePendingMatch("Third Place Game", 1, "sfB", "sfD")),
|
||||
played
|
||||
? makeRankedMatch("Finals", 1, "sfA", "sfC")
|
||||
: makePendingMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
}
|
||||
|
||||
/** Rank the fifa fixture, defaulting to the fifa_48 consolation config. */
|
||||
function rankFifa(
|
||||
matches: Match[] = fifaMatches(),
|
||||
ownership: Map<string, { participantId: string; teamName: string; teamId: string }> = new Map(),
|
||||
consolation: typeof FIFA_CONSOLATION | undefined = FIFA_CONSOLATION
|
||||
) {
|
||||
return computeRankedEntries(
|
||||
matches,
|
||||
FIFA_ROUNDS,
|
||||
groupMatchesByRound(matches),
|
||||
consolation,
|
||||
ownership
|
||||
);
|
||||
}
|
||||
|
||||
function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
|
||||
return entries.find((e) => e.participant.id === id)?.rankLabel;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// llws_20 — double elimination, where a winners-bracket loss places nobody
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Stable participant id for an llws_20 bracket slot. */
|
||||
function llwsTeam(i: number): string {
|
||||
return `t${String(i).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a full 20-team LLWS tournament, always advancing the lower-numbered
|
||||
* participant id so the outcome is deterministic, and return every match.
|
||||
* Routing comes from the real advancement map rather than being hand-listed.
|
||||
*/
|
||||
function llwsMatches(): Match[] {
|
||||
const template = getBracketTemplate("llws_20");
|
||||
if (!template) throw new Error("llws_20 template missing");
|
||||
|
||||
// round → matchNumber → [p1, p2]
|
||||
const slots = new Map<string, Map<number, [string | null, string | null]>>();
|
||||
for (const round of template.rounds) {
|
||||
const byNumber = new Map<number, [string | null, string | null]>();
|
||||
for (let n = 1; n <= round.matchCount; n++) byNumber.set(n, [null, null]);
|
||||
slots.set(round.name, byNumber);
|
||||
}
|
||||
const put = (round: string, n: number, slot: 0 | 1, id: string) => {
|
||||
const pair = slots.get(round)?.get(n);
|
||||
if (pair) pair[slot] = id;
|
||||
};
|
||||
|
||||
// Seed the Opening Round and the four byes, mirroring generateLLWS20Bracket.
|
||||
for (const [base, roundBase] of [[0, 1], [10, 5]] as const) {
|
||||
for (let local = 0; local < 4; local++) {
|
||||
put("Opening Round", roundBase + local, 0, llwsTeam(base + local * 2));
|
||||
put("Opening Round", roundBase + local, 1, llwsTeam(base + local * 2 + 1));
|
||||
}
|
||||
}
|
||||
put("Winners Round 2", 1, 0, llwsTeam(8));
|
||||
put("Winners Round 2", 2, 0, llwsTeam(9));
|
||||
put("Winners Round 2", 3, 0, llwsTeam(18));
|
||||
put("Winners Round 2", 4, 0, llwsTeam(19));
|
||||
|
||||
const matches: Match[] = [];
|
||||
for (const round of template.rounds) {
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const [p1, p2] = slots.get(round.name)?.get(n) ?? [null, null];
|
||||
if (!p1 || !p2) throw new Error(`${round.name} #${n} was not filled`);
|
||||
// Deterministic: the lower id always wins.
|
||||
const winnerId = p1 < p2 ? p1 : p2;
|
||||
const loserId = p1 < p2 ? p2 : p1;
|
||||
matches.push(
|
||||
makeRankedMatch(round.name, n, winnerId, loserId, {
|
||||
winnerSlot: winnerId === p1 ? 1 : 2,
|
||||
})
|
||||
);
|
||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
||||
if (winner) put(winner.round, winner.matchNumber, winner.slot === "participant1Id" ? 0 : 1, winnerId);
|
||||
if (loser) put(loser.round, loser.matchNumber, loser.slot === "participant1Id" ? 0 : 1, loserId);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
describe("computeRankedEntries — llws_20 double elimination", () => {
|
||||
const template = getBracketTemplate("llws_20");
|
||||
const rounds = template?.rounds.map((r) => r.name) ?? [];
|
||||
|
||||
function rankLlws() {
|
||||
const matches = llwsMatches();
|
||||
const consolation = findConsolationRound(template);
|
||||
return computeRankedEntries(
|
||||
matches,
|
||||
rounds,
|
||||
groupMatchesByRound(matches),
|
||||
consolation,
|
||||
new Map(),
|
||||
roundsWithLosersPlacedLater(template, consolation)
|
||||
);
|
||||
}
|
||||
|
||||
it("ranks all 19 non-champions exactly once", () => {
|
||||
const entries = rankLlws();
|
||||
expect(entries).toHaveLength(19);
|
||||
expect(new Set(entries.map((e) => e.participant.id)).size).toBe(19);
|
||||
});
|
||||
|
||||
it("gives the top 8 the positions the scoring tiers depend on", () => {
|
||||
const entries = rankLlws();
|
||||
const labels = entries.map((e) => e.rankLabel);
|
||||
// 2nd (World Championship loser), then 3rd and 4th decided by the consolation
|
||||
// game, then the two 5–6 and two 7–8 tier teams.
|
||||
expect(labels[0]).toBe("T2");
|
||||
expect(labels.filter((l) => l === "3")).toHaveLength(1);
|
||||
expect(labels.filter((l) => l === "4")).toHaveLength(1);
|
||||
expect(labels.filter((l) => l === "T5")).toHaveLength(2);
|
||||
expect(labels.filter((l) => l === "T7")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not inflate positions below the top 8", () => {
|
||||
// Winners-bracket losses place nobody — those teams are ranked by the
|
||||
// elimination-bracket game that actually knocks them out. If the winners
|
||||
// rounds consumed positions, the last tier would read T23 in a 20-team field.
|
||||
const entries = rankLlws();
|
||||
const labels = entries.map((e) => e.rankLabel);
|
||||
expect(labels.filter((l) => l === "T9")).toHaveLength(4);
|
||||
expect(labels.filter((l) => l === "T13")).toHaveLength(4);
|
||||
expect(labels.filter((l) => l === "T17")).toHaveLength(4);
|
||||
// 1 champion (not in the list) + 19 ranked = the full 20-team field.
|
||||
expect(labels.some((l) => Number(l.replace("T", "")) > 17)).toBe(false);
|
||||
});
|
||||
|
||||
it("never ranks a winners-bracket loser at the round they first lost", () => {
|
||||
const entries = rankLlws();
|
||||
// t00 wins every game it plays (lowest id), so take a team that loses in the
|
||||
// winners bracket but survives: the Opening Round M1 loser, t01.
|
||||
const t01 = entries.find((e) => e.participant.id === "t01");
|
||||
expect(t01).toBeDefined();
|
||||
// Losing the opening game must not park them in the bottom tier — they got a
|
||||
// second life in the elimination bracket.
|
||||
expect(t01?.rankLabel).not.toBe("T17");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findConsolationRound", () => {
|
||||
it("identifies the fifa_48 third place game and the round that feeds it", () => {
|
||||
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
|
||||
round: "Third Place Game",
|
||||
feederRound: "Semifinals",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined for a template with no consolation round", () => {
|
||||
expect(findConsolationRound(getBracketTemplate("ncaa_64"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when there is no template", () => {
|
||||
expect(findConsolationRound(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores double-elimination loser routing and finds the real consolation game", () => {
|
||||
// llws_20 sets loserFeedsInto on every winners-bracket round to route losers
|
||||
// into the elimination bracket. Only the Bracket Championship feeds a terminal
|
||||
// round; taking the first loserFeedsInto instead would mistake "Elimination
|
||||
// Round 1" for a third-place game and corrupt the final rankings.
|
||||
expect(findConsolationRound(getBracketTemplate("llws_20"))).toEqual({
|
||||
round: "Consolation Third Place",
|
||||
feederRound: "Bracket Championship",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeRankedEntries", () => {
|
||||
describe("fifa_48 third place game", () => {
|
||||
it("ranks the third place game winner 3rd — they never lose a match after the SF", () => {
|
||||
// sfB lost the semifinal, then won the 3PG.
|
||||
expect(rankOf(rankFifa(), "sfB")).toBe("3");
|
||||
});
|
||||
|
||||
it("ranks the third place game loser 4th, not 3rd", () => {
|
||||
expect(rankOf(rankFifa(), "sfD")).toBe("4");
|
||||
});
|
||||
|
||||
it("gives quarterfinal losers T5 — the 3PG consumes no positions of its own", () => {
|
||||
const entries = rankFifa();
|
||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||
expect(rankOf(entries, "qf2")).toBe("T5");
|
||||
expect(rankOf(entries, "qf3")).toBe("T5");
|
||||
expect(rankOf(entries, "qf4")).toBe("T5");
|
||||
});
|
||||
|
||||
it("ranks the finals loser 2nd and leaves the champion out of the list", () => {
|
||||
const entries = rankFifa();
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfA")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("orders the list by rank: 2nd, 3rd, 4th, then the T5 tier", () => {
|
||||
expect(rankFifa().map((e) => e.rankLabel)).toEqual([
|
||||
"T2",
|
||||
"3",
|
||||
"4",
|
||||
"T5",
|
||||
"T5",
|
||||
"T5",
|
||||
"T5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves semifinal losers unranked until the third place game is played", () => {
|
||||
const entries = rankFifa(fifaMatches({ played: false }));
|
||||
// Both SF losers are still alive for the 3PG.
|
||||
expect(rankOf(entries, "sfB")).toBeUndefined();
|
||||
expect(rankOf(entries, "sfD")).toBeUndefined();
|
||||
// QF losers are still T5 — the later rounds still consume their positions.
|
||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||
});
|
||||
|
||||
it("carries ownership through onto the third place entries", () => {
|
||||
const ownership = new Map([
|
||||
["sfB", { participantId: "sfB", teamName: "Team Nine", teamId: "t9" }],
|
||||
]);
|
||||
const entries = rankFifa(fifaMatches(), ownership);
|
||||
expect(entries.find((e) => e.participant.id === "sfB")?.ownership?.teamName).toBe(
|
||||
"Team Nine"
|
||||
);
|
||||
expect(entries.find((e) => e.participant.id === "sfD")?.ownership).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scores", () => {
|
||||
it("reads each participant's own score regardless of which slot they occupied", () => {
|
||||
const matches = fifaMatches({
|
||||
// Winner sits in slot 2 this time, so a slot-blind lookup would swap the scores.
|
||||
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
||||
winnerSlot: 2,
|
||||
winnerScore: "3",
|
||||
loserScore: "1",
|
||||
}),
|
||||
});
|
||||
const entries = rankFifa(matches);
|
||||
|
||||
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("3");
|
||||
expect(entries.find((e) => e.participant.id === "sfD")?.score).toBe("1");
|
||||
});
|
||||
|
||||
it("reads a loser's score from the slot they actually played in", () => {
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
||||
winnerSlot: 2,
|
||||
winnerScore: "4",
|
||||
loserScore: "2",
|
||||
}),
|
||||
];
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
["Semifinals"],
|
||||
groupMatchesByRound(matches),
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("2");
|
||||
});
|
||||
|
||||
it("reports no score for a participant who occupies neither slot", () => {
|
||||
// A stale row after a bracket edit: loserId no longer matches either slot.
|
||||
// Attributing the other team's score here would look entirely plausible.
|
||||
const stale: Match = {
|
||||
...makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
||||
winnerScore: "4",
|
||||
loserScore: "2",
|
||||
}),
|
||||
loserId: "ghost",
|
||||
loser: { id: "ghost", name: "ghost" },
|
||||
};
|
||||
const entries = computeRankedEntries(
|
||||
[stale],
|
||||
["Semifinals"],
|
||||
groupMatchesByRound([stale]),
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(entries.find((e) => e.participant.id === "ghost")?.score).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a consolation round somewhere other than 3rd/4th", () => {
|
||||
// No template ships this today, but the positions must come from the feeder
|
||||
// round rather than being hardcoded to 3 and 4.
|
||||
const ROUNDS = ["Quarterfinals", "Semifinals", "Fifth Place Game", "Finals"];
|
||||
const CONSOLATION = { round: "Fifth Place Game", feederRound: "Quarterfinals" };
|
||||
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||
makeRankedMatch("Fifth Place Game", 1, "qf1", "qf2"),
|
||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
|
||||
it("places the consolation pair at its feeder round's tier, not at 3rd and 4th", () => {
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
ROUNDS,
|
||||
groupMatchesByRound(matches),
|
||||
CONSOLATION,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(rankOf(entries, "qf1")).toBe("5");
|
||||
expect(rankOf(entries, "qf2")).toBe("6");
|
||||
// The feeder round's other losers start below the pair, not alongside them.
|
||||
expect(rankOf(entries, "qf3")).toBe("T7");
|
||||
expect(rankOf(entries, "qf4")).toBe("T7");
|
||||
// The rounds above it are unaffected.
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfB")).toBe("T3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consolation matches that cannot be placed exactly", () => {
|
||||
it("still ranks the loser when the winner relation is missing", () => {
|
||||
const matches = fifaMatches({
|
||||
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
||||
missingWinnerRelation: true,
|
||||
}),
|
||||
});
|
||||
const entries = rankFifa(matches);
|
||||
|
||||
// The winner cannot be placed without a participant object, but the loser must
|
||||
// not silently vanish the way it would if the round were skipped wholesale.
|
||||
expect(rankOf(entries, "sfD")).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to the loser-driven walk when the feeder round has no matches", () => {
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Third Place Game", 1, "sfB", "sfD"),
|
||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
["Third Place Game", "Finals"],
|
||||
groupMatchesByRound(matches),
|
||||
FIFA_CONSOLATION,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfD")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendered output", () => {
|
||||
/** The card the 3PG winner was incorrectly appearing in. */
|
||||
function inContentionNames() {
|
||||
const card = screen.queryByText("In Contention")?.closest('[data-slot="card"]');
|
||||
if (!card) return [];
|
||||
return within(card as HTMLElement)
|
||||
.getAllByRole("row")
|
||||
.map((r) => r.textContent ?? "");
|
||||
}
|
||||
|
||||
it("does not list the third place game winner as in contention", () => {
|
||||
render(
|
||||
<PlayoffBracket matches={fifaMatches()} rounds={FIFA_ROUNDS} bracketTemplateId="fifa_48" />
|
||||
);
|
||||
|
||||
// sfB won the third place game — they are finished, not still playing.
|
||||
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(false);
|
||||
});
|
||||
|
||||
it("still lists semifinalists as in contention before the third place game", () => {
|
||||
render(
|
||||
<PlayoffBracket
|
||||
matches={fifaMatches({ played: false })}
|
||||
rounds={FIFA_ROUNDS}
|
||||
bracketTemplateId="fifa_48"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("brackets without a consolation round", () => {
|
||||
const ROUNDS = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
|
||||
it("ranks losers by round with tie labels, unchanged", () => {
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
ROUNDS,
|
||||
groupMatchesByRound(matches),
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfB")).toBe("T3");
|
||||
expect(rankOf(entries, "sfD")).toBe("T3");
|
||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||
expect(rankOf(entries, "sfA")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +1,18 @@
|
|||
import * as Sentry from "@sentry/react-router";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { shouldReportServerError } from "~/lib/error-reporting";
|
||||
|
||||
import type { AppLoadContext, EntryContext, HandleErrorFunction } from "react-router";
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { ServerRouter } from "react-router";
|
||||
import { isbot } from "isbot";
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
|
||||
const sentryHandleError = Sentry.createSentryHandleError({
|
||||
export const handleError = Sentry.createSentryHandleError({
|
||||
logErrors: true,
|
||||
});
|
||||
|
||||
export const handleError: HandleErrorFunction = (error, args) => {
|
||||
// Unrecognised URLs and methods are bot scans, not bugs. Skipping early also
|
||||
// keeps them out of the `logErrors` console output; morgan still logs the request.
|
||||
if (!shouldReportServerError(error, args.request)) return;
|
||||
return sentryHandleError(error, args);
|
||||
};
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
async function handleRequest(
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
/**
|
||||
* The AFL Wildcard winners are re-seeded into the Elimination Finals by ladder position
|
||||
* (5th draws the lower-ranked winner, 6th the higher-ranked one) rather than crossing
|
||||
* over from a fixed Wildcard match. These tests pin that mapping for every combination
|
||||
* of results, and for either order of entry.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveAflWildcardPlacements,
|
||||
AFL_WILDCARD_DRAW,
|
||||
AFL_ELIMINATION_HOSTS,
|
||||
type AflWildcardResult,
|
||||
} from "../afl-wildcard-reseed";
|
||||
|
||||
/** Both Wildcard games decided, addressed by the seed that won each. */
|
||||
function bothDecided(match1Winner: 7 | 10, match2Winner: 8 | 9): AflWildcardResult[] {
|
||||
return [
|
||||
{ matchNumber: 1, winnerSlot: match1Winner === 7 ? 1 : 2 },
|
||||
{ matchNumber: 2, winnerSlot: match2Winner === 8 ? 1 : 2 },
|
||||
];
|
||||
}
|
||||
|
||||
/** Elimination Finals match number each winning seed was sent to. */
|
||||
function slotsBySeed(results: AflWildcardResult[]): Record<number, number> {
|
||||
return Object.fromEntries(
|
||||
resolveAflWildcardPlacements(results).map((p) => [p.seed, p.eliminationMatchNumber])
|
||||
);
|
||||
}
|
||||
|
||||
describe("AFL Wildcard draw constants", () => {
|
||||
it("draws 7v10 and 8v9", () => {
|
||||
expect(AFL_WILDCARD_DRAW[1]).toEqual([7, 10]);
|
||||
expect(AFL_WILDCARD_DRAW[2]).toEqual([8, 9]);
|
||||
});
|
||||
|
||||
it("hosts the Elimination Finals with seeds 5 and 6", () => {
|
||||
expect(AFL_ELIMINATION_HOSTS[1]).toBe(5);
|
||||
expect(AFL_ELIMINATION_HOSTS[2]).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAflWildcardPlacements", () => {
|
||||
it("sends the higher-ranked winner to 6th and the lower to 5th (7 and 8 win)", () => {
|
||||
expect(slotsBySeed(bothDecided(7, 8))).toEqual({ 7: 2, 8: 1 });
|
||||
});
|
||||
|
||||
it("re-seeds when the lower seed wins the 7v10 game (10 and 8 win)", () => {
|
||||
// The bug this replaces sent the 7v10 winner to 6th regardless, pairing 5th with
|
||||
// 8th and handing 6th the weakest survivor.
|
||||
expect(slotsBySeed(bothDecided(10, 8))).toEqual({ 8: 2, 10: 1 });
|
||||
});
|
||||
|
||||
it("re-seeds when the lower seed wins the 8v9 game (7 and 9 win)", () => {
|
||||
expect(slotsBySeed(bothDecided(7, 9))).toEqual({ 7: 2, 9: 1 });
|
||||
});
|
||||
|
||||
it("re-seeds when both lower seeds win (10 and 9 win)", () => {
|
||||
expect(slotsBySeed(bothDecided(10, 9))).toEqual({ 9: 2, 10: 1 });
|
||||
});
|
||||
|
||||
it("places the 7v10 winner alone, since its rank is settled either way", () => {
|
||||
// 7th outranks both possible 8v9 winners; 10th is outranked by both.
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: 1 },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
])).toEqual({ 7: 2 });
|
||||
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: 2 },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
])).toEqual({ 10: 1 });
|
||||
});
|
||||
|
||||
it("holds an 8v9 winner back until the 7v10 game is decided", () => {
|
||||
// 8th and 9th both sit between 7th and 10th, so either slot is still possible.
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: null },
|
||||
{ matchNumber: 2, winnerSlot: 1 },
|
||||
])).toEqual({});
|
||||
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: null },
|
||||
{ matchNumber: 2, winnerSlot: 2 },
|
||||
])).toEqual({});
|
||||
});
|
||||
|
||||
it("places nothing while both games are undecided", () => {
|
||||
expect(resolveAflWildcardPlacements([
|
||||
{ matchNumber: 1, winnerSlot: null },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
])).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives the same answer whichever result is entered first", () => {
|
||||
for (const m1 of [7, 10] as const) {
|
||||
for (const m2 of [8, 9] as const) {
|
||||
const final = slotsBySeed(bothDecided(m1, m2));
|
||||
|
||||
// Whatever a single result places must survive the second result unchanged.
|
||||
const m1First = slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: m1 === 7 ? 1 : 2 },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
]);
|
||||
for (const [seed, slot] of Object.entries(m1First)) {
|
||||
expect(final[Number(seed)]).toBe(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a match number outside the Wildcard draw", () => {
|
||||
expect(() => resolveAflWildcardPlacements([{ matchNumber: 3, winnerSlot: 1 }])).toThrow(
|
||||
/Unknown AFL Wildcard Round match number 3/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,533 +0,0 @@
|
|||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { createStaticHandler } from "react-router";
|
||||
import { shouldReportServerError } from "../error-reporting";
|
||||
|
||||
const ORIGIN = "https://brackt.com";
|
||||
|
||||
/** Shaped like the ErrorResponse React Router hands to `handleError`. */
|
||||
function routeError(
|
||||
status: number,
|
||||
internal: boolean,
|
||||
statusText = "Not Found",
|
||||
) {
|
||||
return {
|
||||
status,
|
||||
statusText,
|
||||
internal,
|
||||
data: `Error: No route matches URL "/blog/wp/v2/posts/999999"`,
|
||||
};
|
||||
}
|
||||
|
||||
function request(path: string, referer?: string, method = "GET") {
|
||||
return new Request(`${ORIGIN}${path}`, {
|
||||
method,
|
||||
headers: referer ? { referer } : {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("shouldReportServerError", () => {
|
||||
it("drops a router 404 for a scanner hitting a URL cold", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, true),
|
||||
request("/blog/wp/v2/posts/999999"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a router 404 linked from another site", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, true),
|
||||
request("/blog/", "https://evil.example/"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a router 404 linked from one of our own pages", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, true),
|
||||
request("/leagues/gone", `${ORIGIN}/leagues`),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops the 405 from a POST to a route with no action", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(405, true, "Method Not Allowed"),
|
||||
request("/", undefined, "POST"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a 404 the app threw deliberately", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, false),
|
||||
request("/leagues/missing"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a 403 the app threw from an ownership check", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(403, false, "Forbidden"),
|
||||
request("/admin/sports"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a router-internal 500", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(500, true, "Internal Server Error"),
|
||||
request("/leagues"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a plain exception", () => {
|
||||
expect(
|
||||
shouldReportServerError(new Error("boom"), request("/leagues")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports anything that is not a route error response", () => {
|
||||
expect(shouldReportServerError("just a string", request("/leagues"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldReportServerError(null, request("/leagues"))).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a router 404 whose referer header is not a URL", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, true),
|
||||
request("/blog/", "not a url"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The unit tests above use hand-written error objects. These drive real requests
|
||||
* through React Router so the suite fails if the shape it throws ever changes.
|
||||
*/
|
||||
describe("shouldReportServerError against real React Router errors", () => {
|
||||
const handler = createStaticHandler([
|
||||
{
|
||||
id: "root",
|
||||
path: "/",
|
||||
children: [{ id: "home", index: true, loader: () => null }],
|
||||
},
|
||||
]);
|
||||
|
||||
async function errorFor(req: Request) {
|
||||
const ctx = await handler.query(req);
|
||||
if (ctx instanceof Response) return null;
|
||||
return Object.values(ctx.errors ?? {})[0] ?? null;
|
||||
}
|
||||
|
||||
it('drops the 404 for an unmatched URL (No route matches URL "...")', async () => {
|
||||
const req = request("/blog/wp/v2/posts/999999");
|
||||
const error = await errorFor(req);
|
||||
expect(error).toMatchObject({ status: 404, internal: true });
|
||||
expect(shouldReportServerError(error, req)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports the same 404 when it came from a link on our own site", async () => {
|
||||
const req = request("/nope", `${ORIGIN}/leagues`);
|
||||
expect(shouldReportServerError(await errorFor(req), req)).toBe(true);
|
||||
});
|
||||
|
||||
it("drops the 405 from a POST to a route with no action", async () => {
|
||||
const req = request("/", undefined, "POST");
|
||||
const error = await errorFor(req);
|
||||
expect(error).toMatchObject({ status: 405, internal: true });
|
||||
expect(shouldReportServerError(error, req)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("static asset 404s", () => {
|
||||
it("drops a stale hashed bundle even with a same-host referer", () => {
|
||||
// Every deploy leaves clients requesting the previous build's assets.
|
||||
const req = request("/assets/index-OLDHASH.js", `${ORIGIN}/leagues`);
|
||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/assets/app-x1.css",
|
||||
"/fonts/inter.woff2",
|
||||
"/images/logo.png",
|
||||
"/favicon.ico",
|
||||
])("drops a 404 for %s", (path) => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, true),
|
||||
request(path, `${ORIGIN}/`),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still follows the referer rule for a non-asset path containing a dot", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, true),
|
||||
request("/leagues/v1.2", `${ORIGIN}/leagues`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldReportServerError(routeError(404, true), request("/leagues/v1.2")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("React Router internal statuses that are not 404/405", () => {
|
||||
it("reports an internal 400 (route is missing a loader)", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(400, true, "Bad Request"),
|
||||
request("/leagues"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an internal 403 (route does not match URL)", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(403, true, "Forbidden"),
|
||||
request("/leagues"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("production shape: TLS terminated upstream", () => {
|
||||
it("reports a 404 linked from our own site when the proxy strips https", () => {
|
||||
// Express builds request.url from req.protocol, which is `http` inside the
|
||||
// container. Real browsers send an https referer. Comparing full origins
|
||||
// would never match, silencing every broken internal link.
|
||||
const req = new Request("http://brackt.com/leagues/gone", {
|
||||
headers: { referer: "https://brackt.com/leagues" },
|
||||
});
|
||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(true);
|
||||
});
|
||||
|
||||
it("still drops a cold scanner hit under that same shape", () => {
|
||||
const req = new Request("http://brackt.com/blog/wp/v2/posts/999999");
|
||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
||||
});
|
||||
|
||||
it("still drops a 404 linked from another site under that same shape", () => {
|
||||
const req = new Request("http://brackt.com/nope", {
|
||||
headers: { referer: "https://evil.example/" },
|
||||
});
|
||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
/**
|
||||
* AFL Wildcard Round → Elimination Finals re-seeding.
|
||||
*
|
||||
* The Wildcard Round is drawn 7 v 10 and 8 v 9, and its two winners fill the open slots
|
||||
* in the Elimination Finals opposite the 5th and 6th seeds. Those slots are NOT a fixed
|
||||
* crossover: the winners are re-seeded by ladder position, exactly as the classic final
|
||||
* eight pairs 5 v 8 and 6 v 7 — the higher seed of the two hosts meets the lower-ranked
|
||||
* winner. So 5th plays whichever winner finished further down the ladder and 6th plays
|
||||
* the other, whichever Wildcard game each came out of.
|
||||
*
|
||||
* Worked example: 10th beats 7th and 9th beats 8th. A fixed crossover would send the
|
||||
* 7v10 winner (10th) to 6th and the 8v9 winner (9th) to 5th — handing the higher host
|
||||
* the better opponent. Re-seeded, 5th plays 10th and 6th plays 9th.
|
||||
*/
|
||||
|
||||
/** Seeds drawn into each Wildcard Round match, in [participant1, participant2] order. */
|
||||
export const AFL_WILDCARD_DRAW: Readonly<Record<number, readonly [number, number]>> = {
|
||||
1: [7, 10],
|
||||
2: [8, 9],
|
||||
};
|
||||
|
||||
/** Seed hosting each Elimination Finals match (its participant1 slot). */
|
||||
export const AFL_ELIMINATION_HOSTS: Readonly<Record<number, number>> = {
|
||||
1: 5,
|
||||
2: 6,
|
||||
};
|
||||
|
||||
export interface AflWildcardResult {
|
||||
matchNumber: number;
|
||||
/** Slot the winner occupied, or null while the match is still to be played. */
|
||||
winnerSlot: 1 | 2 | null;
|
||||
}
|
||||
|
||||
export interface AflWildcardPlacement {
|
||||
wildcardMatchNumber: number;
|
||||
/** Seed of the Wildcard winner being placed. */
|
||||
seed: number;
|
||||
eliminationMatchNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which Elimination Final each decided Wildcard winner belongs in.
|
||||
*
|
||||
* A winner is only placed once its destination is settled whichever way the other
|
||||
* Wildcard game falls, so results can be entered in either order:
|
||||
* - 7th winning match 1 outranks both possible match 2 winners → always meets 6th.
|
||||
* - 10th winning match 1 is outranked by both → always meets 5th.
|
||||
* - A match 2 winner (8th or 9th) sits between them, so it is held back until match 1
|
||||
* is decided rather than being placed and then moved.
|
||||
*
|
||||
* Undecided winners are simply omitted; the caller fills the slots it is handed and
|
||||
* leaves the rest TBD.
|
||||
*/
|
||||
export function resolveAflWildcardPlacements(
|
||||
results: readonly AflWildcardResult[]
|
||||
): AflWildcardPlacement[] {
|
||||
const entries = results.map((result) => {
|
||||
const draw = AFL_WILDCARD_DRAW[result.matchNumber];
|
||||
if (!draw) {
|
||||
throw new Error(`Unknown AFL Wildcard Round match number ${result.matchNumber}`);
|
||||
}
|
||||
return {
|
||||
matchNumber: result.matchNumber,
|
||||
seed: result.winnerSlot === null ? null : draw[result.winnerSlot - 1],
|
||||
// Every seed the match could still send through — one entry once it is decided.
|
||||
possibleSeeds: result.winnerSlot === null ? [...draw] : [draw[result.winnerSlot - 1]],
|
||||
};
|
||||
});
|
||||
|
||||
// Best-ranked winner takes the weakest host, so order the hosts worst seed first.
|
||||
const hostsWorstFirst = Object.keys(AFL_ELIMINATION_HOSTS)
|
||||
.map(Number)
|
||||
.toSorted((a, b) => AFL_ELIMINATION_HOSTS[b] - AFL_ELIMINATION_HOSTS[a]);
|
||||
|
||||
const placements: AflWildcardPlacement[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const seed = entry.seed;
|
||||
if (seed === null) continue;
|
||||
|
||||
const others = entries.filter((other) => other !== entry);
|
||||
const outranks = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s < seed);
|
||||
const outrankedBy = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s > seed);
|
||||
|
||||
// This winner's rank is only knowable while every other one sits wholly above or
|
||||
// wholly below it — an undecided game straddling this seed leaves it unplaceable.
|
||||
if (!others.every((other) => outranks(other) || outrankedBy(other))) continue;
|
||||
|
||||
const rank = others.filter(outranks).length;
|
||||
const eliminationMatchNumber = hostsWorstFirst[rank];
|
||||
if (eliminationMatchNumber === undefined) {
|
||||
throw new Error(`No Elimination Finals slot for AFL Wildcard winner ranked ${rank + 1}`);
|
||||
}
|
||||
|
||||
placements.push({ wildcardMatchNumber: entry.matchNumber, seed, eliminationMatchNumber });
|
||||
}
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
|
@ -19,32 +19,6 @@ export interface BracketRound {
|
|||
* When set, the loser of each match in this round is placed into the target round.
|
||||
*/
|
||||
loserFeedsInto?: string | null;
|
||||
/**
|
||||
* Floor position banked by the WINNER of a *non-scoring* round.
|
||||
*
|
||||
* Omit for the default behavior: winners entering the first scoring round bank a
|
||||
* T5–T8 floor (position 5), everyone else banks nothing. Set an explicit number when
|
||||
* that default is wrong — in a double-elimination losers bracket a win can guarantee
|
||||
* a worse finish than 5th (llws_20 "Elimination Round 3" → 7). Set null to bank no
|
||||
* floor even though the next round scores.
|
||||
*
|
||||
* Has no effect on scoring rounds, which use RoundScoringConfig.winnerFloor instead.
|
||||
*/
|
||||
nonScoringWinnerFloor?: number | null;
|
||||
/**
|
||||
* Floor position every team is guaranteed simply by being *seeded into* this
|
||||
* round when the bracket is generated — before a single match is played.
|
||||
*
|
||||
* Omit (the default) for rounds where entering guarantees nothing: a team that
|
||||
* loses its first match earns 0. Set a number when the bracket structure locks
|
||||
* in a scoring tier on entry — e.g. afl_10's Qualifying Finals, where the loser
|
||||
* still gets a Semi-Final and so cannot finish worse than the 5th-6th tier.
|
||||
*
|
||||
* Only teams actually assigned to a match slot at generation receive this floor;
|
||||
* TBD slots filled later by advancing winners get their floor from the round they
|
||||
* won (nonScoringWinnerFloor / RoundScoringConfig.winnerFloor) instead.
|
||||
*/
|
||||
entryFloor?: number | null;
|
||||
}
|
||||
|
||||
export interface GroupStageConfig {
|
||||
|
|
@ -703,8 +677,7 @@ export const NFL_14: BracketTemplate = {
|
|||
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
|
||||
* - Week 1 Finals:
|
||||
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
|
||||
* - Elimination Finals: the two Wildcard winners are re-seeded by ladder position, so
|
||||
* 5th hosts the lower-ranked winner and 6th the higher-ranked one (losers share 7th-8th)
|
||||
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th)
|
||||
* - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th)
|
||||
* - Week 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
|
||||
* - Week 4: Grand Final (1st vs 2nd)
|
||||
|
|
@ -722,32 +695,18 @@ export const AFL_10: BracketTemplate = {
|
|||
matchCount: 2,
|
||||
feedsInto: "Elimination Finals",
|
||||
isScoring: false, // Losers get 0 points (9th-10th)
|
||||
// A Wildcard win only buys an Elimination Final; losing that is the 7th-8th
|
||||
// tier, so the winner banks 7 — not the generic "entering a scoring round
|
||||
// means top-8" default of 5, which would over-award them a 5th-6th floor.
|
||||
nonScoringWinnerFloor: 7,
|
||||
},
|
||||
{
|
||||
name: "Qualifying Finals",
|
||||
matchCount: 2,
|
||||
feedsInto: "Preliminary Finals", // Winners get bye
|
||||
isScoring: false, // Losers get second chance (go to Semi-Finals)
|
||||
// Seeds 1-4 have the double chance from the moment the bracket is drawn:
|
||||
// lose the QF, lose the Semi-Final, and you still finish in the 5th-6th tier.
|
||||
entryFloor: 5,
|
||||
// Winning the QF is a bye straight to a Preliminary Final; losing that is the
|
||||
// 3rd-4th tier, so the winner's floor is 3 rather than the generic default of 5.
|
||||
nonScoringWinnerFloor: 3,
|
||||
},
|
||||
{
|
||||
name: "Elimination Finals",
|
||||
matchCount: 2,
|
||||
feedsInto: "Semi-Finals",
|
||||
isScoring: true, // Losers share 7th-8th
|
||||
// Seeds 5-6 are seeded straight into this round, so the 7th-8th tier is
|
||||
// locked in for them at generation. (The other slot is a TBD Wildcard winner,
|
||||
// who banks the same floor by winning the Wildcard Round.)
|
||||
entryFloor: 7,
|
||||
},
|
||||
{
|
||||
name: "Semi-Finals",
|
||||
|
|
@ -975,258 +934,6 @@ export const NBA_20: BracketTemplate = {
|
|||
],
|
||||
};
|
||||
|
||||
// ── LLWS 20 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Side-local match numbers → global match numbers, per round shape. */
|
||||
const LLWS_OPENING_OFFSET = 4; // Opening Round: US M1–4, Intl M5–8
|
||||
const LLWS_PAIR_OFFSET = 2; // 4-match rounds: US M1–2, Intl M3–4
|
||||
const LLWS_SOLO_OFFSET = 1; // 2-match rounds: US M1, Intl M2
|
||||
|
||||
/** Rounds with 4 matches (2 per side). Opening Round has 8; the rest have 2. */
|
||||
export const LLWS_FOUR_MATCH_ROUNDS = new Set([
|
||||
"Winners Round 2",
|
||||
"Elimination Round 1",
|
||||
"Winners Semifinals",
|
||||
"Elimination Round 2",
|
||||
"Elimination Round 3",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns the global match number for a side-local match in an LLWS round.
|
||||
* side 0 = United States, side 1 = International.
|
||||
*/
|
||||
export function llwsMatchNumber(round: string, side: 0 | 1, localMatch: number): number {
|
||||
const offset =
|
||||
round === "Opening Round"
|
||||
? LLWS_OPENING_OFFSET
|
||||
: LLWS_FOUR_MATCH_ROUNDS.has(round)
|
||||
? LLWS_PAIR_OFFSET
|
||||
: LLWS_SOLO_OFFSET;
|
||||
return localMatch + side * offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of llwsMatchNumber: global match number → { side, localMatch }.
|
||||
*/
|
||||
export function llwsSideAndLocal(
|
||||
round: string,
|
||||
matchNumber: number
|
||||
): { side: 0 | 1; localMatch: number } {
|
||||
const offset =
|
||||
round === "Opening Round"
|
||||
? LLWS_OPENING_OFFSET
|
||||
: LLWS_FOUR_MATCH_ROUNDS.has(round)
|
||||
? LLWS_PAIR_OFFSET
|
||||
: LLWS_SOLO_OFFSET;
|
||||
const side: 0 | 1 = matchNumber > offset ? 1 : 0;
|
||||
return { side, localMatch: matchNumber - side * offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Little League Baseball World Series (20 teams, 2025+ double-elimination format)
|
||||
*
|
||||
* Two independent 10-team double-elimination brackets — United States and
|
||||
* International — each producing a side champion, then a World Championship game and
|
||||
* a Consolation Third Place game between the two side runners-up.
|
||||
*
|
||||
* Rounds are shared across both sides: U.S. matches take the low match numbers and
|
||||
* International the high ones (see llwsMatchNumber). The phases/groups config splits
|
||||
* them back apart for display.
|
||||
*
|
||||
* A loss in the winners bracket is NOT an elimination — it drops the team into the
|
||||
* elimination bracket at a specific slot (see advanceLLWSWinner in models/playoff-match).
|
||||
* A loss in the elimination bracket is final.
|
||||
*
|
||||
* There is deliberately NO "if necessary" game: the winners-bracket champion is out if
|
||||
* it loses the Bracket Championship, dropping to the Consolation game rather than
|
||||
* forcing a rematch. This is the official LLWS modified double-elimination format.
|
||||
*
|
||||
* Placement tiers (only 8 teams score — the field is exactly 8 when Elim R4 begins):
|
||||
* 1st / 2nd World Championship
|
||||
* 3rd / 4th Consolation Third Place (real game, so positions are distinct)
|
||||
* 5th / 6th Elimination Final losers
|
||||
* 7th / 8th Elimination Round 4 losers
|
||||
* 0 pts the 12 teams eliminated in Elimination Rounds 1–3
|
||||
*
|
||||
* Participant array layout (20 slots):
|
||||
* [0–7] U.S. Opening Round teams, two per game (M1..M4)
|
||||
* [8, 9] U.S. bye teams, entering Winners Round 2 M1 / M2 at participant1
|
||||
* [10–17] International Opening Round teams, two per game (M5..M8)
|
||||
* [18,19] International bye teams, entering Winners Round 2 M3 / M4 at participant1
|
||||
*/
|
||||
export const LLWS_20: BracketTemplate = {
|
||||
id: "llws_20",
|
||||
name: "Little League World Series (20 teams)",
|
||||
totalTeams: 20,
|
||||
scoringStartsAtRound: "Winners Final",
|
||||
// Ordered by the real schedule so non-phased views read chronologically.
|
||||
rounds: [
|
||||
{
|
||||
name: "Opening Round",
|
||||
matchCount: 8,
|
||||
feedsInto: "Winners Round 2",
|
||||
isScoring: false,
|
||||
loserFeedsInto: "Elimination Round 1",
|
||||
nonScoringWinnerFloor: null, // 16 teams still alive — nothing guaranteed
|
||||
},
|
||||
{
|
||||
name: "Winners Round 2",
|
||||
matchCount: 4,
|
||||
feedsInto: "Winners Semifinals",
|
||||
isScoring: false,
|
||||
loserFeedsInto: "Elimination Round 2",
|
||||
nonScoringWinnerFloor: null,
|
||||
},
|
||||
{
|
||||
name: "Elimination Round 1",
|
||||
matchCount: 4,
|
||||
feedsInto: "Elimination Round 2",
|
||||
isScoring: false, // losers finish 13th–16th
|
||||
nonScoringWinnerFloor: null,
|
||||
},
|
||||
{
|
||||
name: "Winners Semifinals",
|
||||
matchCount: 4,
|
||||
feedsInto: "Winners Final",
|
||||
isScoring: false,
|
||||
loserFeedsInto: "Elimination Round 3",
|
||||
// Reaching the Winners Final guarantees at worst 5th (lose it, then lose the
|
||||
// Elimination Final). Same value as the engine default, stated explicitly.
|
||||
nonScoringWinnerFloor: 5,
|
||||
},
|
||||
{
|
||||
name: "Elimination Round 2",
|
||||
matchCount: 4,
|
||||
feedsInto: "Elimination Round 3",
|
||||
isScoring: false, // losers finish 11th–12th
|
||||
nonScoringWinnerFloor: null,
|
||||
},
|
||||
{
|
||||
name: "Elimination Round 3",
|
||||
matchCount: 4,
|
||||
feedsInto: "Elimination Round 4",
|
||||
isScoring: false, // losers finish 9th–10th
|
||||
// Winners reach Elimination Round 4, where a loss is 7th — not 5th.
|
||||
nonScoringWinnerFloor: 7,
|
||||
},
|
||||
{
|
||||
name: "Winners Final",
|
||||
matchCount: 2,
|
||||
feedsInto: "Bracket Championship",
|
||||
isScoring: true, // loser drops to the Elimination Final (provisional 5th)
|
||||
loserFeedsInto: "Elimination Final",
|
||||
},
|
||||
{
|
||||
name: "Elimination Round 4",
|
||||
matchCount: 2,
|
||||
feedsInto: "Elimination Final",
|
||||
isScoring: true, // losers share 7th–8th
|
||||
},
|
||||
{
|
||||
name: "Elimination Final",
|
||||
matchCount: 2,
|
||||
feedsInto: "Bracket Championship",
|
||||
isScoring: true, // losers share 5th–6th
|
||||
},
|
||||
{
|
||||
name: "Bracket Championship",
|
||||
matchCount: 2,
|
||||
feedsInto: "World Championship",
|
||||
isScoring: true, // loser drops to the Consolation game (provisional 4th)
|
||||
loserFeedsInto: "Consolation Third Place",
|
||||
},
|
||||
{
|
||||
name: "Consolation Third Place",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // winner 3rd, loser 4th
|
||||
},
|
||||
{
|
||||
name: "World Championship",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // winner 1st, loser 2nd
|
||||
},
|
||||
],
|
||||
// Region assignments rotate year to year (which region draws the bye changes), so
|
||||
// these are positional slot labels rather than region names. Kept short — the admin
|
||||
// form renders them in a narrow fixed-width column alongside each participant picker.
|
||||
participantLabels: [
|
||||
"US G1 Home", "US G1 Away",
|
||||
"US G2 Home", "US G2 Away",
|
||||
"US G3 Home", "US G3 Away",
|
||||
"US G4 Home", "US G4 Away",
|
||||
"US Bye 1", "US Bye 2",
|
||||
"Intl G1 Home", "Intl G1 Away",
|
||||
"Intl G2 Home", "Intl G2 Away",
|
||||
"Intl G3 Home", "Intl G3 Away",
|
||||
"Intl G4 Home", "Intl G4 Away",
|
||||
"Intl Bye 1", "Intl Bye 2",
|
||||
],
|
||||
phases: [
|
||||
{
|
||||
name: "United States",
|
||||
groups: [
|
||||
{
|
||||
name: "U.S. Winner's Bracket",
|
||||
roundMatchNumbers: {
|
||||
"Opening Round": [1, 2, 3, 4],
|
||||
"Winners Round 2": [1, 2],
|
||||
"Winners Semifinals": [1, 2],
|
||||
"Winners Final": [1],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "U.S. Elimination Bracket",
|
||||
roundMatchNumbers: {
|
||||
"Elimination Round 1": [1, 2],
|
||||
"Elimination Round 2": [1, 2],
|
||||
"Elimination Round 3": [1, 2],
|
||||
"Elimination Round 4": [1],
|
||||
"Elimination Final": [1],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "U.S. Championship",
|
||||
roundMatchNumbers: { "Bracket Championship": [1] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "International",
|
||||
groups: [
|
||||
{
|
||||
name: "International Winner's Bracket",
|
||||
roundMatchNumbers: {
|
||||
"Opening Round": [5, 6, 7, 8],
|
||||
"Winners Round 2": [3, 4],
|
||||
"Winners Semifinals": [3, 4],
|
||||
"Winners Final": [2],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "International Elimination Bracket",
|
||||
roundMatchNumbers: {
|
||||
"Elimination Round 1": [3, 4],
|
||||
"Elimination Round 2": [3, 4],
|
||||
"Elimination Round 3": [3, 4],
|
||||
"Elimination Round 4": [2],
|
||||
"Elimination Final": [2],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "International Championship",
|
||||
roundMatchNumbers: { "Bracket Championship": [2] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Championship",
|
||||
rounds: ["Consolation Third Place", "World Championship"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* All available bracket templates
|
||||
*/
|
||||
|
|
@ -1244,7 +951,6 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
|
|||
tennis_128: TENNIS_128,
|
||||
cfp_12: CFP_12,
|
||||
nba_20: NBA_20,
|
||||
llws_20: LLWS_20,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1300,18 +1006,6 @@ export function getScoringRoundType(
|
|||
const round = template.rounds.find((r) => r.name === roundName);
|
||||
if (!round || !round.isScoring) return null;
|
||||
|
||||
// Special handling for LLWS double elimination: match counts don't identify the
|
||||
// tier (Elimination Round 4 and the Elimination Final both have 2 matches), and
|
||||
// the Winners Final eliminates nobody.
|
||||
if (template.id === "llws_20") {
|
||||
if (roundName === "Elimination Round 4") return "quarterfinals"; // losers share 7-8th
|
||||
if (roundName === "Elimination Final") return "quarterfinals"; // losers share 5-6th
|
||||
if (roundName === "Bracket Championship") return "semifinals"; // losers play for 3-4th
|
||||
if (roundName === "Consolation Third Place") return "semifinals"; // finalizes 3rd/4th
|
||||
if (roundName === "World Championship") return "finals"; // 1st and 2nd
|
||||
return null; // Winners Final: loser drops to the elimination bracket, nobody is out
|
||||
}
|
||||
|
||||
// Special handling for AFL finals
|
||||
if (template.id === "afl_10") {
|
||||
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
/**
|
||||
* Decides which server-side errors are worth sending to Sentry.
|
||||
*
|
||||
* Automated scanners probe for CMS paths that have never existed here
|
||||
* (`/blog/wp/v2/posts/999999`, `/wp-login.php`, a bare `POST /`). React Router
|
||||
* throws for each one — a 404 when no route matches, a 405 when a route has no
|
||||
* `action` — and every throw reaches `handleError` in `app/entry.server.tsx`.
|
||||
* Reporting those burns the Sentry quota without ever describing a real bug.
|
||||
*/
|
||||
import { isRouteErrorResponse } from "react-router";
|
||||
|
||||
/**
|
||||
* Statuses React Router uses to say "nothing here matched this request":
|
||||
* 404 when no route matches the URL, 405 when the route has no `action` or the
|
||||
* method is invalid. Its other internal statuses (400 "did not provide a
|
||||
* `loader`", 403 "Route does not match URL") describe a misconfigured route
|
||||
* rather than an unrecognised request, so those keep reporting.
|
||||
*/
|
||||
const UNMATCHED_REQUEST_STATUSES = new Set([404, 405]);
|
||||
|
||||
/**
|
||||
* Static assets 404 in bulk for reasons that are never actionable: scanners
|
||||
* guessing filenames, and clients running stale HTML that still references the
|
||||
* previous deploy's hashed bundles.
|
||||
*/
|
||||
const ASSET_EXT_RE =
|
||||
/\.(css|js|mjs|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot)$/i;
|
||||
|
||||
/** React Router stamps `internal: true` on the errors it generates itself. */
|
||||
function isInternalRouterError(error: unknown): boolean {
|
||||
return (error as { internal?: unknown }).internal === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request was linked from a page on this same site.
|
||||
*
|
||||
* Compares host rather than origin on purpose. Production terminates TLS
|
||||
* upstream and serves plain HTTP in the container, so `request.url` — which
|
||||
* `@react-router/express` builds from `req.protocol` — says `http` while the
|
||||
* browser sends an `https` referer. Comparing full origins would therefore
|
||||
* never match in production. (`app/routes/leagues/$leagueId.server.ts` works
|
||||
* around the same mismatch for invite URLs.) Protocol tells us nothing about
|
||||
* whether the link was ours; host does.
|
||||
*/
|
||||
function hasSameHostReferer(request: Request): boolean {
|
||||
const referer = request.headers.get("referer");
|
||||
if (!referer) return false;
|
||||
try {
|
||||
return new URL(referer).host === new URL(request.url).host;
|
||||
} catch {
|
||||
// Scanners send garbage in this header; a referer we can't parse isn't ours.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `error` should be reported to Sentry.
|
||||
*
|
||||
* Drops the 404s and 405s React Router generated for a request that matched
|
||||
* nothing. Everything else is reported: real exceptions, 5xx, React Router's
|
||||
* other internal statuses, and responses the app threw deliberately
|
||||
* (`internal: false`), so a 403 from an ownership check still shows up.
|
||||
*
|
||||
* The exception is a request carrying a same-host `Referer`: a 404 reached from
|
||||
* one of our own pages is a broken internal link, not a scanner, and stays
|
||||
* visible in Sentry. Asset paths are excluded from that exception — a stale
|
||||
* client requesting last deploy's bundle sends a same-host referer too, and
|
||||
* would otherwise spike Sentry on every release.
|
||||
*/
|
||||
export function shouldReportServerError(
|
||||
error: unknown,
|
||||
request: Request,
|
||||
): boolean {
|
||||
if (!isRouteErrorResponse(error)) return true;
|
||||
if (!isInternalRouterError(error)) return true;
|
||||
if (!UNMATCHED_REQUEST_STATUSES.has(error.status)) return true;
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(request.url).pathname;
|
||||
} catch {
|
||||
pathname = "";
|
||||
}
|
||||
if (ASSET_EXT_RE.test(pathname)) return false;
|
||||
|
||||
return hasSameHostReferer(request);
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
/**
|
||||
* 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) };
|
||||
}
|
||||
|
|
@ -7,8 +7,7 @@
|
|||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AFL_10, getScoringRoundType } from "~/lib/bracket-templates";
|
||||
import { calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints, type ScoringRules } from "../scoring-rules";
|
||||
import { getBracketEntryFloor } from "../scoring-calculator";
|
||||
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
|
||||
|
||||
const DEFAULT_SCORING: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
|
|
@ -207,69 +206,3 @@ describe("AFL Finals System - Phase 3.3", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AFL guaranteed floors from seeding (afl_10)", () => {
|
||||
const byName = (name: string) => AFL_10.rounds.find((r) => r.name === name);
|
||||
|
||||
describe("getBracketEntryFloor — banked the moment the bracket is set", () => {
|
||||
it("gives seeds 1-4 the 5th-6th tier: the double chance is locked in at seeding", () => {
|
||||
// Worst case for a top-4 seed is lose the Qualifying Final, then lose the
|
||||
// Semi-Final — which is the 5th-6th tier. They can never finish below it.
|
||||
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
|
||||
expect(calculateBracketPoints(5, DEFAULT_SCORING, "afl_10")).toBe(25);
|
||||
});
|
||||
|
||||
it("gives seeds 5-6 the 7th-8th tier: they are seeded straight into a scoring round", () => {
|
||||
expect(getBracketEntryFloor("Elimination Finals", "afl_10")).toBe(7);
|
||||
expect(calculateBracketPoints(7, DEFAULT_SCORING, "afl_10")).toBe(15);
|
||||
});
|
||||
|
||||
it("gives seeds 7-10 nothing: a Wildcard loss is worth 0", () => {
|
||||
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the template is unknown or missing", () => {
|
||||
expect(getBracketEntryFloor("Qualifying Finals", null)).toBeNull();
|
||||
expect(getBracketEntryFloor("Qualifying Finals", "not_a_template")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not hand out floors for TBD rounds nobody is seeded into yet", () => {
|
||||
// These rounds do carry a loser tier, but every slot is empty at generation,
|
||||
// so applyBracketEntryFloors has no participant to write against.
|
||||
expect(byName("Semi-Finals")?.entryFloor).toBeUndefined();
|
||||
expect(byName("Preliminary Finals")?.entryFloor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nonScoringWinnerFloor — the generic top-8 default is wrong for both AFL non-scoring rounds", () => {
|
||||
it("Qualifying Finals winners bank 3, not 5 — the bye means a Prelim loss is 3rd-4th", () => {
|
||||
expect(byName("Qualifying Finals")?.nonScoringWinnerFloor).toBe(3);
|
||||
});
|
||||
|
||||
it("Wildcard winners bank 7, not 5 — winning only buys an Elimination Final", () => {
|
||||
expect(byName("Wildcard Round")?.nonScoringWinnerFloor).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("floors only ever improve along every AFL path", () => {
|
||||
const pts = (position: number) => calculateBracketPoints(position, DEFAULT_SCORING, "afl_10");
|
||||
|
||||
it("top-4 seed: entry 5 → QF win 3 → PF win 2 → GF win 1", () => {
|
||||
expect(pts(5)).toBeLessThan(pts(3));
|
||||
expect(pts(3)).toBeLessThan(pts(2));
|
||||
expect(pts(2)).toBeLessThan(pts(1));
|
||||
});
|
||||
|
||||
it("top-4 seed losing the QF holds the entry floor, then finalizes at 5th-6th", () => {
|
||||
// QF losers advance to the Semi-Final, so nothing is written at the QF —
|
||||
// the entry floor of 5 carries them until the Semi-Final resolves.
|
||||
const entryFloor = getBracketEntryFloor("Qualifying Finals", "afl_10");
|
||||
expect(entryFloor).toBe(5);
|
||||
expect(pts(entryFloor ?? 0)).toBe(25); // unchanged by the loss
|
||||
});
|
||||
|
||||
it("seeds 5-6 and Wildcard winners share a 7th-8th floor, below the top-4's", () => {
|
||||
expect(pts(7)).toBeLessThan(pts(5));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,273 +0,0 @@
|
|||
/**
|
||||
* Advancing an AFL Elimination Finals winner into the Semi-Finals.
|
||||
*
|
||||
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
|
||||
* n. The crossover comes a round later, at Semi-Finals → Preliminary Finals, so that a
|
||||
* Qualifying Final loser cannot meet the side that just beat it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AFL_10 } from "~/lib/bracket-templates";
|
||||
|
||||
interface MatchRow {
|
||||
id: string;
|
||||
scoringEventId: string;
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
isComplete: boolean;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
}
|
||||
|
||||
let rows: MatchRow[] = [];
|
||||
|
||||
/**
|
||||
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
|
||||
* mock needs to tell one lookup from another — there is no query engine behind it.
|
||||
*/
|
||||
function whereValues(node: unknown, depth = 0): string[] {
|
||||
if (!node || depth > 10) return [];
|
||||
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
|
||||
if (typeof node !== "object") return [];
|
||||
const obj = node as Record<string, unknown>;
|
||||
const own = typeof obj.value === "string" ? [obj.value] : [];
|
||||
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
|
||||
}
|
||||
|
||||
const db = {
|
||||
query: {
|
||||
playoffMatches: {
|
||||
findFirst: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(rows.find((r) => values.includes(r.id)));
|
||||
}),
|
||||
findMany: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(
|
||||
rows
|
||||
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
);
|
||||
}),
|
||||
},
|
||||
},
|
||||
update: vi.fn(() => ({
|
||||
set: (data: Partial<MatchRow>) => {
|
||||
const applyTo = (where: unknown) => {
|
||||
const values = whereValues(where);
|
||||
const target = rows.find((r) => values.includes(r.id));
|
||||
if (target) Object.assign(target, data);
|
||||
return target;
|
||||
};
|
||||
// Advancement writes through the query builder with and without .returning().
|
||||
return {
|
||||
where: (where: unknown) => {
|
||||
const applied = Promise.resolve([applyTo(where)]);
|
||||
return Object.assign(applied, { returning: () => applied });
|
||||
},
|
||||
};
|
||||
},
|
||||
})),
|
||||
// No rollback: the tests assert the writes that were attempted, in order.
|
||||
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: () => db }));
|
||||
|
||||
const { advanceWinnerTemplate, reseedAflSemiFinals } = await import("../playoff-match");
|
||||
|
||||
const EVENT = "event-1";
|
||||
|
||||
/**
|
||||
* The real 2026 finals, which is what surfaced the crossover bug. Ladder: 1 Fremantle,
|
||||
* 2 Sydney, 3 Brisbane, 4 Hawthorn, 5 Geelong, 6 Adelaide, 7 Melbourne, 8 Bulldogs,
|
||||
* 9 Collingwood, 10 Carlton. Carlton (10th) and the Bulldogs (8th) came through the
|
||||
* Wildcard Round, so 5th hosts Carlton and 6th hosts the Bulldogs.
|
||||
*/
|
||||
const FREO = "fremantle";
|
||||
const SYDNEY = "sydney";
|
||||
const BRISBANE = "brisbane";
|
||||
const HAWTHORN = "hawthorn";
|
||||
const GEELONG = "geelong";
|
||||
const ADELAIDE = "adelaide";
|
||||
const BULLDOGS = "bulldogs";
|
||||
const CARLTON = "carlton";
|
||||
|
||||
/** An afl_10 bracket with week one played: Freo and Brisbane lost their Qualifying Finals. */
|
||||
function bracket(): MatchRow[] {
|
||||
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
|
||||
return [
|
||||
{ ...base, id: "qf1", round: "Qualifying Finals", matchNumber: 1, participant1Id: FREO, participant2Id: HAWTHORN, isComplete: true, winnerId: HAWTHORN, loserId: FREO },
|
||||
{ ...base, id: "qf2", round: "Qualifying Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: BRISBANE, isComplete: true, winnerId: SYDNEY, loserId: BRISBANE },
|
||||
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: GEELONG, participant2Id: CARLTON },
|
||||
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: ADELAIDE, participant2Id: BULLDOGS },
|
||||
// Filled by the Qualifying Final losers, as advancement already does.
|
||||
{ ...base, id: "sf1", round: "Semi-Finals", matchNumber: 1, participant1Id: FREO, participant2Id: null },
|
||||
{ ...base, id: "sf2", round: "Semi-Finals", matchNumber: 2, participant1Id: BRISBANE, participant2Id: null },
|
||||
{ ...base, id: "pf1", round: "Preliminary Finals", matchNumber: 1, participant1Id: HAWTHORN, participant2Id: null },
|
||||
{ ...base, id: "pf2", round: "Preliminary Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: null },
|
||||
];
|
||||
}
|
||||
|
||||
function row(id: string): MatchRow {
|
||||
const found = rows.find((r) => r.id === id);
|
||||
if (!found) throw new Error(`No such match ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Record a result the way setMatchWinner does, then advance it. */
|
||||
async function win(id: string, winnerId: string) {
|
||||
const match = row(id);
|
||||
match.winnerId = winnerId;
|
||||
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||||
match.isComplete = true;
|
||||
await advanceWinnerTemplate(id, winnerId, AFL_10);
|
||||
}
|
||||
|
||||
const pairing = () => ({
|
||||
sf1: [row("sf1").participant1Id, row("sf1").participant2Id],
|
||||
sf2: [row("sf2").participant1Id, row("sf2").participant2Id],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
rows = bracket();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Elimination Finals → Semi-Finals advancement", () => {
|
||||
it("feeds Elimination Final 1 into Semi-Final 1", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
|
||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
||||
expect(row("sf2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("feeds Elimination Final 2 into Semi-Final 2", async () => {
|
||||
await win("ef2", ADELAIDE);
|
||||
|
||||
expect(row("sf2").participant2Id).toBe(ADELAIDE);
|
||||
expect(row("sf1").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the real 2026 Semi-Finals: Freo v Geelong and Brisbane v Adelaide", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
await win("ef2", ADELAIDE);
|
||||
|
||||
expect(pairing()).toEqual({
|
||||
sf1: [FREO, GEELONG],
|
||||
sf2: [BRISBANE, ADELAIDE],
|
||||
});
|
||||
});
|
||||
|
||||
it("draws the same Semi-Finals whichever order the results are entered", async () => {
|
||||
await win("ef2", ADELAIDE);
|
||||
await win("ef1", GEELONG);
|
||||
|
||||
expect(pairing()).toEqual({
|
||||
sf1: [FREO, GEELONG],
|
||||
sf2: [BRISBANE, ADELAIDE],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the Preliminary Finals crossover so a QF loser dodges the side that beat it", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
await win("ef2", ADELAIDE);
|
||||
// Freo (lost QF1 to Hawthorn) wins its semi, so it must land in Sydney's Prelim.
|
||||
await win("sf1", FREO);
|
||||
|
||||
expect(row("pf2").participant2Id).toBe(FREO);
|
||||
expect(row("pf1").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("pulls the beaten team back out when an Elimination Final result is corrected", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
||||
|
||||
await win("ef1", CARLTON);
|
||||
|
||||
expect(row("sf1").participant2Id).toBe(CARLTON);
|
||||
expect(row("sf2").participant2Id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reseedAflSemiFinals", () => {
|
||||
it("repairs a bracket left crossed by the old fixed crossover", async () => {
|
||||
// What advancement wrote before the fix: EF1 winner into SF2, EF2 winner into SF1.
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
|
||||
row("sf1").participant2Id = ADELAIDE;
|
||||
row("sf2").participant2Id = GEELONG;
|
||||
|
||||
const reseed = await reseedAflSemiFinals(EVENT);
|
||||
|
||||
expect(pairing()).toEqual({
|
||||
sf1: [FREO, GEELONG],
|
||||
sf2: [BRISBANE, ADELAIDE],
|
||||
});
|
||||
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
|
||||
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
|
||||
{ matchNumber: 1, participantId: GEELONG },
|
||||
{ matchNumber: 2, participantId: ADELAIDE },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes nothing when the pairings are already right", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
|
||||
row("sf1").participant2Id = GEELONG;
|
||||
row("sf2").participant2Id = ADELAIDE;
|
||||
|
||||
const reseed = await reseedAflSemiFinals(EVENT);
|
||||
|
||||
expect(reseed).toEqual({ vacated: [], filled: [] });
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves an undecided Elimination Final's slot TBD", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
|
||||
await reseedAflSemiFinals(EVENT);
|
||||
|
||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
||||
expect(row("sf2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a slot held by someone who never played an Elimination Final", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
row("sf1").participant2Id = SYDNEY;
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow("SF 1 participant2 already filled");
|
||||
});
|
||||
|
||||
it("refuses to move a qualifier out of a Semi-Final that has been played", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
Object.assign(row("sf1"), {
|
||||
participant2Id: ADELAIDE,
|
||||
isComplete: true,
|
||||
winnerId: FREO,
|
||||
loserId: ADELAIDE,
|
||||
});
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
||||
"Semi-Finals match 1 already has a recorded result"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an Elimination Final winner who is not one of its participants", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: SYDNEY, loserId: CARLTON });
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
||||
"Elimination Finals match 1 winner is not one of its participants"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on an event with no Semi-Finals to re-seed", async () => {
|
||||
rows = rows.filter((r) => r.round !== "Semi-Finals");
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
||||
"no AFL Elimination Finals / Semi-Finals matches to re-seed"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
/**
|
||||
* Advancing an AFL Wildcard Round winner into the Elimination Finals.
|
||||
*
|
||||
* The two winners are re-seeded by ladder position — 5th hosts the lower-ranked winner,
|
||||
* 6th the higher-ranked one — so the destination is not a fixed crossover from a given
|
||||
* Wildcard match, and results can be recorded in either order.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AFL_10 } from "~/lib/bracket-templates";
|
||||
|
||||
interface MatchRow {
|
||||
id: string;
|
||||
scoringEventId: string;
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
isComplete: boolean;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
}
|
||||
|
||||
let rows: MatchRow[] = [];
|
||||
|
||||
/**
|
||||
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
|
||||
* mock needs to tell one lookup from another — there is no query engine behind it.
|
||||
*/
|
||||
function whereValues(node: unknown, depth = 0): string[] {
|
||||
if (!node || depth > 10) return [];
|
||||
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
|
||||
if (typeof node !== "object") return [];
|
||||
const obj = node as Record<string, unknown>;
|
||||
const own = typeof obj.value === "string" ? [obj.value] : [];
|
||||
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
|
||||
}
|
||||
|
||||
const db = {
|
||||
query: {
|
||||
playoffMatches: {
|
||||
findFirst: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(rows.find((r) => values.includes(r.id)));
|
||||
}),
|
||||
findMany: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(
|
||||
rows
|
||||
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
);
|
||||
}),
|
||||
},
|
||||
},
|
||||
update: vi.fn(() => ({
|
||||
set: (data: Partial<MatchRow>) => {
|
||||
const applyTo = (where: unknown) => {
|
||||
const values = whereValues(where);
|
||||
const target = rows.find((r) => values.includes(r.id));
|
||||
if (target) Object.assign(target, data);
|
||||
return target;
|
||||
};
|
||||
// Advancement writes through the query builder with and without .returning().
|
||||
return {
|
||||
where: (where: unknown) => {
|
||||
const applied = Promise.resolve([applyTo(where)]);
|
||||
return Object.assign(applied, { returning: () => applied });
|
||||
},
|
||||
};
|
||||
},
|
||||
})),
|
||||
// No rollback: the tests assert the writes that were attempted, in order.
|
||||
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: () => db }));
|
||||
|
||||
const { advanceWinnerTemplate, reseedAflEliminationFinals } = await import("../playoff-match");
|
||||
|
||||
const EVENT = "event-1";
|
||||
|
||||
/** Ladder seed n → participant id. */
|
||||
const seed = (n: number) => `seed-${n}`;
|
||||
|
||||
/** A freshly generated afl_10 Wildcard Round (7v10, 8v9) and Elimination Finals (5, 6). */
|
||||
function bracket(): MatchRow[] {
|
||||
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
|
||||
return [
|
||||
{ ...base, id: "wc1", round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
|
||||
{ ...base, id: "wc2", round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
|
||||
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
|
||||
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
|
||||
];
|
||||
}
|
||||
|
||||
function row(id: string): MatchRow {
|
||||
const found = rows.find((r) => r.id === id);
|
||||
if (!found) throw new Error(`No such match ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Record a Wildcard result the way setMatchWinner does, then advance it. */
|
||||
async function winWildcard(id: string, winnerId: string) {
|
||||
const match = row(id);
|
||||
match.winnerId = winnerId;
|
||||
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||||
match.isComplete = true;
|
||||
await advanceWinnerTemplate(id, winnerId, AFL_10);
|
||||
}
|
||||
|
||||
describe("AFL Wildcard Round advancement", () => {
|
||||
beforeEach(() => {
|
||||
rows = bracket();
|
||||
});
|
||||
|
||||
it("sends 5th the lower-ranked winner and 6th the higher-ranked one", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(8));
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
});
|
||||
|
||||
it("re-seeds when the lower seed wins through", async () => {
|
||||
// The reported bug: 10th beating 7th used to be crossed straight to 6th, leaving
|
||||
// 5th with the better survivor.
|
||||
await winWildcard("wc1", seed(10));
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("re-seeds a 9th-placed winner above a 10th-placed one", async () => {
|
||||
await winWildcard("wc1", seed(10));
|
||||
await winWildcard("wc2", seed(9));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(9));
|
||||
});
|
||||
|
||||
it("places the same pairings whichever result is entered first", async () => {
|
||||
await winWildcard("wc2", seed(8));
|
||||
await winWildcard("wc1", seed(10));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("places the 7v10 winner immediately, since its slot is settled either way", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
expect(row("ef1").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("holds an 8v9 winner back until the 7v10 game is decided", async () => {
|
||||
// 8th and 9th sit between 7th and 10th, so placing one now could need undoing.
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBeNull();
|
||||
expect(row("ef2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("does not disturb a winner it already placed", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(9));
|
||||
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
expect(row("ef1").participant2Id).toBe(seed(9));
|
||||
});
|
||||
|
||||
it("refuses to overwrite a slot already holding someone else", async () => {
|
||||
row("ef1").participant2Id = "stranger";
|
||||
|
||||
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already filled/);
|
||||
expect(row("ef1").participant2Id).toBe("stranger");
|
||||
});
|
||||
|
||||
it("moves the winner when a recorded Wildcard result is corrected", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
|
||||
// The result was wrong: 10th won. 7th must not be left alive in the other slot.
|
||||
await winWildcard("wc1", seed(10));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("re-seeds a pairing left behind by the old fixed crossover", async () => {
|
||||
// Pre-fix state: the 7v10 winner was crossed to 6th whatever its ladder position.
|
||||
row("wc1").winnerId = seed(10);
|
||||
row("wc1").loserId = seed(7);
|
||||
row("wc1").isComplete = true;
|
||||
row("ef2").participant2Id = seed(10);
|
||||
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("swaps both winners when re-resolving an already-placed pair", async () => {
|
||||
row("wc1").winnerId = seed(10);
|
||||
row("wc1").loserId = seed(7);
|
||||
row("wc1").isComplete = true;
|
||||
row("ef2").participant2Id = seed(10);
|
||||
row("ef1").participant2Id = seed(8);
|
||||
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("refuses to re-seed an Elimination Final that has already been played", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
Object.assign(row("ef2"), { isComplete: true, winnerId: seed(6), loserId: seed(7) });
|
||||
|
||||
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already has a recorded result/);
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
});
|
||||
|
||||
it("repairs an already-advanced bracket from the recorded results alone", async () => {
|
||||
// What scripts/fix-afl-wildcard-reseed.ts does: no new result, just the rows a
|
||||
// bracket advanced under the old fixed crossover left behind.
|
||||
Object.assign(row("wc1"), { isComplete: true, winnerId: seed(10), loserId: seed(7) });
|
||||
Object.assign(row("wc2"), { isComplete: true, winnerId: seed(8), loserId: seed(9) });
|
||||
row("ef2").participant2Id = seed(10);
|
||||
row("ef1").participant2Id = seed(8);
|
||||
|
||||
const reseed = await reseedAflEliminationFinals(EVENT);
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
|
||||
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
|
||||
{ matchNumber: 1, participantId: seed(10) },
|
||||
{ matchNumber: 2, participantId: seed(8) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports no change when a repair run finds the pairings correct", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(8));
|
||||
db.transaction.mockClear();
|
||||
|
||||
const reseed = await reseedAflEliminationFinals(EVENT);
|
||||
|
||||
expect(reseed).toEqual({ vacated: [], filled: [] });
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an event with no AFL bracket rather than reporting nothing to do", async () => {
|
||||
await expect(reseedAflEliminationFinals("no-such-event")).rejects.toThrow(/no AFL Wildcard/);
|
||||
});
|
||||
|
||||
it("leaves the bracket alone when the pairings are already right", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(8));
|
||||
db.transaction.mockClear();
|
||||
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
expect(row("ef1").participant2Id).toBe(seed(8));
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
/**
|
||||
* Entry-floor scoring: points a bracket guarantees at seeding time.
|
||||
*
|
||||
* Some seedings lock in a scoring tier before a single match is played. The AFL
|
||||
* finals are the clearest case: a top-4 seed has the double chance, so losing the
|
||||
* Qualifying Final still leaves them a Semi-Final, and losing that is the 5th-6th
|
||||
* tier. Those teams must not sit on 0 fantasy points until their first game.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
interface MatchRow {
|
||||
round: string;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal db mock. applyBracketEntryFloors calls, in order:
|
||||
* 1. db.query.scoringEvents.findFirst → the event (for template + sportsSeasonId)
|
||||
* 2. db.query.playoffMatches.findMany → the bracket's match slots
|
||||
* 3. upsertParticipantResult per floored participant → findFirst + insert/update
|
||||
*/
|
||||
function makeDb(
|
||||
event: { bracketTemplateId: string | null; sportsSeasonId: string } | null,
|
||||
matches: MatchRow[],
|
||||
existingByParticipant: Record<string, { id: string; finalPosition: number; isPartialScore: boolean }> = {}
|
||||
) {
|
||||
const existingRows = Object.entries(existingByParticipant).map(([participantId, row]) => ({
|
||||
participantId,
|
||||
finalPosition: row.finalPosition,
|
||||
}));
|
||||
const insertedRows: Array<Record<string, unknown>> = [];
|
||||
const updatedRows: Array<Record<string, unknown>> = [];
|
||||
|
||||
return {
|
||||
db: {
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
||||
insertedRows.push(values);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
||||
updatedRows.push(values);
|
||||
return { where: vi.fn().mockResolvedValue(undefined) };
|
||||
}),
|
||||
}),
|
||||
query: {
|
||||
scoringEvents: { findFirst: vi.fn().mockResolvedValue(event) },
|
||||
playoffMatches: { findMany: vi.fn().mockResolvedValue(matches) },
|
||||
seasonParticipantResults: {
|
||||
// The pre-pass that stops a floor from downgrading an existing placement.
|
||||
findMany: vi.fn().mockResolvedValue(existingRows),
|
||||
findFirst: vi.fn().mockImplementation((args: { where?: unknown }) => {
|
||||
// Resolve by scanning the seeded map — the mock has no real query engine,
|
||||
// so tests that need an existing row use a single-participant bracket.
|
||||
void args;
|
||||
const only = Object.values(existingByParticipant)[0];
|
||||
return Promise.resolve(only);
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
insertedRows,
|
||||
updatedRows,
|
||||
};
|
||||
}
|
||||
|
||||
import { applyBracketEntryFloors, getBracketEntryFloor } from "../scoring-calculator";
|
||||
|
||||
/** The AFL bracket exactly as generateAFL10Bracket writes it: later rounds are TBD. */
|
||||
const AFL_BRACKET: MatchRow[] = [
|
||||
{ round: "Wildcard Round", participant1Id: "seed7", participant2Id: "seed10" },
|
||||
{ round: "Wildcard Round", participant1Id: "seed8", participant2Id: "seed9" },
|
||||
{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: "seed4" },
|
||||
{ round: "Qualifying Finals", participant1Id: "seed2", participant2Id: "seed3" },
|
||||
{ round: "Elimination Finals", participant1Id: "seed5", participant2Id: null },
|
||||
{ round: "Elimination Finals", participant1Id: "seed6", participant2Id: null },
|
||||
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
|
||||
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
|
||||
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
|
||||
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
|
||||
{ round: "Grand Final", participant1Id: null, participant2Id: null },
|
||||
];
|
||||
|
||||
describe("applyBracketEntryFloors", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("afl_10", () => {
|
||||
it("banks 5 for the top 4 and 7 for seeds 5-6, and nothing for the wildcard teams", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
AFL_BRACKET
|
||||
);
|
||||
|
||||
const applied = await applyBracketEntryFloors("event-1", db);
|
||||
|
||||
expect(applied).toBe(6);
|
||||
const floors = Object.fromEntries(
|
||||
insertedRows.map((r) => [r.participantId as string, r.finalPosition as number])
|
||||
);
|
||||
expect(floors).toEqual({
|
||||
seed1: 5, seed2: 5, seed3: 5, seed4: 5, // double chance → 5th-6th tier
|
||||
seed5: 7, seed6: 7, // seeded into the Elimination Finals
|
||||
});
|
||||
// Seeds 7-10 lose the Wildcard Round for 0, so nothing is guaranteed yet.
|
||||
expect(floors).not.toHaveProperty("seed7");
|
||||
expect(floors).not.toHaveProperty("seed10");
|
||||
});
|
||||
|
||||
it("writes every floor as provisional so real results supersede it", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
AFL_BRACKET
|
||||
);
|
||||
|
||||
await applyBracketEntryFloors("event-1", db);
|
||||
|
||||
expect(insertedRows.every((r) => r.isPartialScore === true)).toBe(true);
|
||||
expect(insertedRows.every((r) => r.sportsSeasonId === "ss-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves TBD slots alone — a Semi-Final nobody has reached grants nothing", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Semi-Finals", participant1Id: null, participant2Id: null }]
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never downgrades a better placement — a finalist regenerating stays a finalist", async () => {
|
||||
// clear-bracket → generate-bracket mid-tournament must not knock a team sitting
|
||||
// on a 2nd-place floor back down to their 5th-6th seeding floor.
|
||||
const { db, insertedRows, updatedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
||||
{ seed1: { id: "row-1", finalPosition: 2, isPartialScore: true } }
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
expect(updatedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("treats position 0 as eliminated, not as a better placement", async () => {
|
||||
// A 0 means "missed the bracket". Re-seeding a team into the bracket must still
|
||||
// give them their floor rather than reading 0 as an unbeatable placement.
|
||||
const { db, updatedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
||||
{ seed1: { id: "row-1", finalPosition: 0, isPartialScore: true } }
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(1);
|
||||
expect(updatedRows).toHaveLength(1);
|
||||
expect(updatedRows[0]).toMatchObject({ finalPosition: 5, isPartialScore: true });
|
||||
});
|
||||
|
||||
it("does not un-finalize a participant who already has a real result", async () => {
|
||||
// upsertParticipantResult's never-un-finalize guard: a finalized row must not be
|
||||
// dragged back to a provisional floor when the bracket is regenerated.
|
||||
const { db, insertedRows, updatedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
||||
{ seed1: { id: "row-1", finalPosition: 1, isPartialScore: false } }
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
expect(updatedRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("other brackets", () => {
|
||||
it("is a no-op for an event with no bracket template", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: null, sportsSeasonId: "ss-1" },
|
||||
AFL_BRACKET
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is a no-op when the event does not exist", async () => {
|
||||
const { db } = makeDb(null, AFL_BRACKET);
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
});
|
||||
|
||||
it("grants nothing to an NBA bracket: every seeded round is non-scoring", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "nba_20", sportsSeasonId: "ss-1" },
|
||||
[
|
||||
{ round: "Play-In Round 1", participant1Id: "e7", participant2Id: "e8" },
|
||||
{ round: "First Round", participant1Id: "e1", participant2Id: null },
|
||||
]
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("grants the T5-8 tier to a simple_8 field: every entrant is already in a scoring round", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "simple_8", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Quarterfinals", participant1Id: "a", participant2Id: "b" }]
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(2);
|
||||
expect(insertedRows.map((r) => r.finalPosition)).toEqual([5, 5]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBracketEntryFloor", () => {
|
||||
it("prefers a round's explicit entryFloor over its loser position", () => {
|
||||
// Qualifying Finals is non-scoring, so only the explicit entryFloor makes it pay.
|
||||
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
|
||||
});
|
||||
|
||||
it("falls back to a scoring round's own loser position", () => {
|
||||
expect(getBracketEntryFloor("Quarterfinals", "simple_8")).toBe(5);
|
||||
expect(getBracketEntryFloor("Semifinals", "simple_8")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns null for non-scoring rounds with no explicit floor", () => {
|
||||
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
|
||||
expect(getBracketEntryFloor("First Round", "nba_20")).toBeNull();
|
||||
expect(getBracketEntryFloor("Round of 64", "ncaa_68")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown rounds and templates", () => {
|
||||
expect(getBracketEntryFloor("Not A Round", "afl_10")).toBeNull();
|
||||
expect(getBracketEntryFloor("Quarterfinals", "not_a_template")).toBeNull();
|
||||
expect(getBracketEntryFloor("Quarterfinals", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
getBracketTemplateIdForSportsSeason,
|
||||
getBracketTemplateIdsForSportsSeasons,
|
||||
} from "../bracket-template";
|
||||
|
||||
/**
|
||||
* A sports season can own several scoring events — a bracket plus schedule events, or a
|
||||
* re-created bracket alongside a stale one. Resolving the template from an arbitrary row
|
||||
* is not harmless: calculateBracketPoints falls back to the flat 5th–8th average when the
|
||||
* template id is null, so losing "llws_20" makes a team locked into 5th–6th and one
|
||||
* locked into 7th–8th both score 20.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal db stub. Applies the same filter and ordering the real query does, so the
|
||||
* assertions exercise the helper's row-picking rather than re-stating the query.
|
||||
*/
|
||||
function makeDb(
|
||||
rows: Array<{ sportsSeasonId: string; bracketTemplateId: string | null; createdAt: Date }>
|
||||
) {
|
||||
const findMany = vi.fn(async () =>
|
||||
rows
|
||||
.filter((row) => row.bracketTemplateId !== null)
|
||||
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
);
|
||||
return { db: { query: { scoringEvents: { findMany } } } as any, findMany };
|
||||
}
|
||||
|
||||
describe("getBracketTemplateIdForSportsSeason", () => {
|
||||
it("ignores a non-bracket event and returns the bracket event's template", async () => {
|
||||
const { db } = makeDb([
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-07-01") },
|
||||
]);
|
||||
|
||||
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
|
||||
});
|
||||
|
||||
it("takes the most recent bracket event when a stale one is still around", async () => {
|
||||
const { db } = makeDb([
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: "simple_16", createdAt: new Date("2026-06-01") },
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
|
||||
]);
|
||||
|
||||
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
|
||||
});
|
||||
|
||||
it("returns null when the season has no bracket event", async () => {
|
||||
const { db } = makeDb([
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
|
||||
]);
|
||||
|
||||
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBracketTemplateIdsForSportsSeasons", () => {
|
||||
it("resolves each season independently in one query", async () => {
|
||||
const { db, findMany } = makeDb([
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
|
||||
{ sportsSeasonId: "ss2", bracketTemplateId: "afl_10", createdAt: new Date("2026-08-02") },
|
||||
{ sportsSeasonId: "ss3", bracketTemplateId: null, createdAt: new Date("2026-08-03") },
|
||||
]);
|
||||
|
||||
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2", "ss3"], db);
|
||||
|
||||
expect(resolved.get("ss1")).toBe("llws_20");
|
||||
expect(resolved.get("ss2")).toBe("afl_10");
|
||||
expect(resolved.get("ss3")).toBeNull();
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("gives every requested season an entry so callers can cache the miss", async () => {
|
||||
const { db } = makeDb([]);
|
||||
|
||||
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2"], db);
|
||||
|
||||
expect([...resolved.entries()]).toEqual([
|
||||
["ss1", null],
|
||||
["ss2", null],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not query at all for an empty season list", async () => {
|
||||
const { db, findMany } = makeDb([]);
|
||||
|
||||
await expect(getBracketTemplateIdsForSportsSeasons([], db)).resolves.toEqual(new Map());
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,481 +0,0 @@
|
|||
/**
|
||||
* LLWS 20-Team Double-Elimination Bracket Tests
|
||||
*
|
||||
* Verifies the llws_20 template against the official 2026 LLBWS bracket
|
||||
* (Williamsport, Aug 19–30). The PDF numbers its games 1–38; those numbers appear
|
||||
* throughout as `G<n>` so the routing can be checked against the printed bracket.
|
||||
*
|
||||
* The critical property under test is the double-elimination loser routing: a loss in
|
||||
* the winners bracket drops a team into the elimination bracket at a specific slot,
|
||||
* while a loss in the elimination bracket is final.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
LLWS_20,
|
||||
getScoringRoundType,
|
||||
llwsMatchNumber,
|
||||
llwsSideAndLocal,
|
||||
} from "~/lib/bracket-templates";
|
||||
import {
|
||||
doesLoserAdvance,
|
||||
generateBracketFromTemplate,
|
||||
resolveLLWSAdvancement,
|
||||
} from "../playoff-match";
|
||||
import {
|
||||
calculateBracketPoints,
|
||||
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.
|
||||
const insertedRows: Record<string, unknown>[] = [];
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: () => ({
|
||||
insert: () => ({
|
||||
values: (rows: Record<string, unknown>[]) => ({
|
||||
returning: async () => {
|
||||
insertedRows.push(...rows);
|
||||
return rows;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const DEFAULT_SCORING: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 50,
|
||||
pointsFor4th: 40,
|
||||
pointsFor5th: 25,
|
||||
pointsFor6th: 20,
|
||||
pointsFor7th: 15,
|
||||
pointsFor8th: 10,
|
||||
};
|
||||
|
||||
describe("LLWS 20 Bracket Template", () => {
|
||||
describe("Template structure", () => {
|
||||
it("has correct identity and size", () => {
|
||||
expect(LLWS_20.id).toBe("llws_20");
|
||||
expect(LLWS_20.totalTeams).toBe(20);
|
||||
expect(LLWS_20.scoringStartsAtRound).toBe("Winners Final");
|
||||
});
|
||||
|
||||
it("has 12 rounds totalling 38 matches", () => {
|
||||
expect(LLWS_20.rounds).toHaveLength(12);
|
||||
const total = LLWS_20.rounds.reduce((sum, r) => sum + r.matchCount, 0);
|
||||
expect(total).toBe(38);
|
||||
});
|
||||
|
||||
it("has the expected match count per round", () => {
|
||||
const counts = Object.fromEntries(
|
||||
LLWS_20.rounds.map((r) => [r.name, r.matchCount])
|
||||
);
|
||||
expect(counts).toEqual({
|
||||
"Opening Round": 8,
|
||||
"Winners Round 2": 4,
|
||||
"Elimination Round 1": 4,
|
||||
"Winners Semifinals": 4,
|
||||
"Elimination Round 2": 4,
|
||||
"Elimination Round 3": 4,
|
||||
"Winners Final": 2,
|
||||
"Elimination Round 4": 2,
|
||||
"Elimination Final": 2,
|
||||
"Bracket Championship": 2,
|
||||
"Consolation Third Place": 1,
|
||||
"World Championship": 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks exactly the point-awarding rounds as scoring", () => {
|
||||
const scoring = LLWS_20.rounds.filter((r) => r.isScoring).map((r) => r.name);
|
||||
expect(scoring).toEqual([
|
||||
"Winners Final",
|
||||
"Elimination Round 4",
|
||||
"Elimination Final",
|
||||
"Bracket Championship",
|
||||
"Consolation Third Place",
|
||||
"World Championship",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists rounds in chronological order", () => {
|
||||
// Elimination Round 1 (Aug 22) is played before Winners Semifinals (Aug 23).
|
||||
const names = LLWS_20.rounds.map((r) => r.name);
|
||||
expect(names.indexOf("Elimination Round 1")).toBeLessThan(
|
||||
names.indexOf("Winners Semifinals")
|
||||
);
|
||||
expect(names.indexOf("Winners Final")).toBeLessThan(
|
||||
names.indexOf("Elimination Final")
|
||||
);
|
||||
});
|
||||
|
||||
it("gives elimination-bracket winners a floor matching their real worst case", () => {
|
||||
const byName = (n: string) => LLWS_20.rounds.find((r) => r.name === n);
|
||||
// Winning Elim R3 only guarantees 7th (a loss in Elim R4 is the 7–8 tier),
|
||||
// so the engine's default floor of 5 would overstate it.
|
||||
expect(byName("Elimination Round 3")?.nonScoringWinnerFloor).toBe(7);
|
||||
// Reaching the Winners Final guarantees 5th at worst.
|
||||
expect(byName("Winners Semifinals")?.nonScoringWinnerFloor).toBe(5);
|
||||
// Nothing is guaranteed earlier than that.
|
||||
expect(byName("Opening Round")?.nonScoringWinnerFloor).toBeNull();
|
||||
expect(byName("Winners Round 2")?.nonScoringWinnerFloor).toBeNull();
|
||||
expect(byName("Elimination Round 1")?.nonScoringWinnerFloor).toBeNull();
|
||||
expect(byName("Elimination Round 2")?.nonScoringWinnerFloor).toBeNull();
|
||||
});
|
||||
|
||||
it("has 20 participant labels", () => {
|
||||
expect(LLWS_20.participantLabels).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("splits display into U.S., International and Championship phases", () => {
|
||||
expect(LLWS_20.phases?.map((p) => p.name)).toEqual([
|
||||
"United States",
|
||||
"International",
|
||||
"Championship",
|
||||
]);
|
||||
});
|
||||
|
||||
it("assigns every match to exactly one phase group", () => {
|
||||
const claimed = new Map<string, number>();
|
||||
for (const phase of LLWS_20.phases ?? []) {
|
||||
for (const group of phase.groups ?? []) {
|
||||
for (const [round, numbers] of Object.entries(group.roundMatchNumbers)) {
|
||||
for (const n of numbers) {
|
||||
const key = `${round}#${n}`;
|
||||
claimed.set(key, (claimed.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every per-side match claimed exactly once (36 games; the 2 finals live in
|
||||
// the Championship phase's plain round list, not in a group).
|
||||
expect(claimed.size).toBe(36);
|
||||
expect([...claimed.values()].every((c) => c === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bracket generation", () => {
|
||||
const PARTICIPANTS = Array.from({ length: 20 }, (_, i) => `team-${i}`);
|
||||
|
||||
async function generate() {
|
||||
insertedRows.length = 0;
|
||||
await generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS);
|
||||
return insertedRows.map((r) => ({
|
||||
round: r.round as string,
|
||||
matchNumber: r.matchNumber as number,
|
||||
participant1Id: (r.participant1Id ?? null) as string | null,
|
||||
participant2Id: (r.participant2Id ?? null) as string | null,
|
||||
isScoring: r.isScoring as boolean,
|
||||
}));
|
||||
}
|
||||
|
||||
it("creates all 38 matches", async () => {
|
||||
const rows = await generate();
|
||||
expect(rows).toHaveLength(38);
|
||||
});
|
||||
|
||||
it("creates the right number of matches per round", async () => {
|
||||
const rows = await generate();
|
||||
for (const round of LLWS_20.rounds) {
|
||||
expect(
|
||||
rows.filter((r) => r.round === round.name),
|
||||
`${round.name} match count`
|
||||
).toHaveLength(round.matchCount);
|
||||
}
|
||||
});
|
||||
|
||||
it("numbers matches 1..n within each round", async () => {
|
||||
const rows = await generate();
|
||||
for (const round of LLWS_20.rounds) {
|
||||
const numbers = rows
|
||||
.filter((r) => r.round === round.name)
|
||||
.map((r) => r.matchNumber)
|
||||
.toSorted((a, b) => a - b);
|
||||
expect(numbers).toEqual(
|
||||
Array.from({ length: round.matchCount }, (_, i) => i + 1)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds the Opening Round two teams at a time, U.S. then International", async () => {
|
||||
const rows = await generate();
|
||||
const opening = rows
|
||||
.filter((r) => r.round === "Opening Round")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
// U.S. slots 0–7 fill matches 1–4; International slots 10–17 fill matches 5–8.
|
||||
expect(opening.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
|
||||
["team-0", "team-1"],
|
||||
["team-2", "team-3"],
|
||||
["team-4", "team-5"],
|
||||
["team-6", "team-7"],
|
||||
["team-10", "team-11"],
|
||||
["team-12", "team-13"],
|
||||
["team-14", "team-15"],
|
||||
["team-16", "team-17"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("seats the four bye teams in Winners Round 2 awaiting an opponent", async () => {
|
||||
const rows = await generate();
|
||||
const wr2 = rows
|
||||
.filter((r) => r.round === "Winners Round 2")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
expect(wr2.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
|
||||
["team-8", null],
|
||||
["team-9", null],
|
||||
["team-18", null],
|
||||
["team-19", null],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses each participant exactly once and leaves every other slot empty", async () => {
|
||||
const rows = await generate();
|
||||
const seeded = rows
|
||||
.flatMap((r) => [r.participant1Id, r.participant2Id])
|
||||
.filter((id): id is string => id !== null);
|
||||
expect(seeded).toHaveLength(20);
|
||||
expect(new Set(seeded).size).toBe(20);
|
||||
expect(new Set(seeded)).toEqual(new Set(PARTICIPANTS));
|
||||
});
|
||||
|
||||
it("stamps isScoring from the template", async () => {
|
||||
const rows = await generate();
|
||||
for (const round of LLWS_20.rounds) {
|
||||
for (const row of rows.filter((r) => r.round === round.name)) {
|
||||
expect(row.isScoring, `${round.name} #${row.matchNumber}`).toBe(round.isScoring);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a participant count other than 20", async () => {
|
||||
await expect(
|
||||
generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS.slice(0, 19))
|
||||
).rejects.toThrow(/requires 20 participants/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Side / match-number mapping", () => {
|
||||
it("round-trips every match number through side-local form", () => {
|
||||
for (const round of LLWS_20.rounds) {
|
||||
if (round.matchCount === 1) continue; // shared finals have no side
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { side, localMatch } = llwsSideAndLocal(round.name, n);
|
||||
expect(llwsMatchNumber(round.name, side, localMatch)).toBe(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("puts U.S. matches in the low half and International in the high half", () => {
|
||||
expect(llwsSideAndLocal("Opening Round", 4).side).toBe(0);
|
||||
expect(llwsSideAndLocal("Opening Round", 5).side).toBe(1);
|
||||
expect(llwsSideAndLocal("Winners Semifinals", 2).side).toBe(0);
|
||||
expect(llwsSideAndLocal("Winners Semifinals", 3).side).toBe(1);
|
||||
expect(llwsSideAndLocal("Winners Final", 1).side).toBe(0);
|
||||
expect(llwsSideAndLocal("Winners Final", 2).side).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Advancement matches the official bracket", () => {
|
||||
/**
|
||||
* Replay the whole tournament through resolveLLWSAdvancement and record which
|
||||
* feed label ends up in each slot, then compare against the printed bracket.
|
||||
*/
|
||||
const actualSlots: Record<number, [string | null, string | null]> = {};
|
||||
for (const game of Object.keys(EXPECTED_SLOTS)) {
|
||||
actualSlots[Number(game)] = [null, null];
|
||||
}
|
||||
|
||||
for (const [gameStr, { round, matchNumber }] of Object.entries(GAME_TO_MATCH)) {
|
||||
const game = Number(gameStr);
|
||||
const { winner, loser } = resolveLLWSAdvancement(round, matchNumber);
|
||||
for (const [dest, label] of [
|
||||
[winner, `W${game}`],
|
||||
[loser, `L${game}`],
|
||||
] as const) {
|
||||
if (!dest) continue;
|
||||
const targetGame = gameNumberFor(dest.round, dest.matchNumber);
|
||||
const slotIndex = dest.slot === "participant1Id" ? 0 : 1;
|
||||
actualSlots[targetGame][slotIndex] = label;
|
||||
}
|
||||
}
|
||||
|
||||
it.each(Object.keys(EXPECTED_SLOTS).map(Number).toSorted((a, b) => a - b))(
|
||||
"Game %i has the printed participants",
|
||||
(game) => {
|
||||
expect(actualSlots[game]).toEqual(EXPECTED_SLOTS[game]);
|
||||
}
|
||||
);
|
||||
|
||||
it("fills every slot in the bracket exactly once", () => {
|
||||
// 38 games × 2 slots = 76. 20 are seeded directly (16 opening teams + 4 byes),
|
||||
// leaving 56 to be filled by advancement.
|
||||
const filled = Object.values(actualSlots)
|
||||
.flat()
|
||||
.filter((s) => s !== null).length;
|
||||
expect(filled).toBe(56);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Double-elimination loser routing", () => {
|
||||
it("routes every winners-bracket loser into the elimination bracket", () => {
|
||||
const winnersRounds = [
|
||||
"Opening Round",
|
||||
"Winners Round 2",
|
||||
"Winners Semifinals",
|
||||
"Winners Final",
|
||||
];
|
||||
for (const roundName of winnersRounds) {
|
||||
const round = LLWS_20.rounds.find((r) => r.name === roundName);
|
||||
if (!round) throw new Error(`missing round ${roundName}`);
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { loser } = resolveLLWSAdvancement(roundName, n);
|
||||
expect(loser, `${roundName} #${n} loser should advance`).not.toBeNull();
|
||||
expect(loser?.round.startsWith("Elimination")).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("eliminates every elimination-bracket loser", () => {
|
||||
const elimRounds = [
|
||||
"Elimination Round 1",
|
||||
"Elimination Round 2",
|
||||
"Elimination Round 3",
|
||||
"Elimination Round 4",
|
||||
"Elimination Final",
|
||||
];
|
||||
for (const roundName of elimRounds) {
|
||||
const round = LLWS_20.rounds.find((r) => r.name === roundName);
|
||||
if (!round) throw new Error(`missing round ${roundName}`);
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { loser } = resolveLLWSAdvancement(roundName, n);
|
||||
expect(loser, `${roundName} #${n} loser should be out`).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the winners-bracket final loser alive via the Elimination Final", () => {
|
||||
// G30 (U.S. Winners Final) loser → G34, not out. This is the defining
|
||||
// double-elimination behavior: a first loss never eliminates.
|
||||
const { winner, loser } = resolveLLWSAdvancement("Winners Final", 1);
|
||||
expect(destinationGame(loser)).toBe(34);
|
||||
expect(destinationGame(winner)).toBe(36);
|
||||
});
|
||||
|
||||
it("sends the side-championship loser to the consolation game, not out", () => {
|
||||
// No "if necessary" rematch: the winners-bracket champion that loses G36 is
|
||||
// done in the bracket, but still plays G37 for 3rd/4th.
|
||||
const us = resolveLLWSAdvancement("Bracket Championship", 1);
|
||||
expect(destinationGame(us.winner)).toBe(38);
|
||||
expect(destinationGame(us.loser)).toBe(37);
|
||||
expect(required(us.winner).slot).toBe("participant1Id");
|
||||
expect(required(us.loser).slot).toBe("participant1Id");
|
||||
|
||||
const intl = resolveLLWSAdvancement("Bracket Championship", 2);
|
||||
expect(required(intl.winner).slot).toBe("participant2Id");
|
||||
expect(required(intl.loser).slot).toBe("participant2Id");
|
||||
});
|
||||
|
||||
it("flags winners-bracket losers as advancing so they are not marked eliminated", () => {
|
||||
// doesLoserAdvance is what stops the scoring engine writing a 0-point
|
||||
// elimination (and announcing a knockout) for a team that is still alive.
|
||||
// Winners Final and Bracket Championship are scoring rounds and are covered
|
||||
// by loserIsPartial instead, so they are deliberately not listed here.
|
||||
for (const round of ["Opening Round", "Winners Round 2", "Winners Semifinals"]) {
|
||||
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(true);
|
||||
}
|
||||
for (const round of [
|
||||
"Elimination Round 1",
|
||||
"Elimination Round 2",
|
||||
"Elimination Round 3",
|
||||
"Elimination Round 4",
|
||||
"Elimination Final",
|
||||
]) {
|
||||
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not apply LLWS loser routing to other templates", () => {
|
||||
expect(doesLoserAdvance("Opening Round", 1, "ncaa_68")).toBe(false);
|
||||
expect(doesLoserAdvance("Winners Semifinals", 1, "")).toBe(false);
|
||||
});
|
||||
|
||||
it("advances nobody out of the two final games", () => {
|
||||
for (const round of ["Consolation Third Place", "World Championship"]) {
|
||||
expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("never crosses a team between the U.S. and International sides", () => {
|
||||
for (const round of LLWS_20.rounds) {
|
||||
if (round.name === "Bracket Championship") continue; // the crossover point
|
||||
if (round.matchCount === 1) continue;
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { side } = llwsSideAndLocal(round.name, n);
|
||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
||||
for (const dest of [winner, loser]) {
|
||||
if (!dest) continue;
|
||||
const destRound = LLWS_20.rounds.find((r) => r.name === dest.round);
|
||||
if (!destRound || destRound.matchCount === 1) continue;
|
||||
expect(llwsSideAndLocal(dest.round, dest.matchNumber).side).toBe(side);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Placement tiers", () => {
|
||||
it("classifies scoring rounds correctly", () => {
|
||||
expect(getScoringRoundType("Elimination Round 4", LLWS_20)).toBe("quarterfinals");
|
||||
expect(getScoringRoundType("Elimination Final", LLWS_20)).toBe("quarterfinals");
|
||||
expect(getScoringRoundType("Bracket Championship", LLWS_20)).toBe("semifinals");
|
||||
expect(getScoringRoundType("World Championship", LLWS_20)).toBe("finals");
|
||||
// Nobody is eliminated in the Winners Final — the loser drops to the
|
||||
// elimination bracket — so it has no placement tier.
|
||||
expect(getScoringRoundType("Winners Final", LLWS_20)).toBeNull();
|
||||
});
|
||||
|
||||
it("pays 3rd and 4th distinctly (there is a real consolation game)", () => {
|
||||
expect(calculateBracketPoints(3, DEFAULT_SCORING, "llws_20")).toBe(50);
|
||||
expect(calculateBracketPoints(4, DEFAULT_SCORING, "llws_20")).toBe(40);
|
||||
});
|
||||
|
||||
it("splits 5–8 into two two-team tiers", () => {
|
||||
const upper = calculateAveragedPoints([5, 6], DEFAULT_SCORING); // (25+20)/2
|
||||
const lower = calculateAveragedPoints([7, 8], DEFAULT_SCORING); // (15+10)/2
|
||||
expect(calculateBracketPoints(5, DEFAULT_SCORING, "llws_20")).toBe(upper);
|
||||
expect(calculateBracketPoints(6, DEFAULT_SCORING, "llws_20")).toBe(upper);
|
||||
expect(calculateBracketPoints(7, DEFAULT_SCORING, "llws_20")).toBe(lower);
|
||||
expect(calculateBracketPoints(8, DEFAULT_SCORING, "llws_20")).toBe(lower);
|
||||
// Surviving Elimination Round 4 is worth more than losing it.
|
||||
expect(upper).toBeGreaterThan(lower);
|
||||
});
|
||||
|
||||
it("awards nothing below 8th", () => {
|
||||
// The 12 teams knocked out in Elimination Rounds 1–3 finish 9th–20th.
|
||||
expect(calculateBracketPoints(9, DEFAULT_SCORING, "llws_20")).toBe(0);
|
||||
expect(calculateBracketPoints(0, DEFAULT_SCORING, "llws_20")).toBe(0);
|
||||
});
|
||||
|
||||
it("has exactly 8 teams alive when the first scoring elimination game is played", () => {
|
||||
// Elimination Round 4 is the 7th–8th tier, so the field must be 8 at that point:
|
||||
// per side the Winners Final winner, the Winners Final loser, and the two
|
||||
// Elimination Round 3 winners.
|
||||
const eliminatedBeforeElimR4 =
|
||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 1")?.matchCount ?? 0) +
|
||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 2")?.matchCount ?? 0) +
|
||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 3")?.matchCount ?? 0);
|
||||
expect(eliminatedBeforeElimR4).toBe(12);
|
||||
expect(LLWS_20.totalTeams - eliminatedBeforeElimR4).toBe(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -190,26 +190,12 @@ describe("processMatchResult", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("AFL Wildcard Round: loser=0, winner gets T7 floor (a Wildcard win only buys an Elimination Final)", async () => {
|
||||
// The generic "entering a scoring round ⇒ top-8" default would bank 5 here,
|
||||
// over-awarding the 5th-6th tier to a team whose next loss is the 7th-8th tier.
|
||||
it("AFL Wildcard Round: loser=0, winner gets T5 floor (feeds into Elimination Finals = scoring)", async () => {
|
||||
const { db, insertedRows } = makeDb();
|
||||
await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db);
|
||||
expect(insertedRows).toHaveLength(2);
|
||||
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
||||
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 7, isPartialScore: true });
|
||||
});
|
||||
|
||||
it("AFL Qualifying Finals: winner gets T3 floor (bye to a Preliminary Final), loser holds their entry floor", async () => {
|
||||
const { db, insertedRows } = makeDb();
|
||||
await processMatchResult(
|
||||
{ ...BASE, bracketTemplateId: "afl_10", round: "Qualifying Finals", isScoring: false, loserAdvances: true },
|
||||
db
|
||||
);
|
||||
// Only the winner is written: the loser still has a Semi-Final, so their
|
||||
// seeding-derived floor of 5 stands untouched.
|
||||
expect(insertedRows).toHaveLength(1);
|
||||
expect(insertedRows[0]).toMatchObject({ participantId: "winner-1", finalPosition: 3, isPartialScore: true });
|
||||
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true });
|
||||
});
|
||||
|
||||
describe("NBA Play-In loserAdvances=true (7v8 game)", () => {
|
||||
|
|
@ -319,22 +305,6 @@ describe("processMatchResult", () => {
|
|||
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
|
||||
});
|
||||
|
||||
it("skips only the probability refresh when asked, still announcing", async () => {
|
||||
// For a caller scoring several matches in a loop: the refresh is season-wide and, for a
|
||||
// bracket-aware sport, a full Monte Carlo run, so it belongs once after the loop rather
|
||||
// than once per match. Standings and the announcement still happen per match.
|
||||
const { db } = makeDb();
|
||||
|
||||
await processMatchResult(
|
||||
{ ...BASE, round: "Quarterfinals", isScoring: true, skipProbabilities: true },
|
||||
db
|
||||
);
|
||||
|
||||
expect(updateProbabilitiesAfterResult).not.toHaveBeenCalled();
|
||||
// recalculateAffectedLeagues still ran: it is the only thing that reads seasonSports.
|
||||
expect(db.query.seasonSports.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throw even if probability update fails", async () => {
|
||||
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("network error")
|
||||
|
|
|
|||
|
|
@ -1,192 +0,0 @@
|
|||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -111,50 +111,4 @@ describe("simulator input model", () => {
|
|||
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
|
||||
expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull();
|
||||
});
|
||||
|
||||
it("hides an Elo flagged as projection-derived so the projection is re-derived", async () => {
|
||||
// This is what stops a stale Elo from winning the baseEloPriority race. A row
|
||||
// carrying projectedWins and a projectedWins method flag must surface with a
|
||||
// null sourceElo, so resolveSourceElos falls through to the projection rather
|
||||
// than reusing an Elo that was itself derived from an older projection.
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
|
||||
{ id: "projected" },
|
||||
{ id: "hand-entered" },
|
||||
]);
|
||||
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
|
||||
{
|
||||
participantId: "projected",
|
||||
sourceOdds: null,
|
||||
sourceElo: 1561,
|
||||
worldRanking: null,
|
||||
rating: null,
|
||||
projectedWins: "95.00",
|
||||
projectedTablePoints: null,
|
||||
seed: null,
|
||||
region: null,
|
||||
metadata: { sourceEloMethod: "projectedWins" },
|
||||
},
|
||||
{
|
||||
participantId: "hand-entered",
|
||||
sourceOdds: null,
|
||||
sourceElo: 1561,
|
||||
worldRanking: null,
|
||||
rating: null,
|
||||
projectedWins: "95.00",
|
||||
projectedTablePoints: null,
|
||||
seed: null,
|
||||
region: null,
|
||||
metadata: {},
|
||||
},
|
||||
]);
|
||||
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
|
||||
|
||||
const inputs = await getParticipantSimulatorInputs("season-1");
|
||||
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
|
||||
|
||||
expect(byParticipant.get("projected")?.sourceElo).toBeNull();
|
||||
expect(byParticipant.get("projected")?.projectedWins).toBe(95);
|
||||
// No flag means the admin entered that Elo themselves — it is trusted as direct.
|
||||
expect(byParticipant.get("hand-entered")?.sourceElo).toBe(1561);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,9 +80,6 @@ function makeDb(
|
|||
},
|
||||
scoringEvents: {
|
||||
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
|
||||
// getBracketTemplateIdsForSportsSeasons filters to events that carry a
|
||||
// template, so "no bracket template" is an empty result, not a null row.
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
seasonParticipantResults: {
|
||||
findMany: vi.fn().mockResolvedValue(seasonResults),
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { and, desc, inArray, isNotNull } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Resolve which bracket template a sports season's placements should be scored against.
|
||||
*
|
||||
* A sports season can own several scoring events — a bracket plus schedule events, or a
|
||||
* re-created bracket alongside a stale one — and only some of them carry a
|
||||
* bracketTemplateId. Picking an arbitrary row is not harmless: calculateBracketPoints
|
||||
* falls back to the standard single 5th–8th tier when the template id is null, which
|
||||
* silently collapses the two-tier templates (llws_20, afl_10) so a team locked into
|
||||
* 5th–6th and one locked into 7th–8th both score the flat 5–8 average. The 3rd/4th
|
||||
* distinction that llws_20 and fifa_48 have goes the same way.
|
||||
*
|
||||
* So: only events that actually carry a template are considered, most recent first —
|
||||
* matching the "a re-created event wins over a stale one" rule the LLWS simulator uses
|
||||
* when it picks its bracket event.
|
||||
*
|
||||
* Every requested season gets an entry, null when it has no bracket event, so callers
|
||||
* can cache the negative result too.
|
||||
*/
|
||||
export async function getBracketTemplateIdsForSportsSeasons(
|
||||
sportsSeasonIds: string[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<Map<string, string | null>> {
|
||||
const resolved = new Map<string, string | null>(
|
||||
sportsSeasonIds.map((id) => [id, null])
|
||||
);
|
||||
if (sportsSeasonIds.length === 0) return resolved;
|
||||
|
||||
const db = providedDb || database();
|
||||
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: and(
|
||||
inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds),
|
||||
isNotNull(schema.scoringEvents.bracketTemplateId)
|
||||
),
|
||||
columns: { sportsSeasonId: true, bracketTemplateId: true },
|
||||
// createdAt can tie when a bracket is generated in the same transaction as a
|
||||
// sibling event, so id breaks the tie and keeps the choice deterministic.
|
||||
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
|
||||
});
|
||||
|
||||
for (const event of events) {
|
||||
// Ordered newest-first, so the first row seen for a season is the one to keep.
|
||||
// The isNotNull filter means bracketTemplateId is set, but a mocked or partial row
|
||||
// could still carry null — skip those rather than caching a null as a real answer.
|
||||
if (resolved.get(event.sportsSeasonId) === null && event.bracketTemplateId) {
|
||||
resolved.set(event.sportsSeasonId, event.bracketTemplateId);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-season form of getBracketTemplateIdsForSportsSeasons.
|
||||
*/
|
||||
export async function getBracketTemplateIdForSportsSeason(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<string | null> {
|
||||
const resolved = await getBracketTemplateIdsForSportsSeasons([sportsSeasonId], providedDb);
|
||||
return resolved.get(sportsSeasonId) ?? null;
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ import {
|
|||
calculateBracketPoints,
|
||||
calculateSharedPlacementPoints,
|
||||
} from "./scoring-rules";
|
||||
import { getBracketTemplateIdsForSportsSeasons } from "./bracket-template";
|
||||
|
||||
export async function createDraftPick(data: {
|
||||
seasonId: string;
|
||||
|
|
@ -176,10 +175,18 @@ export async function getDraftedParticipantsWithPoints(
|
|||
}
|
||||
|
||||
// Batch-fetch bracket template IDs (one per sports season)
|
||||
const bracketTemplateMap =
|
||||
bracketSeasonIds.size > 0
|
||||
? await getBracketTemplateIdsForSportsSeasons([...bracketSeasonIds], db)
|
||||
: new Map<string, string | null>();
|
||||
const bracketTemplateMap = new Map<string, string | null>();
|
||||
if (bracketSeasonIds.size > 0) {
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]),
|
||||
columns: { sportsSeasonId: true, bracketTemplateId: true },
|
||||
});
|
||||
for (const ev of events) {
|
||||
if (!bracketTemplateMap.has(ev.sportsSeasonId)) {
|
||||
bracketTemplateMap.set(ev.sportsSeasonId, ev.bracketTemplateId ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-fetch QP totals for qualifying_points participants
|
||||
const qpMap = new Map<string, number>(); // participantId → totalQP
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
|
|
@ -104,33 +104,6 @@ export async function deleteParticipantResultsBySportsSeasonId(
|
|||
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the results of specific participants within one sports season.
|
||||
*
|
||||
* The season-wide delete above is too blunt for a single bracket: results are keyed by
|
||||
* sports season, not by event, so wiping the season takes every other event's placements
|
||||
* with it. Scoping to the participants a bracket actually holds lets reprocess-bracket
|
||||
* rebuild that bracket from scratch while leaving the rest of the season alone.
|
||||
*
|
||||
* No-ops on an empty id list — `inArray` with no values is not a valid SQL predicate.
|
||||
*/
|
||||
export async function deleteParticipantResultsForParticipants(
|
||||
sportsSeasonId: string,
|
||||
participantIds: string[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
if (participantIds.length === 0) return;
|
||||
const db = providedDb || database();
|
||||
await db
|
||||
.delete(schema.seasonParticipantResults)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
|
||||
inArray(schema.seasonParticipantResults.participantId, participantIds)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set result for a participant in a sports season
|
||||
* Points are calculated on-demand based on each fantasy league's scoring rules
|
||||
|
|
|
|||
|
|
@ -6,19 +6,8 @@ import {
|
|||
getBracketTemplate,
|
||||
buildNCAA68SlotMap,
|
||||
matchIndexForSeedSlot,
|
||||
llwsMatchNumber,
|
||||
llwsSideAndLocal,
|
||||
STANDARD_BRACKET_SEEDING,
|
||||
} from "~/lib/bracket-templates";
|
||||
import {
|
||||
LLWS_LOSER_ADVANCES_ROUNDS,
|
||||
resolveLLWSAdvancement,
|
||||
type LLWSResolvedDestination,
|
||||
} from "~/lib/llws-bracket";
|
||||
import {
|
||||
resolveAflWildcardPlacements,
|
||||
type AflWildcardResult,
|
||||
} from "~/lib/afl-wildcard-reseed";
|
||||
|
||||
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
||||
|
|
@ -478,11 +467,6 @@ export async function generateBracketFromTemplate(
|
|||
return await generateNBA20Bracket(eventId, template, participantIds);
|
||||
}
|
||||
|
||||
// LLWS 20 requires special handling for its two double-elimination brackets
|
||||
if (templateId === "llws_20") {
|
||||
return await generateLLWS20Bracket(eventId, template, participantIds);
|
||||
}
|
||||
|
||||
const matches: NewPlayoffMatch[] = [];
|
||||
|
||||
// Generate matches for each round in the template
|
||||
|
|
@ -746,11 +730,9 @@ async function generateNFL14Bracket(
|
|||
* Structure:
|
||||
* - Wildcard Round: 7v10, 8v9
|
||||
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
||||
* - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder
|
||||
* position — 5th draws the lower-ranked winner, 6th the higher-ranked one
|
||||
* - Semi-Finals: SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner
|
||||
* - Preliminary Finals: PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner
|
||||
* (the crossover keeps a QF loser away from the side that just beat it)
|
||||
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
|
||||
* - Semi-Finals: QF losers vs EF winners
|
||||
* - Preliminary Finals: QF winners vs SF winners
|
||||
* - Grand Final: PF winners
|
||||
*/
|
||||
async function generateAFL10Bracket(
|
||||
|
|
@ -802,16 +784,14 @@ async function generateAFL10Bracket(
|
|||
});
|
||||
}
|
||||
|
||||
// Elimination Finals: 5th and 6th host the two Wildcard winners. Which winner lands
|
||||
// where is decided by ladder position once both games are played (see
|
||||
// resolveAflWildcardPlacements), not by a fixed crossover from a Wildcard match.
|
||||
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
|
||||
const eliminationSeeding = [
|
||||
{ higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
|
||||
{ higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
|
||||
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
|
||||
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
|
||||
];
|
||||
|
||||
for (let i = 0; i < eliminationSeeding.length; i++) {
|
||||
const { higher, opponent } = eliminationSeeding[i];
|
||||
const { higher, wildcard } = eliminationSeeding[i];
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Elimination Finals",
|
||||
|
|
@ -821,11 +801,11 @@ async function generateAFL10Bracket(
|
|||
isComplete: false,
|
||||
isScoring: true, // Losers share 7th-8th
|
||||
templateRound: "Elimination Finals",
|
||||
seedInfo: participantIds ? `${higher + 1} vs ${opponent}` : null,
|
||||
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Semi-Finals: SF n = QF n loser vs EF n winner (TBD vs TBD)
|
||||
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
|
|
@ -871,257 +851,15 @@ async function generateAFL10Bracket(
|
|||
return await createManyPlayoffMatches(matches);
|
||||
}
|
||||
|
||||
/** What a re-seed changed, by Elimination Finals match number. */
|
||||
export interface AflEliminationReseed {
|
||||
vacated: number[];
|
||||
filled: Array<{ matchNumber: number; participantId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the decided Wildcard winners in the Elimination Finals they belong in.
|
||||
*
|
||||
* The two winners are re-seeded by ladder position — 5th meets the lower-ranked one and
|
||||
* 6th the higher-ranked one — rather than crossing over from a fixed Wildcard match. That
|
||||
* destination depends on both games, so this reconciles both slots against the results
|
||||
* recorded so far every time it runs: it places a winner whose slot only became certain
|
||||
* once the other game was decided, and moves one that an earlier (or corrected) result,
|
||||
* or a bracket advanced before this rule existed, put in the other slot.
|
||||
*
|
||||
* `pending` supplies a result that may not be in the database yet — the row read back
|
||||
* while advancing a match can predate the winner being written to it.
|
||||
*
|
||||
* Idempotent: pairings that are already right do no writes.
|
||||
*/
|
||||
export async function reseedAflEliminationFinals(
|
||||
eventId: string,
|
||||
pending?: { matchId: string; winnerId: string }
|
||||
): Promise<AflEliminationReseed> {
|
||||
const [wcMatches, efMatches] = await Promise.all([
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"),
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
|
||||
]);
|
||||
|
||||
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
|
||||
if (wcMatches.length === 0 || efMatches.length === 0) {
|
||||
throw new Error(
|
||||
`Event ${eventId} has no AFL Wildcard Round / Elimination Finals matches to re-seed`
|
||||
);
|
||||
}
|
||||
|
||||
const winnerByMatchNumber = new Map<number, string>();
|
||||
for (const wc of wcMatches) {
|
||||
const decidedWinner =
|
||||
pending && wc.id === pending.matchId ? pending.winnerId : wc.isComplete ? wc.winnerId : null;
|
||||
if (decidedWinner) winnerByMatchNumber.set(wc.matchNumber, decidedWinner);
|
||||
}
|
||||
|
||||
const results: AflWildcardResult[] = wcMatches.map((wc) => {
|
||||
const decidedWinner = winnerByMatchNumber.get(wc.matchNumber) ?? null;
|
||||
if (decidedWinner === null) return { matchNumber: wc.matchNumber, winnerSlot: null };
|
||||
if (decidedWinner === wc.participant1Id) return { matchNumber: wc.matchNumber, winnerSlot: 1 };
|
||||
if (decidedWinner === wc.participant2Id) return { matchNumber: wc.matchNumber, winnerSlot: 2 };
|
||||
throw new Error(
|
||||
`Wildcard Round match ${wc.matchNumber} winner is not one of its participants`
|
||||
);
|
||||
});
|
||||
|
||||
const wanted = new Map<number, string>();
|
||||
for (const placement of resolveAflWildcardPlacements(results)) {
|
||||
const placedWinner = winnerByMatchNumber.get(placement.wildcardMatchNumber);
|
||||
if (placedWinner) wanted.set(placement.eliminationMatchNumber, placedWinner);
|
||||
}
|
||||
|
||||
// Only these teams can legitimately be moved between the two Elimination Finals;
|
||||
// anyone else in a slot came from somewhere this function knows nothing about.
|
||||
const wildcardParticipants = new Set<string>();
|
||||
for (const wc of wcMatches) {
|
||||
if (wc.participant1Id) wildcardParticipants.add(wc.participant1Id);
|
||||
if (wc.participant2Id) wildcardParticipants.add(wc.participant2Id);
|
||||
}
|
||||
|
||||
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
|
||||
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
|
||||
|
||||
for (const efMatch of efMatches) {
|
||||
const occupant = efMatch.participant2Id;
|
||||
const belongsHere = wanted.get(efMatch.matchNumber) ?? null;
|
||||
if (occupant === belongsHere) continue;
|
||||
|
||||
if (occupant !== null && !wildcardParticipants.has(occupant)) {
|
||||
throw new Error(`EF ${efMatch.matchNumber} participant2 already filled`);
|
||||
}
|
||||
// Re-seeding a game that has already been played would rewrite who contested a
|
||||
// recorded result. Surface that (this message is not one callers swallow) rather
|
||||
// than quietly corrupting the bracket.
|
||||
if (occupant !== null && (efMatch.isComplete || efMatch.winnerId)) {
|
||||
throw new Error(
|
||||
`Elimination Finals match ${efMatch.matchNumber} already has a recorded result, ` +
|
||||
`so its Wildcard qualifier cannot be re-seeded — clear and regenerate the bracket`
|
||||
);
|
||||
}
|
||||
if (occupant !== null) slotsToClear.push({ id: efMatch.id, matchNumber: efMatch.matchNumber });
|
||||
if (belongsHere !== null) {
|
||||
slotsToFill.push({ id: efMatch.id, matchNumber: efMatch.matchNumber, participantId: belongsHere });
|
||||
}
|
||||
}
|
||||
|
||||
const reseed: AflEliminationReseed = {
|
||||
vacated: slotsToClear.map((slot) => slot.matchNumber),
|
||||
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
|
||||
};
|
||||
|
||||
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
|
||||
|
||||
// One transaction, vacating before filling: a half-applied re-seed would leave the
|
||||
// same team in both Elimination Finals.
|
||||
const db = database();
|
||||
await db.transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
for (const slot of slotsToClear) {
|
||||
await tx
|
||||
.update(schema.playoffMatches)
|
||||
.set({ participant2Id: null, updatedAt: now })
|
||||
.where(eq(schema.playoffMatches.id, slot.id));
|
||||
}
|
||||
for (const slot of slotsToFill) {
|
||||
await tx
|
||||
.update(schema.playoffMatches)
|
||||
.set({ participant2Id: slot.participantId, updatedAt: now })
|
||||
.where(eq(schema.playoffMatches.id, slot.id));
|
||||
}
|
||||
});
|
||||
|
||||
return reseed;
|
||||
}
|
||||
|
||||
/** What a Semi-Finals re-seed changed, by Semi-Finals match number. */
|
||||
export interface AflSemiFinalReseed {
|
||||
vacated: number[];
|
||||
filled: Array<{ matchNumber: number; participantId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the decided Elimination Final winners in the Semi-Finals they belong in.
|
||||
*
|
||||
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
|
||||
* n, so SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the EF2
|
||||
* winner. The crossover in this system comes a round later, at Semi-Final → Preliminary
|
||||
* Final, so that a Qualifying Final loser cannot meet the side that just beat it.
|
||||
*
|
||||
* Brackets advanced before this was fixed crossed the two winners — the EF1 winner went
|
||||
* to SF2 and the EF2 winner to SF1 — which is why this reconciles both slots against the
|
||||
* results recorded so far rather than writing the one it was called for: a winner sitting
|
||||
* in the wrong Semi-Final is vacated, and a corrected Elimination Final result pulls the
|
||||
* beaten team back out instead of leaving it alive.
|
||||
*
|
||||
* `pending` supplies a result that may not be in the database yet — the row read back
|
||||
* while advancing a match can predate the winner being written to it.
|
||||
*
|
||||
* Idempotent: pairings that are already right do no writes.
|
||||
*/
|
||||
export async function reseedAflSemiFinals(
|
||||
eventId: string,
|
||||
pending?: { matchId: string; winnerId: string }
|
||||
): Promise<AflSemiFinalReseed> {
|
||||
const [efMatches, sfMatches] = await Promise.all([
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals"),
|
||||
]);
|
||||
|
||||
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
|
||||
if (efMatches.length === 0 || sfMatches.length === 0) {
|
||||
throw new Error(
|
||||
`Event ${eventId} has no AFL Elimination Finals / Semi-Finals matches to re-seed`
|
||||
);
|
||||
}
|
||||
|
||||
// Elimination Final n feeds Semi-Final n, so a decided winner's destination never
|
||||
// depends on the other game.
|
||||
const wanted = new Map<number, string>();
|
||||
for (const ef of efMatches) {
|
||||
const decidedWinner =
|
||||
pending && ef.id === pending.matchId ? pending.winnerId : ef.isComplete ? ef.winnerId : null;
|
||||
if (!decidedWinner) continue;
|
||||
if (decidedWinner !== ef.participant1Id && decidedWinner !== ef.participant2Id) {
|
||||
throw new Error(
|
||||
`Elimination Finals match ${ef.matchNumber} winner is not one of its participants`
|
||||
);
|
||||
}
|
||||
wanted.set(ef.matchNumber, decidedWinner);
|
||||
}
|
||||
|
||||
// Only these teams can legitimately be moved between the two Semi-Finals; anyone else
|
||||
// in a slot came from somewhere this function knows nothing about.
|
||||
const eliminationParticipants = new Set<string>();
|
||||
for (const ef of efMatches) {
|
||||
if (ef.participant1Id) eliminationParticipants.add(ef.participant1Id);
|
||||
if (ef.participant2Id) eliminationParticipants.add(ef.participant2Id);
|
||||
}
|
||||
|
||||
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
|
||||
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
|
||||
|
||||
for (const sfMatch of sfMatches) {
|
||||
const occupant = sfMatch.participant2Id;
|
||||
const belongsHere = wanted.get(sfMatch.matchNumber) ?? null;
|
||||
if (occupant === belongsHere) continue;
|
||||
|
||||
if (occupant !== null && !eliminationParticipants.has(occupant)) {
|
||||
throw new Error(`SF ${sfMatch.matchNumber} participant2 already filled`);
|
||||
}
|
||||
// Re-seeding a game that has already been played would rewrite who contested a
|
||||
// recorded result. Surface that (this message is not one callers swallow) rather
|
||||
// than quietly corrupting the bracket.
|
||||
if (occupant !== null && (sfMatch.isComplete || sfMatch.winnerId)) {
|
||||
throw new Error(
|
||||
`Semi-Finals match ${sfMatch.matchNumber} already has a recorded result, ` +
|
||||
`so its Elimination Finals qualifier cannot be re-seeded — clear and regenerate the bracket`
|
||||
);
|
||||
}
|
||||
if (occupant !== null) slotsToClear.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber });
|
||||
if (belongsHere !== null) {
|
||||
slotsToFill.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber, participantId: belongsHere });
|
||||
}
|
||||
}
|
||||
|
||||
const reseed: AflSemiFinalReseed = {
|
||||
vacated: slotsToClear.map((slot) => slot.matchNumber),
|
||||
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
|
||||
};
|
||||
|
||||
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
|
||||
|
||||
// One transaction, vacating before filling: a half-applied re-seed would leave the
|
||||
// same team in both Semi-Finals.
|
||||
const db = database();
|
||||
await db.transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
for (const slot of slotsToClear) {
|
||||
await tx
|
||||
.update(schema.playoffMatches)
|
||||
.set({ participant2Id: null, updatedAt: now })
|
||||
.where(eq(schema.playoffMatches.id, slot.id));
|
||||
}
|
||||
for (const slot of slotsToFill) {
|
||||
await tx
|
||||
.update(schema.playoffMatches)
|
||||
.set({ participant2Id: slot.participantId, updatedAt: now })
|
||||
.where(eq(schema.playoffMatches.id, slot.id));
|
||||
}
|
||||
});
|
||||
|
||||
return reseed;
|
||||
}
|
||||
|
||||
/**
|
||||
* AFL-specific advancement logic for the complex double-chance system
|
||||
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
||||
*
|
||||
* Advancement rules:
|
||||
* - Wildcard Round: Winner → Elimination Finals (re-seeded by ladder position)
|
||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals (QF n → PF n, SF n)
|
||||
* - Elimination Finals: Winner → Semi-Finals (EF n → SF n, a fixed pathway)
|
||||
* - Semi-Finals: Winner → Preliminary Finals (SF n crosses over: SF1 → PF2, SF2 → PF1)
|
||||
* - Wildcard Round: Winner → Elimination Finals
|
||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
||||
* - Elimination Finals: Winner → Semi-Finals
|
||||
* - Semi-Finals: Winner → Preliminary Finals
|
||||
* - Preliminary Finals: Winner → Grand Final
|
||||
*/
|
||||
async function advanceAFLWinner(
|
||||
|
|
@ -1131,10 +869,18 @@ async function advanceAFLWinner(
|
|||
): Promise<void> {
|
||||
const eventId = match.scoringEventId;
|
||||
|
||||
// Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
|
||||
// position, so every result re-resolves both slots.
|
||||
// Wildcard Round: Winner advances to Elimination Finals
|
||||
if (match.round === "Wildcard Round") {
|
||||
await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId });
|
||||
// Wildcard Match 1 winner → EF Match 2, participant2Id
|
||||
// Wildcard Match 2 winner → EF Match 1, participant2Id
|
||||
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||||
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
|
||||
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
|
||||
|
||||
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
|
||||
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
|
||||
|
||||
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1162,11 +908,18 @@ async function advanceAFLWinner(
|
|||
return;
|
||||
}
|
||||
|
||||
// Elimination Finals: Winner → Semi-Finals. EF n feeds SF n — the crossover in this
|
||||
// system is a round later, at Semi-Finals → Preliminary Finals. Reconcile both slots so
|
||||
// a corrected result moves the qualifier instead of leaving the beaten team alive.
|
||||
// Elimination Finals: Winner → Semi-Finals
|
||||
if (match.round === "Elimination Finals") {
|
||||
await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
|
||||
// EF Match 1 winner → SF2 participant2
|
||||
// EF Match 2 winner → SF1 participant2
|
||||
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||||
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
|
||||
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
|
||||
|
||||
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
|
||||
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
|
||||
|
||||
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1227,15 +980,6 @@ export async function advanceWinnerTemplate(
|
|||
return await advanceNBAPlayInWinner(match, winnerId, loserId);
|
||||
}
|
||||
|
||||
// Special handling for LLWS 20 double elimination: winners-bracket losers route
|
||||
// into the elimination bracket instead of being knocked out.
|
||||
if (template.id === "llws_20") {
|
||||
const loserId =
|
||||
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||||
if (!loserId) throw new Error("Cannot determine loser for LLWS advancement");
|
||||
return await advanceLLWSWinner(match, winnerId, loserId);
|
||||
}
|
||||
|
||||
// Special handling for AFL 10 double-chance system
|
||||
// Phase 3.3: AFL has complex winner/loser advancement rules
|
||||
if (template.id === "afl_10") {
|
||||
|
|
@ -1683,12 +1427,6 @@ export function doesLoserAdvance(
|
|||
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
||||
return true;
|
||||
}
|
||||
// LLWS winners bracket: a loss drops the team into the elimination bracket, so it
|
||||
// must not be recorded as an elimination. (Winners Final and Bracket Championship
|
||||
// are scoring rounds and are handled via loserIsPartial instead.)
|
||||
if (templateId === "llws_20" && LLWS_LOSER_ADVANCES_ROUNDS.has(round)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1800,140 +1538,3 @@ async function advanceNBAPlayInWinner(
|
|||
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
|
||||
|
||||
// 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).
|
||||
*
|
||||
* Only the Opening Round and the four bye slots receive participants up front;
|
||||
* everything else is filled by advanceLLWSWinner as games complete.
|
||||
*
|
||||
* Participant array layout (see LLWS_20 in lib/bracket-templates):
|
||||
* [0–7] U.S. Opening Round teams, two per game
|
||||
* [8, 9] U.S. bye teams → Winners Round 2 M1 / M2 participant1
|
||||
* [10–17] International Opening Round teams, two per game
|
||||
* [18,19] International bye teams → Winners Round 2 M3 / M4 participant1
|
||||
*/
|
||||
async function generateLLWS20Bracket(
|
||||
eventId: string,
|
||||
template: BracketTemplate,
|
||||
participantIds?: string[]
|
||||
): Promise<PlayoffMatch[]> {
|
||||
const matches: NewPlayoffMatch[] = [];
|
||||
const p = (idx: number): string | null =>
|
||||
participantIds ? (participantIds[idx] ?? null) : null;
|
||||
|
||||
const sides = [
|
||||
{ side: 0 as const, label: "U.S.", openingBase: 0, byeBase: 8 },
|
||||
{ side: 1 as const, label: "Intl", openingBase: 10, byeBase: 18 },
|
||||
];
|
||||
|
||||
// ── Opening Round: 4 games per side, both slots seeded ──────────────────────
|
||||
for (const { side, label, openingBase } of sides) {
|
||||
for (let local = 1; local <= 4; local++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Opening Round",
|
||||
matchNumber: llwsMatchNumber("Opening Round", side, local),
|
||||
participant1Id: p(openingBase + (local - 1) * 2),
|
||||
participant2Id: p(openingBase + (local - 1) * 2 + 1),
|
||||
isComplete: false,
|
||||
isScoring: false,
|
||||
templateRound: "Opening Round",
|
||||
seedInfo: `${label} Opening ${local}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Winners Round 2: bye team at participant1, Opening winner at participant2 ─
|
||||
for (const { side, label, byeBase } of sides) {
|
||||
for (let local = 1; local <= 2; local++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Winners Round 2",
|
||||
matchNumber: llwsMatchNumber("Winners Round 2", side, local),
|
||||
participant1Id: p(byeBase + (local - 1)),
|
||||
participant2Id: null, // Opening Round winner
|
||||
isComplete: false,
|
||||
isScoring: false,
|
||||
templateRound: "Winners Round 2",
|
||||
seedInfo: `${label} Bye ${local} vs Opening ${local} winner`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Every remaining round starts empty ──────────────────────────────────────
|
||||
const remaining = template.rounds.filter(
|
||||
(r) => r.name !== "Opening Round" && r.name !== "Winners Round 2"
|
||||
);
|
||||
for (const round of remaining) {
|
||||
for (let i = 1; i <= round.matchCount; i++) {
|
||||
// Championship/Consolation are single shared games; everything else is per-side.
|
||||
const perSide = round.matchCount > 1;
|
||||
const label = perSide
|
||||
? llwsSideAndLocal(round.name, i).side === 0
|
||||
? "U.S."
|
||||
: "Intl"
|
||||
: null;
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: round.name,
|
||||
matchNumber: i,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
isComplete: false,
|
||||
isScoring: round.isScoring,
|
||||
templateRound: round.name,
|
||||
seedInfo: label ? `${label} ${round.name}` : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await createManyPlayoffMatches(matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* LLWS advancement: routes the winner forward and, in the winners bracket, routes the
|
||||
* loser into the elimination bracket rather than eliminating them.
|
||||
*
|
||||
* All routing decisions live in resolveLLWSAdvancement; this function only writes.
|
||||
*/
|
||||
async function advanceLLWSWinner(
|
||||
match: PlayoffMatch,
|
||||
winnerId: string,
|
||||
loserId: string
|
||||
): Promise<void> {
|
||||
const eventId = match.scoringEventId;
|
||||
const { winner, loser } = resolveLLWSAdvancement(match.round, match.matchNumber);
|
||||
|
||||
// Winner and loser can land in different rounds, so resolve each independently.
|
||||
const moves: Array<{ destination: LLWSResolvedDestination; participantId: string }> = [];
|
||||
if (winner) moves.push({ destination: winner, participantId: winnerId });
|
||||
if (loser) moves.push({ destination: loser, participantId: loserId });
|
||||
|
||||
for (const { destination, participantId } of moves) {
|
||||
const targetMatches = await findPlayoffMatchesByEventIdAndRound(
|
||||
eventId,
|
||||
destination.round
|
||||
);
|
||||
const target = targetMatches.find((m) => m.matchNumber === destination.matchNumber);
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`Next match not found: round=${destination.round}, matchNumber=${destination.matchNumber}`
|
||||
);
|
||||
}
|
||||
if (target[destination.slot]) {
|
||||
throw new Error(
|
||||
`Next match ${destination.slot} is already filled ` +
|
||||
`(round=${destination.round}, matchNumber=${destination.matchNumber})`
|
||||
);
|
||||
}
|
||||
await updatePlayoffMatch(target.id, { [destination.slot]: participantId });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-
|
|||
import { getUserDisplayName } from "~/models/user";
|
||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
import { getBracketTemplateIdForSportsSeason } from "~/models/bracket-template";
|
||||
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getEventResults } from "./event-result";
|
||||
|
|
@ -114,21 +113,6 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
|
|||
// 3rd place game finalizes both positions distinctly.
|
||||
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
||||
},
|
||||
llws_20: {
|
||||
// Winners Final loser drops to the Elimination Final, so 5th is provisional —
|
||||
// winning that game lifts them back to a 4th-place floor.
|
||||
"Winners Final": { loserPosition: 5, loserIsPartial: true, winnerFloor: 4 },
|
||||
// Elimination Round 4 losers are the 7th–8th tier (8 teams alive at this point).
|
||||
"Elimination Round 4": { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 },
|
||||
// Elimination Final losers are the 5th–6th tier; the winner reaches the side
|
||||
// championship, where the worst case is 4th (lose it, then lose the consolation).
|
||||
"Elimination Final": { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 },
|
||||
// Side championship loser still has the consolation game — provisional 4th.
|
||||
"Bracket Championship": { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 },
|
||||
// Consolation finalizes 3rd and 4th distinctly.
|
||||
"Consolation Third Place": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
||||
"World Championship": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
||||
},
|
||||
tennis_128: {
|
||||
// R16 losers share 9th–16th; winner advances to QF (floor 5th–8th).
|
||||
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
|
||||
|
|
@ -142,141 +126,28 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
|
|||
};
|
||||
|
||||
/**
|
||||
* Returns the floor position that winners of a NON-scoring round should bank, or null
|
||||
* to bank nothing.
|
||||
* Returns true if a non-scoring round's winners are entering the first scoring round
|
||||
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor).
|
||||
*
|
||||
* Default: winners entering the first scoring round have guaranteed a top-8 fantasy
|
||||
* placement and receive a T5–T8 floor (5); everyone else gets nothing yet. For
|
||||
* multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 → Sweet
|
||||
* Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring bracket.
|
||||
* For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 →
|
||||
* Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring
|
||||
* bracket — Round of 64 and Round of 32 winners should not receive any floor yet.
|
||||
*
|
||||
* A round may override this with `nonScoringWinnerFloor` when the default is wrong —
|
||||
* in a double-elimination losers bracket a win can guarantee a worse finish than 5th
|
||||
* (llws_20 "Elimination Round 3" → 7), or nothing at all.
|
||||
*
|
||||
* Falls back to 5 when template/round info is unavailable, preserving legacy behavior.
|
||||
* Falls back to true when template/round info is unavailable to preserve legacy behavior.
|
||||
*/
|
||||
function nonScoringWinnerFloorFor(
|
||||
function doesNonScoringRoundFeedIntoScoringRound(
|
||||
round: string,
|
||||
bracketTemplateId: string | null | undefined
|
||||
): number | null {
|
||||
if (!bracketTemplateId) return 5; // Legacy: preserve old behavior
|
||||
): boolean {
|
||||
if (!bracketTemplateId) return true; // Legacy: preserve old behavior
|
||||
const template = BRACKET_TEMPLATES[bracketTemplateId];
|
||||
if (!template) return 5; // Unknown template: preserve old behavior
|
||||
if (!template) return true; // Unknown template: preserve old behavior
|
||||
const currentRound = template.rounds.find((r) => r.name === round);
|
||||
if (!currentRound) return 5; // Unknown round: preserve old behavior
|
||||
// Explicit per-round override wins, including an explicit null (bank nothing).
|
||||
if (currentRound.nonScoringWinnerFloor !== undefined) {
|
||||
return currentRound.nonScoringWinnerFloor;
|
||||
}
|
||||
if (!currentRound) return true; // Unknown round: preserve old behavior
|
||||
const nextRoundName = currentRound.feedsInto;
|
||||
if (!nextRoundName) return null; // No next round (shouldn't happen for non-scoring)
|
||||
if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring)
|
||||
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
|
||||
return nextRound?.isScoring === true ? 5 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the floor position a participant banks purely by being *seeded into* the
|
||||
* given round when the bracket is generated, or null when entry guarantees nothing.
|
||||
*
|
||||
* Two sources, in order:
|
||||
* 1. The template round's explicit `entryFloor` (e.g. afl_10 "Qualifying Finals" → 5:
|
||||
* seeds 1-4 have the double chance, so the 5th-6th tier is locked in on day one).
|
||||
* 2. Otherwise a scoring round's own loser position — being drawn into a round whose
|
||||
* losers score means the worst case is that round's loser tier.
|
||||
*
|
||||
* Non-scoring rounds with no explicit `entryFloor` return null: losing your first game
|
||||
* there is worth 0, so there is nothing to bank yet.
|
||||
*/
|
||||
export function getBracketEntryFloor(
|
||||
round: string,
|
||||
bracketTemplateId: string | null | undefined
|
||||
): number | null {
|
||||
const template = bracketTemplateId ? BRACKET_TEMPLATES[bracketTemplateId] : undefined;
|
||||
const templateRound = template?.rounds.find((r) => r.name === round);
|
||||
if (templateRound?.entryFloor !== undefined) return templateRound.entryFloor;
|
||||
if (!templateRound?.isScoring) return null;
|
||||
return getRoundConfig(round, bracketTemplateId)?.loserPosition ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the provisional entry floors for a freshly generated (or reprocessed) bracket.
|
||||
*
|
||||
* A seeded bracket can guarantee points before anyone plays: an AFL top-4 seed cannot
|
||||
* finish below the 5th-6th tier because a Qualifying Final loss still leaves them a
|
||||
* Semi-Final. Without this, those teams sit on 0 fantasy points until their first game
|
||||
* resolves, which understates every roster holding them.
|
||||
*
|
||||
* Only participants already assigned to a match slot are touched, and every write is
|
||||
* provisional (isPartialScore=true) so it is superseded the moment a real result lands.
|
||||
*
|
||||
* Floors never go backwards. A participant already sitting on an equal or better
|
||||
* placement is skipped, so regenerating a bracket mid-tournament (clear-bracket →
|
||||
* generate-bracket) cannot knock a finalist back down to their seeding floor. Combined
|
||||
* with upsertParticipantResult's never-un-finalize guard, re-running over the same
|
||||
* bracket is a no-op.
|
||||
*
|
||||
* Returns the number of participants whose floor this call actually raised.
|
||||
*/
|
||||
export async function applyBracketEntryFloors(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<number> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
});
|
||||
if (!event?.bracketTemplateId) return 0;
|
||||
|
||||
const matches = await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
||||
});
|
||||
|
||||
// Highest (best) floor wins when a participant somehow appears in more than one
|
||||
// round's slots — a lower position number is a better guarantee.
|
||||
const floorByParticipant = new Map<string, number>();
|
||||
for (const match of matches) {
|
||||
const floor = getBracketEntryFloor(match.round, event.bracketTemplateId);
|
||||
if (floor === null) continue;
|
||||
for (const participantId of [match.participant1Id, match.participant2Id]) {
|
||||
if (!participantId) continue;
|
||||
const existing = floorByParticipant.get(participantId);
|
||||
if (existing === undefined || floor < existing) {
|
||||
floorByParticipant.set(participantId, floor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Existing placements, so a floor is only ever written when it improves on what
|
||||
// the participant already has. Position 0 means eliminated / missed the bracket —
|
||||
// not a better placement — so it never blocks a floor.
|
||||
const existingRows = await db.query.seasonParticipantResults.findMany({
|
||||
where: eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId),
|
||||
columns: { participantId: true, finalPosition: true },
|
||||
});
|
||||
const existingPosition = new Map(
|
||||
existingRows
|
||||
.filter((r) => r.finalPosition !== null && r.finalPosition > 0)
|
||||
.map((r) => [r.participantId, r.finalPosition as number])
|
||||
);
|
||||
|
||||
let applied = 0;
|
||||
for (const [participantId, floor] of floorByParticipant) {
|
||||
const current = existingPosition.get(participantId);
|
||||
if (current !== undefined && current <= floor) continue; // already as good or better
|
||||
|
||||
const oldFloor = await upsertParticipantResult(
|
||||
participantId,
|
||||
event.sportsSeasonId,
|
||||
floor,
|
||||
db,
|
||||
true // provisional: replaced as soon as the participant wins or is eliminated
|
||||
);
|
||||
if (oldFloor !== null) applied++;
|
||||
}
|
||||
|
||||
return applied;
|
||||
return nextRound?.isScoring === true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -410,18 +281,19 @@ export async function processPlayoffEvent(
|
|||
}
|
||||
|
||||
if (!isScoring) {
|
||||
// Non-scoring round: losers are permanently eliminated (0 pts) unless they
|
||||
// advance (double-elimination winners-bracket losers). Winners bank a
|
||||
// provisional floor only when this round guarantees them one — see
|
||||
// nonScoringWinnerFloorFor for how that is derived per template.
|
||||
const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId);
|
||||
// Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts).
|
||||
// Winners only bank a provisional T5–T8 floor if they're entering the first
|
||||
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences
|
||||
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should
|
||||
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
|
||||
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
|
||||
for (const match of matches) {
|
||||
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
|
||||
if (match.loserId && !loserAdvances) {
|
||||
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
||||
}
|
||||
if (match.winnerId && winnerFloor !== null) {
|
||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true);
|
||||
if (match.winnerId && awardFloor) {
|
||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -481,7 +353,7 @@ export async function processPlayoffEvent(
|
|||
// Progressive floor scoring: assign guaranteed minimum points to winners.
|
||||
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
|
||||
// winner is already finalized as 1st above. For non-scoring rounds it also
|
||||
// returns null; those winners were given their floor inline above.
|
||||
// returns null (winners were given floor 5 inline above).
|
||||
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
||||
round,
|
||||
event.bracketTemplateId,
|
||||
|
|
@ -547,19 +419,6 @@ export async function processMatchResult(
|
|||
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
||||
matchId?: string;
|
||||
skipSideEffects?: boolean;
|
||||
/**
|
||||
* Skip only the probability refresh, still recalculating standings and announcing.
|
||||
*
|
||||
* For a caller scoring several matches in a loop: the refresh is season-wide and
|
||||
* idempotent, so running it per match repeats the whole thing needlessly — and for a
|
||||
* bracket-aware sport that now means a full Monte Carlo run each time. Set this in the
|
||||
* loop and call updateProbabilitiesAfterResult once when it finishes. Per-match
|
||||
* announcements then project from the previous probabilities until that final call.
|
||||
*
|
||||
* Distinct from skipSideEffects, which also suppresses the standings recalculation and
|
||||
* the announcement.
|
||||
*/
|
||||
skipProbabilities?: boolean;
|
||||
/**
|
||||
* When true, the loser of this non-scoring round advances to another match
|
||||
* (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be
|
||||
|
|
@ -570,7 +429,7 @@ export async function processMatchResult(
|
|||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, skipProbabilities, loserAdvances } = params;
|
||||
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params;
|
||||
|
||||
if (!isScoring) {
|
||||
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
||||
|
|
@ -582,9 +441,8 @@ export async function processMatchResult(
|
|||
if (!loserAdvances) {
|
||||
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
||||
}
|
||||
const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId);
|
||||
if (nonScoringFloor !== null) {
|
||||
await upsertParticipantResult(winnerId, sportsSeasonId, nonScoringFloor, db, true);
|
||||
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
||||
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
||||
}
|
||||
// Non-scoring round wins are not surfaced in the Recent Scores feed.
|
||||
} else {
|
||||
|
|
@ -650,15 +508,13 @@ export async function processMatchResult(
|
|||
: undefined;
|
||||
// Update probabilities first so the standings recalc reads fresh EVs and
|
||||
// projected points reflect the new result.
|
||||
if (!skipProbabilities) {
|
||||
try {
|
||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
try {
|
||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||
}
|
||||
|
|
@ -1481,7 +1337,11 @@ export async function calculateTeamScore(
|
|||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||
}
|
||||
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
columns: { bracketTemplateId: true },
|
||||
});
|
||||
const templateId = event?.bracketTemplateId ?? null;
|
||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||
return templateId;
|
||||
}
|
||||
|
|
@ -1590,7 +1450,11 @@ export async function calculateTeamProjectedScore(
|
|||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||
}
|
||||
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
columns: { bracketTemplateId: true },
|
||||
});
|
||||
const templateId = event?.bracketTemplateId ?? null;
|
||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||
return templateId;
|
||||
}
|
||||
|
|
@ -2076,10 +1940,8 @@ export async function recalculateAffectedLeagues(
|
|||
// in the World Cup) does not qualify on its own — their owner hasn't earned anything yet.
|
||||
// When a match qualifies because the loser is owned, the winner's manager tag is still
|
||||
// shown for context (who beat them), but the winner is not Discord-pinged.
|
||||
// Both managers' tags are always shown for context when their teams are drafted; the
|
||||
// showLoser flag (isLoserNotifiable) only gates whether the loser is @-pinged — a loser
|
||||
// who advanced rather than being eliminated (loserAdvances=true, e.g. NBA 7v8 → PIR2, or
|
||||
// a World Cup semifinal loser) is named but not pinged.
|
||||
// Losers who advance to another match (loserAdvances=true, e.g. NBA 7v8 → PIR2) have
|
||||
// showLoser=false and are correctly suppressed.
|
||||
let scoredMatches: ScoredMatch[] | undefined;
|
||||
if (allCompletedMatches.length > 0) {
|
||||
const relevant = allCompletedMatches.filter(
|
||||
|
|
@ -2112,12 +1974,7 @@ export async function recalculateAffectedLeagues(
|
|||
winnerName: x.m.winnerName ?? "",
|
||||
loserName: x.m.loserName ?? "",
|
||||
winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined,
|
||||
// Show the loser's manager tag whenever their team is drafted, mirroring the
|
||||
// winner above — even when the loser advances rather than being eliminated
|
||||
// (World Cup semifinal → 3rd-place playoff, AFL Qualifying Final → Semi Final).
|
||||
// The @-ping stays gated by showLoser (loserDiscordUserId below): a still-alive
|
||||
// loser who neither scored nor was eliminated is named for context but not pinged.
|
||||
loserUsername: x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
|
||||
loserUsername: x.showLoser && x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
|
||||
winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined,
|
||||
loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -134,21 +134,14 @@ export function calculateSharedPlacementPoints(
|
|||
* AFL is different: it has TWO distinct tiers in the 5–8 zone:
|
||||
* - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6])
|
||||
* - T7-T8: Elimination Finals losers (positions 7 and 8) → avg([7,8])
|
||||
*
|
||||
* LLWS has the same shape from its two elimination brackets:
|
||||
* - T5-T6: Elimination Final losers (one per side) → avg([5,6])
|
||||
* - T7-T8: Elimination Round 4 losers (one per side) → avg([7,8])
|
||||
*/
|
||||
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]);
|
||||
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]);
|
||||
|
||||
/**
|
||||
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
|
||||
* (not averaged). Standard brackets average them because both SF losers tie.
|
||||
*
|
||||
* llws_20's Consolation Third Place game decides 3rd and 4th head-to-head between
|
||||
* the two side runners-up.
|
||||
*/
|
||||
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]);
|
||||
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48"]);
|
||||
|
||||
/**
|
||||
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
/**
|
||||
* 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,10 +15,6 @@ 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;
|
||||
|
|
@ -359,28 +355,18 @@ export async function batchUpsertParticipantSimulatorInputs(
|
|||
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
|
||||
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
|
||||
// tell readers whether the stored Elo/rating is generated vs. a trusted
|
||||
// direct value. Two rules apply, and both always apply — they are not
|
||||
// alternatives:
|
||||
//
|
||||
// 1. Drop the method flag for any column receiving a fresh direct
|
||||
// value, otherwise a stale "generated" flag would cause that
|
||||
// newly-entered Elo/rating to be filtered out as derived (see
|
||||
// getParticipantSimulatorInputs).
|
||||
// 2. Merge any metadata the caller supplied over the result
|
||||
// (prepareSimulatorInputsForRun and the projection importers set the
|
||||
// correct flags) — a merge rather than a replace so a caller that
|
||||
// only needs to stamp one method flag does not wipe unrelated keys.
|
||||
//
|
||||
// Ordering matters: strip first, then merge, so a caller stamping one flag
|
||||
// still gets the other column's stale flag cleared. Running these as
|
||||
// exclusive CASE branches instead would mean a bulk row carrying both a
|
||||
// direct `rating` and a `projectedWins` (which stamps sourceEloMethod)
|
||||
// silently kept a stale ratingMethod, hiding the rating it just set.
|
||||
metadata: sql`(
|
||||
COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
|
||||
// direct value. When a caller supplies explicit metadata, use it as-is
|
||||
// (prepareSimulatorInputsForRun and the projection importer set the
|
||||
// correct flags). Otherwise preserve existing metadata, but drop the
|
||||
// method flag for any column receiving a fresh direct value — otherwise a
|
||||
// stale "generated" flag would cause that newly-entered Elo/rating to be
|
||||
// filtered out as derived (see getParticipantSimulatorInputs).
|
||||
metadata: sql`CASE
|
||||
WHEN excluded.metadata IS NOT NULL THEN excluded.metadata
|
||||
ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
|
||||
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
|
||||
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
|
||||
) || COALESCE(excluded.metadata, '{}'::jsonb)`,
|
||||
END`,
|
||||
updatedAt: sql`excluded.updated_at`,
|
||||
},
|
||||
});
|
||||
|
|
@ -502,19 +488,6 @@ 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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from
|
|||
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getParticipantEV } from "./participant-expected-value";
|
||||
import { getBracketTemplateIdForSportsSeason } from "./bracket-template";
|
||||
import { calculateEV } from "~/services/ev-calculator";
|
||||
|
||||
// Re-export types from shared types file
|
||||
|
|
@ -164,7 +163,11 @@ export async function getTeamScoreBreakdown(
|
|||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||
}
|
||||
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
columns: { bracketTemplateId: true },
|
||||
});
|
||||
const templateId = event?.bracketTemplateId ?? null;
|
||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||
return templateId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The Expected Values admin page renders EV from the stored probability columns.
|
||||
*
|
||||
* It used to carry its own hardcoded scoring table (100/70/45/45/20/20/20/20), which
|
||||
* flattened positions 5–8 to 20 points each. For a standard single-elimination bracket
|
||||
* that was invisible — all four quarterfinal losers share one tier worth
|
||||
* avg(25,25,15,15) = 20 anyway — but for the templates that split 5–8 into two tiers
|
||||
* (llws_20, afl_10) it reported a team locked into 5th–6th and a team locked into
|
||||
* 7th–8th as the same 20 points. These pin it to the shared DEFAULT_SCORING_RULES.
|
||||
*/
|
||||
|
||||
vi.mock("../admin.sports-seasons.$id.expected-values.server", () => ({
|
||||
loader: vi.fn(),
|
||||
}));
|
||||
|
||||
import { evFromProbs } from "../admin.sports-seasons.$id.expected-values";
|
||||
|
||||
const ZERO = {
|
||||
probFirst: "0", probSecond: "0", probThird: "0", probFourth: "0",
|
||||
probFifth: "0", probSixth: "0", probSeventh: "0", probEighth: "0",
|
||||
};
|
||||
|
||||
describe("evFromProbs", () => {
|
||||
it("gives a team locked into the 5th–6th tier 25 points, not 20", () => {
|
||||
expect(evFromProbs({ ...ZERO, probFifth: "0.5", probSixth: "0.5" })).toBe(25);
|
||||
});
|
||||
|
||||
it("gives a team locked into the 7th–8th tier 15 points, not 20", () => {
|
||||
expect(evFromProbs({ ...ZERO, probSeventh: "0.5", probEighth: "0.5" })).toBe(15);
|
||||
});
|
||||
|
||||
it("still gives a single 5th–8th tier (4 QF losers) 20 points", () => {
|
||||
const ev = evFromProbs({
|
||||
...ZERO,
|
||||
probFifth: "0.25", probSixth: "0.25", probSeventh: "0.25", probEighth: "0.25",
|
||||
});
|
||||
expect(ev).toBe(20);
|
||||
});
|
||||
|
||||
it("keeps 3rd and 4th distinct rather than a flat 45 each", () => {
|
||||
expect(evFromProbs({ ...ZERO, probThird: "1" })).toBe(50);
|
||||
expect(evFromProbs({ ...ZERO, probFourth: "1" })).toBe(40);
|
||||
});
|
||||
|
||||
it("preserves the 340 total-EV invariant across a full set of unit columns", () => {
|
||||
const perPosition = [
|
||||
evFromProbs({ ...ZERO, probFirst: "1" }),
|
||||
evFromProbs({ ...ZERO, probSecond: "1" }),
|
||||
evFromProbs({ ...ZERO, probThird: "1" }),
|
||||
evFromProbs({ ...ZERO, probFourth: "1" }),
|
||||
evFromProbs({ ...ZERO, probFifth: "1" }),
|
||||
evFromProbs({ ...ZERO, probSixth: "1" }),
|
||||
evFromProbs({ ...ZERO, probSeventh: "1" }),
|
||||
evFromProbs({ ...ZERO, probEighth: "1" }),
|
||||
];
|
||||
expect(perPosition.reduce((sum, ev) => sum + ev, 0)).toBe(340);
|
||||
});
|
||||
|
||||
it("returns 0 for a participant with no probability mass", () => {
|
||||
expect(evFromProbs(ZERO)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseBaseEloPriorityChoice,
|
||||
projectionMethodMetadata,
|
||||
resolvedInputMethodLabel,
|
||||
} from "../admin.sports-seasons.$id.simulator.helpers";
|
||||
import { DEFAULT_BASE_ELO_PRIORITY } from "~/services/simulations/input-policy";
|
||||
|
||||
describe("projectionMethodMetadata", () => {
|
||||
it("flags a row that supplies projected wins and no Elo", () => {
|
||||
expect(projectionMethodMetadata(undefined, 95, undefined)).toEqual({
|
||||
sourceEloMethod: "projectedWins",
|
||||
});
|
||||
});
|
||||
|
||||
it("flags a row that supplies projected table points and no Elo", () => {
|
||||
expect(projectionMethodMetadata(undefined, undefined, 76.5)).toEqual({
|
||||
sourceEloMethod: "projectedTablePoints",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves metadata alone when the row supplies an explicit Elo", () => {
|
||||
// An explicit Elo is a direct entry and must stay trusted, even alongside a
|
||||
// projection — the upsert then clears any stale generated flag.
|
||||
expect(projectionMethodMetadata(1600, 95, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves metadata alone for a row with neither", () => {
|
||||
expect(projectionMethodMetadata(undefined, undefined, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers wins over table points when a row somehow carries both", () => {
|
||||
expect(projectionMethodMetadata(undefined, 95, 76.5)).toEqual({
|
||||
sourceEloMethod: "projectedWins",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBaseEloPriorityChoice", () => {
|
||||
it("puts projections ahead of raw Elo", () => {
|
||||
expect(parseBaseEloPriorityChoice("projectionsFirst", DEFAULT_BASE_ELO_PRIORITY)).toEqual([
|
||||
"projectedWins",
|
||||
"projectedTablePoints",
|
||||
"sourceElo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts raw Elo first for eloFirst", () => {
|
||||
expect(parseBaseEloPriorityChoice("eloFirst", DEFAULT_BASE_ELO_PRIORITY)).toEqual(
|
||||
DEFAULT_BASE_ELO_PRIORITY
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the stored ordering when the select was not on the form", () => {
|
||||
// Simulators with no projection alternative never render the control; saving
|
||||
// other config must not rewrite their ordering.
|
||||
const custom: typeof DEFAULT_BASE_ELO_PRIORITY = ["projectedWins", "sourceElo"];
|
||||
expect(parseBaseEloPriorityChoice(null, custom)).toEqual(custom);
|
||||
});
|
||||
|
||||
it("preserves the relative order of the projection keys", () => {
|
||||
expect(
|
||||
parseBaseEloPriorityChoice("projectionsFirst", [
|
||||
"projectedTablePoints",
|
||||
"sourceElo",
|
||||
"projectedWins",
|
||||
])
|
||||
).toEqual(["projectedTablePoints", "projectedWins", "sourceElo"]);
|
||||
});
|
||||
|
||||
it("round-trips: flipping back restores Elo-first", () => {
|
||||
const flipped = parseBaseEloPriorityChoice("projectionsFirst", DEFAULT_BASE_ELO_PRIORITY);
|
||||
expect(parseBaseEloPriorityChoice("eloFirst", flipped)).toEqual(DEFAULT_BASE_ELO_PRIORITY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvedInputMethodLabel", () => {
|
||||
it("badges nothing for a directly entered Elo or rating", () => {
|
||||
expect(resolvedInputMethodLabel("direct")).toBeNull();
|
||||
});
|
||||
|
||||
it("badges both projection methods the same way", () => {
|
||||
expect(resolvedInputMethodLabel("projectedWins")).toBe("from projections");
|
||||
expect(resolvedInputMethodLabel("projectedTablePoints")).toBe("from projections");
|
||||
});
|
||||
|
||||
it("distinguishes futures and blended Elo", () => {
|
||||
expect(resolvedInputMethodLabel("sourceOdds")).toBe("from futures");
|
||||
expect(resolvedInputMethodLabel("blend")).toBe("blended");
|
||||
});
|
||||
|
||||
it("badges every missing-input strategy as a fallback", () => {
|
||||
expect(resolvedInputMethodLabel("fallbackElo")).toBe("fallback");
|
||||
expect(resolvedInputMethodLabel("fallbackRating")).toBe("fallback");
|
||||
expect(resolvedInputMethodLabel("averageKnown")).toBe("fallback");
|
||||
expect(resolvedInputMethodLabel("worstKnownMinus")).toBe("fallback");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
/**
|
||||
* generate-bracket banks the floors a seeding guarantees before anyone plays (an AFL
|
||||
* top-4 seed cannot finish below the 5th-6th tier). Those floors only reach
|
||||
* teamStandings.totalPoints through a standings recalculation, so the action has to be
|
||||
* sure one ran — markEliminatedAndAnnounce runs one for its Discord announcement in some
|
||||
* cases but not others.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { generateBracketFromTemplate } from "~/models/playoff-match";
|
||||
import {
|
||||
findParticipantResultsBySportsSeasonId,
|
||||
setParticipantResult,
|
||||
} from "~/models/participant-result";
|
||||
import {
|
||||
applyBracketEntryFloors,
|
||||
recalculateAffectedLeagues,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
||||
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>()),
|
||||
generateBracketFromTemplate: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findParticipantResultsBySportsSeasonId: vi.fn(),
|
||||
setParticipantResult: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
applyBracketEntryFloors: vi.fn(),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findParticipantsBySportsSeasonId: vi.fn(),
|
||||
}));
|
||||
|
||||
const params = { id: "season-1", eventId: "event-1" };
|
||||
|
||||
const EVENT = {
|
||||
id: "event-1",
|
||||
name: "AFL Finals",
|
||||
sportsSeasonId: "season-1",
|
||||
isQualifyingEvent: false,
|
||||
bracketTemplateId: "afl_10",
|
||||
};
|
||||
|
||||
/** afl_10 takes exactly 10 seeded participants. */
|
||||
const SEEDED = Array.from({ length: 10 }, (_, i) => `seed-${i + 1}`);
|
||||
|
||||
function generateRequest(): Request {
|
||||
const body = new FormData();
|
||||
body.set("intent", "generate-bracket");
|
||||
body.set("templateId", "afl_10");
|
||||
SEEDED.forEach((id, i) => body.set(`participant${i}`, id));
|
||||
return new Request("http://localhost/generate", { method: "POST", body });
|
||||
}
|
||||
|
||||
const run = (request: Request) =>
|
||||
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
||||
error?: string;
|
||||
success?: string;
|
||||
}>)({ request, params });
|
||||
|
||||
/**
|
||||
* @param extras participants in the season beyond the 10 seeded into the bracket —
|
||||
* these are the ones generate-bracket marks eliminated.
|
||||
* @param withExistingResults ids that already carry a result row, so
|
||||
* markEliminatedAndAnnounce treats them as not newly eliminated.
|
||||
*/
|
||||
function setSeason(extras: string[], withExistingResults: string[] = []) {
|
||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
|
||||
[...SEEDED, ...extras].map((id) => ({ id })) as unknown as Awaited<
|
||||
ReturnType<typeof findParticipantsBySportsSeasonId>
|
||||
>
|
||||
);
|
||||
vi.mocked(findParticipantResultsBySportsSeasonId).mockResolvedValue(
|
||||
withExistingResults.map((participantId) => ({ participantId })) as unknown as Awaited<
|
||||
ReturnType<typeof findParticipantResultsBySportsSeasonId>
|
||||
>
|
||||
);
|
||||
}
|
||||
|
||||
describe("generate-bracket entry-floor standings recalculation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(
|
||||
EVENT as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
||||
);
|
||||
vi.mocked(generateBracketFromTemplate).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof generateBracketFromTemplate>>
|
||||
);
|
||||
vi.mocked(updateScoringEvent).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof updateScoringEvent>>
|
||||
);
|
||||
vi.mocked(setParticipantResult).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
|
||||
);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
||||
);
|
||||
// afl_10 seeds 1-4 into the Qualifying Finals, whose entry floor is the 5th-6th tier.
|
||||
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
|
||||
});
|
||||
|
||||
it("recalculates when every eliminated team already had a result row", async () => {
|
||||
// The second run of a generation: the first wrote position 0 for the non-bracket
|
||||
// participants, so nobody is *newly* eliminated and the announcement is skipped.
|
||||
// The floors banked moments ago would never reach the standings.
|
||||
setSeason(["extra-1"], ["extra-1"]);
|
||||
|
||||
const result = await run(generateRequest());
|
||||
|
||||
expect(result.success).toBeDefined();
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
||||
"season-1",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ skipDiscord: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("recalculates for a qualifying event, which never announces eliminations", async () => {
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(
|
||||
{ ...EVENT, isQualifyingEvent: true } as unknown as Awaited<
|
||||
ReturnType<typeof getScoringEventById>
|
||||
>
|
||||
);
|
||||
setSeason(["extra-1"]);
|
||||
|
||||
await run(generateRequest());
|
||||
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
||||
"season-1",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ skipDiscord: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("recalculates when the bracket field is the whole season", async () => {
|
||||
setSeason([]);
|
||||
|
||||
await run(generateRequest());
|
||||
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("recalculates when the elimination announcement threw", async () => {
|
||||
// The announcement is best-effort and its failure is swallowed — but a failed recalc
|
||||
// is exactly when the floors still need one.
|
||||
setSeason(["extra-1"]);
|
||||
vi.mocked(recalculateAffectedLeagues)
|
||||
.mockRejectedValueOnce(new Error("discord down"))
|
||||
.mockResolvedValue(undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>);
|
||||
|
||||
const result = await run(generateRequest());
|
||||
|
||||
expect(result.success).toBeDefined();
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(2);
|
||||
expect(recalculateAffectedLeagues).toHaveBeenLastCalledWith(
|
||||
"season-1",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ skipDiscord: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("does not recalculate twice when the announcement already did", async () => {
|
||||
setSeason(["extra-1"]);
|
||||
|
||||
await run(generateRequest());
|
||||
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||||
// The announcing call, not the floor fallback.
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
||||
"season-1",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ eliminatedParticipantIds: ["extra-1"] })
|
||||
);
|
||||
});
|
||||
|
||||
it("does not recalculate at all when no floors were banked", async () => {
|
||||
// A template that guarantees nothing at seeding: no floors, nobody to eliminate,
|
||||
// so there is nothing for a recalculation to pick up.
|
||||
vi.mocked(applyBracketEntryFloors).mockResolvedValue(0);
|
||||
setSeason([]);
|
||||
|
||||
await run(generateRequest());
|
||||
|
||||
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
/**
|
||||
* reprocess-bracket rebuilds a bracket's placements from scratch. What it wipes first
|
||||
* decides whether the clear-bracket → regenerate → reprocess repair path actually works,
|
||||
* and whether it takes the rest of the season's placements down with it.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||||
import {
|
||||
deleteParticipantResultsBySportsSeasonId,
|
||||
deleteParticipantResultsForParticipants,
|
||||
setParticipantResult,
|
||||
} from "~/models/participant-result";
|
||||
import {
|
||||
applyBracketEntryFloors,
|
||||
processMatchResult,
|
||||
processQualifyingBracketEvent,
|
||||
recalculateAffectedLeagues,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { getScoringEventById } from "~/models/scoring-event";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
||||
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(),
|
||||
}));
|
||||
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
deleteParticipantResultsBySportsSeasonId: vi.fn(),
|
||||
deleteParticipantResultsForParticipants: vi.fn(),
|
||||
setParticipantResult: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
applyBracketEntryFloors: vi.fn(),
|
||||
processMatchResult: vi.fn(),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
processQualifyingBracketEvent: vi.fn(),
|
||||
finalizeQualifyingPoints: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findParticipantsBySportsSeasonId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/sports-season", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findSportsSeasonById: vi.fn(),
|
||||
}));
|
||||
|
||||
const params = { id: "season-1", eventId: "event-1" };
|
||||
|
||||
const EVENT = {
|
||||
id: "event-1",
|
||||
name: "AFL Finals",
|
||||
sportsSeasonId: "season-1",
|
||||
isQualifyingEvent: false,
|
||||
isPrimary: false,
|
||||
tournamentId: null,
|
||||
bracketTemplateId: "afl_10",
|
||||
};
|
||||
|
||||
function reprocessRequest(): Request {
|
||||
const body = new FormData();
|
||||
body.set("intent", "reprocess-bracket");
|
||||
return new Request("http://localhost/reprocess", { method: "POST", body });
|
||||
}
|
||||
|
||||
/** A seeded, unplayed bracket slot. */
|
||||
function slot(matchNumber: number, participant1Id: string, participant2Id: string) {
|
||||
return {
|
||||
id: `m-${matchNumber}`,
|
||||
round: "Qualifying Finals",
|
||||
matchNumber,
|
||||
participant1Id,
|
||||
participant2Id,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
isComplete: false,
|
||||
isScoring: true,
|
||||
};
|
||||
}
|
||||
|
||||
const run = (request: Request) =>
|
||||
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
||||
error?: string;
|
||||
success?: string;
|
||||
}>)({ request, params });
|
||||
|
||||
function setEvent(overrides: Partial<typeof EVENT> = {}) {
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(
|
||||
{ ...EVENT, ...overrides } as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
||||
);
|
||||
}
|
||||
|
||||
function setMatches(matches: ReturnType<typeof slot>[]) {
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
|
||||
matches as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
|
||||
);
|
||||
}
|
||||
|
||||
describe("reprocess-bracket", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setEvent();
|
||||
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
|
||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
|
||||
[] as unknown as Awaited<ReturnType<typeof findParticipantsBySportsSeasonId>>
|
||||
);
|
||||
vi.mocked(setParticipantResult).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
|
||||
);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
||||
);
|
||||
vi.mocked(processMatchResult).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof processMatchResult>>
|
||||
);
|
||||
vi.mocked(processQualifyingBracketEvent).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof processQualifyingBracketEvent>>
|
||||
);
|
||||
vi.mocked(findSportsSeasonById).mockResolvedValue(
|
||||
{ qualifyingPointsFinalized: false } as unknown as Awaited<
|
||||
ReturnType<typeof findSportsSeasonById>
|
||||
>
|
||||
);
|
||||
});
|
||||
|
||||
it("clears placements even when no match has been played", async () => {
|
||||
// The clear-bracket → regenerate → reprocess repair path lands here: the freshly
|
||||
// re-seeded bracket has nothing completed, yet the discarded bracket's finalized
|
||||
// placements are exactly what has to go. Skipping the wipe leaves them permanently,
|
||||
// because upsertParticipantResult refuses to un-finalize a result.
|
||||
setMatches([slot(1, "p1", "p2"), slot(2, "p3", "p4")]);
|
||||
|
||||
const result = await run(reprocessRequest());
|
||||
|
||||
expect(result.success).toBeDefined();
|
||||
expect(deleteParticipantResultsForParticipants).toHaveBeenCalledTimes(1);
|
||||
const [sportsSeasonId, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||
expect(sportsSeasonId).toBe("season-1");
|
||||
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3", "p4"]);
|
||||
});
|
||||
|
||||
it("scopes the wipe to this bracket, never the whole season", async () => {
|
||||
// A season-wide delete would take every other event's placements with it, with only
|
||||
// this bracket's replay able to rebuild them.
|
||||
setMatches([slot(1, "p1", "p2")]);
|
||||
|
||||
await run(reprocessRequest());
|
||||
|
||||
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||||
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||
expect(ids).not.toContain("p3");
|
||||
});
|
||||
|
||||
it("passes each participant once when a team appears in more than one slot", async () => {
|
||||
setMatches([slot(1, "p1", "p2"), slot(2, "p1", "p3")]);
|
||||
|
||||
await run(reprocessRequest());
|
||||
|
||||
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||
expect(ids).toHaveLength(3);
|
||||
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3"]);
|
||||
});
|
||||
|
||||
it("skips empty slots rather than passing nulls through", async () => {
|
||||
setMatches([
|
||||
{ ...slot(1, "p1", "p2"), participant2Id: null as unknown as string },
|
||||
]);
|
||||
|
||||
await run(reprocessRequest());
|
||||
|
||||
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||
expect(ids).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("still takes the season-wide delete for a qualifying event", async () => {
|
||||
// Qualifying seasons have no legitimate per-major fantasy placements — those come
|
||||
// from finalizeQualifyingPoints across all majors — so that path wipes the season
|
||||
// on purpose and rebuilds QP from the bracket.
|
||||
setEvent({ isQualifyingEvent: true });
|
||||
setMatches([slot(1, "p1", "p2")]);
|
||||
|
||||
await run(reprocessRequest());
|
||||
|
||||
expect(deleteParticipantResultsBySportsSeasonId).toHaveBeenCalledWith("season-1", {});
|
||||
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an event with no bracket rather than wiping anything", async () => {
|
||||
setMatches([]);
|
||||
|
||||
const result = await run(reprocessRequest());
|
||||
|
||||
expect(result.error).toContain("No bracket to reprocess");
|
||||
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
|
||||
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
/**
|
||||
* The Fix Semi-Final Pairings admin action.
|
||||
*
|
||||
* Elimination Final n feeds Semi-Final n, but brackets advanced before that was fixed
|
||||
* crossed the two winners, and nothing re-runs advancement — a completed match cannot be
|
||||
* re-submitted from the UI.
|
||||
*
|
||||
* It moves qualifier slots only — no scoring runs, so nothing reaches Discord.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { reseedAflSemiFinals } from "~/models/playoff-match";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { getScoringEventById } from "~/models/scoring-event";
|
||||
import { processMatchResult, recalculateAffectedLeagues } from "~/models/scoring-calculator";
|
||||
import { sendDiscordWebhook } from "~/services/discord";
|
||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
getScoringEventById: vi.fn(),
|
||||
isReadOnlySibling: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
reseedAflSemiFinals: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findParticipantsBySportsSeasonId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
processMatchResult: vi.fn(),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/services/discord", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
sendDiscordWebhook: vi.fn(),
|
||||
}));
|
||||
|
||||
const params = { id: "season-1", eventId: "event-1" };
|
||||
|
||||
const EVENT = {
|
||||
id: "event-1",
|
||||
name: "AFL Finals",
|
||||
sportsSeasonId: "season-1",
|
||||
isQualifyingEvent: false,
|
||||
bracketTemplateId: "afl_10",
|
||||
};
|
||||
|
||||
function request() {
|
||||
const body = new FormData();
|
||||
body.set("intent", "reseed-afl-semifinals");
|
||||
return new Request("http://localhost/bracket", { method: "POST", body });
|
||||
}
|
||||
|
||||
const run = () => action({ request: request(), params } as never);
|
||||
|
||||
describe("reseed-afl-semifinals", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never);
|
||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
|
||||
{ id: "geelong", name: "Geelong Cats" },
|
||||
{ id: "adelaide", name: "Adelaide Crows" },
|
||||
] as never);
|
||||
});
|
||||
|
||||
it("names the teams that moved", async () => {
|
||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
||||
vacated: [1, 2],
|
||||
filled: [
|
||||
{ matchNumber: 2, participantId: "adelaide" },
|
||||
{ matchNumber: 1, participantId: "geelong" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(reseedAflSemiFinals).toHaveBeenCalledWith("event-1");
|
||||
expect(result).toEqual({
|
||||
success:
|
||||
"Re-seeded the Semi-Finals: match 1 now hosts Geelong Cats, " +
|
||||
"match 2 now hosts Adelaide Crows.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a slot that was emptied without being refilled", async () => {
|
||||
// Un-recording an Elimination Final result takes its winner back out of the semi.
|
||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
||||
vacated: [1, 2],
|
||||
filled: [{ matchNumber: 2, participantId: "adelaide" }],
|
||||
});
|
||||
|
||||
expect(await run()).toEqual({
|
||||
success:
|
||||
"Re-seeded the Semi-Finals: match 1 is back to TBD, " +
|
||||
"match 2 now hosts Adelaide Crows.",
|
||||
});
|
||||
});
|
||||
|
||||
it("says so when the pairings are already right", async () => {
|
||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({ vacated: [], filled: [] });
|
||||
|
||||
expect(await run()).toEqual({
|
||||
success: "Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("scores nothing and announces nothing", async () => {
|
||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
||||
vacated: [1, 2],
|
||||
filled: [{ matchNumber: 1, participantId: "geelong" }],
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
expect(processMatchResult).not.toHaveBeenCalled();
|
||||
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
|
||||
expect(sendDiscordWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a bracket that is not an AFL finals bracket", async () => {
|
||||
vi.mocked(getScoringEventById).mockResolvedValue({
|
||||
...EVENT,
|
||||
bracketTemplateId: "nfl_14",
|
||||
} as never);
|
||||
|
||||
expect(await run()).toEqual({
|
||||
error: "This action only applies to AFL finals brackets",
|
||||
});
|
||||
expect(reseedAflSemiFinals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a refusal to re-seed a game that has been played", async () => {
|
||||
vi.mocked(reseedAflSemiFinals).mockRejectedValue(
|
||||
new Error("Semi-Finals match 1 already has a recorded result")
|
||||
);
|
||||
|
||||
expect(await run()).toEqual({
|
||||
error: "Semi-Finals match 1 already has a recorded result",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
/**
|
||||
* The Re-seed Wildcard Winners admin action.
|
||||
*
|
||||
* Advancement pairs the Wildcard winners with 5th and 6th by ladder position on every
|
||||
* result, so this action exists for brackets advanced before that rule: their winners sit
|
||||
* in the wrong Elimination Finals and nothing re-runs advancement, because a completed
|
||||
* match cannot be re-submitted from the UI.
|
||||
*
|
||||
* It moves qualifier slots only — no scoring runs, so nothing reaches Discord.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { reseedAflEliminationFinals } from "~/models/playoff-match";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { getScoringEventById } from "~/models/scoring-event";
|
||||
import { processMatchResult, recalculateAffectedLeagues } from "~/models/scoring-calculator";
|
||||
import { sendDiscordWebhook } from "~/services/discord";
|
||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
getScoringEventById: vi.fn(),
|
||||
isReadOnlySibling: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
reseedAflEliminationFinals: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findParticipantsBySportsSeasonId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
processMatchResult: vi.fn(),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/services/discord", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
sendDiscordWebhook: vi.fn(),
|
||||
}));
|
||||
|
||||
const params = { id: "season-1", eventId: "event-1" };
|
||||
|
||||
const EVENT = {
|
||||
id: "event-1",
|
||||
name: "AFL Finals",
|
||||
sportsSeasonId: "season-1",
|
||||
isQualifyingEvent: false,
|
||||
bracketTemplateId: "afl_10",
|
||||
};
|
||||
|
||||
function request() {
|
||||
const body = new FormData();
|
||||
body.set("intent", "reseed-afl-wildcard");
|
||||
return new Request("http://localhost/bracket", { method: "POST", body });
|
||||
}
|
||||
|
||||
const run = () => action({ request: request(), params } as never);
|
||||
|
||||
describe("reseed-afl-wildcard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never);
|
||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
|
||||
{ id: "carlton", name: "Carlton Blues" },
|
||||
{ id: "bulldogs", name: "Western Bulldogs" },
|
||||
] as never);
|
||||
});
|
||||
|
||||
it("names the teams that moved", async () => {
|
||||
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({
|
||||
vacated: [1, 2],
|
||||
filled: [
|
||||
{ matchNumber: 2, participantId: "bulldogs" },
|
||||
{ matchNumber: 1, participantId: "carlton" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(reseedAflEliminationFinals).toHaveBeenCalledWith("event-1");
|
||||
expect(result).toEqual({
|
||||
success:
|
||||
"Re-seeded the Elimination Finals: match 1 now hosts Carlton Blues, " +
|
||||
"match 2 now hosts Western Bulldogs.",
|
||||
});
|
||||
});
|
||||
|
||||
it("says so when the pairings are already right", async () => {
|
||||
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({ vacated: [], filled: [] });
|
||||
|
||||
expect(await run()).toEqual({
|
||||
success: "Elimination Finals already match the Wildcard results — nothing to re-seed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("scores nothing and announces nothing", async () => {
|
||||
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({
|
||||
vacated: [1, 2],
|
||||
filled: [{ matchNumber: 1, participantId: "carlton" }],
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
expect(processMatchResult).not.toHaveBeenCalled();
|
||||
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
|
||||
expect(sendDiscordWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a bracket that is not an AFL finals bracket", async () => {
|
||||
vi.mocked(getScoringEventById).mockResolvedValue({
|
||||
...EVENT,
|
||||
bracketTemplateId: "nfl_14",
|
||||
} as never);
|
||||
|
||||
expect(await run()).toEqual({
|
||||
error: "This action only applies to AFL finals brackets",
|
||||
});
|
||||
expect(reseedAflEliminationFinals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a refusal to re-seed a game that has been played", async () => {
|
||||
vi.mocked(reseedAflEliminationFinals).mockRejectedValue(
|
||||
new Error("Elimination Finals match 1 already has a recorded result")
|
||||
);
|
||||
|
||||
expect(await run()).toEqual({
|
||||
error: "Elimination Finals match 1 already has a recorded result",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -31,7 +31,7 @@ import {
|
|||
projectedWinsToElo,
|
||||
} from '~/services/probability-engine';
|
||||
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
||||
import { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from '~/models/simulator';
|
||||
import { getSportsSeasonSimulatorConfig } from '~/models/simulator';
|
||||
|
||||
// Simulator types that use worldRanking in addition to sourceElo
|
||||
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
|
||||
|
|
@ -80,38 +80,13 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||
const simulatorInputs = await getParticipantSimulatorInputs(sportsSeasonId);
|
||||
|
||||
// The projection a participant was actually saved with. Read it back verbatim:
|
||||
// deriving the field from the stored Elo instead (as this page used to) shows the
|
||||
// admin a different number than they typed, because wins → Elo rounds to an
|
||||
// integer Elo and a simulation run then re-resolves that Elo through the input
|
||||
// policy (clamping, and blending in futures odds when a season has them).
|
||||
const projectionsByParticipant = new Map(
|
||||
simulatorInputs.map((input) => [
|
||||
input.participantId,
|
||||
{ projectedWins: input.projectedWins, projectedTablePoints: input.projectedTablePoints },
|
||||
])
|
||||
);
|
||||
|
||||
const existingData: Record<
|
||||
string,
|
||||
{ elo: number | null; ranking: number | null; projectedWins: number | null; projectedTablePoints: number | null }
|
||||
> = {};
|
||||
for (const participant of participants) {
|
||||
const projection = projectionsByParticipant.get(participant.id);
|
||||
existingData[participant.id] = {
|
||||
elo: null,
|
||||
ranking: null,
|
||||
projectedWins: projection?.projectedWins ?? null,
|
||||
projectedTablePoints: projection?.projectedTablePoints ?? null,
|
||||
};
|
||||
}
|
||||
const existingData: Record<string, { elo: number | null; ranking: number | null }> = {};
|
||||
for (const ev of existingEVs) {
|
||||
const existing = existingData[ev.participantId];
|
||||
if (!existing) continue;
|
||||
existing.elo = ev.sourceElo ?? null;
|
||||
existing.ranking = ev.worldRanking ?? null;
|
||||
existingData[ev.participantId] = {
|
||||
elo: ev.sourceElo ?? null,
|
||||
ranking: ev.worldRanking ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
||||
|
|
@ -277,16 +252,7 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
if (simulatorConfig) {
|
||||
participants.forEach(p => {
|
||||
const d = existingData[p.id];
|
||||
// A stored projection is shown exactly as it was entered. Only fall back to
|
||||
// deriving it from the Elo when this season has no projection saved (a
|
||||
// season that has only ever had Elos entered still gets a useful starting
|
||||
// point) — that derived value is lossy and must never overwrite a real one.
|
||||
const stored = simulatorConfig.projectionInput === 'tablePoints'
|
||||
? d?.projectedTablePoints
|
||||
: d?.projectedWins;
|
||||
if (stored !== null && stored !== undefined) {
|
||||
initial[p.id] = stored.toString();
|
||||
} else if (d?.elo !== null && d?.elo !== undefined) {
|
||||
if (d?.elo !== null && d?.elo !== undefined) {
|
||||
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||
: eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||
|
|
@ -299,8 +265,8 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
|
||||
const [bulkText, setBulkText] = useState('');
|
||||
const [parseResults, setParseResults] = useState<{
|
||||
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }>;
|
||||
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }>;
|
||||
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>;
|
||||
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }>;
|
||||
} | null>(null);
|
||||
|
||||
function findParticipantMatch(inputName: string) {
|
||||
|
|
@ -325,8 +291,8 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
|
||||
function parseBulkText() {
|
||||
const lines = bulkText.split('\n');
|
||||
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }> = [];
|
||||
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }> = [];
|
||||
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = [];
|
||||
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
|
|
@ -349,9 +315,9 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
const participant = findParticipantMatch(inputName);
|
||||
if (participant && !seen.has(participant.id)) {
|
||||
seen.add(participant.id);
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, projection: projectedWins, inputName });
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, inputName });
|
||||
} else if (!participant) {
|
||||
unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
|
||||
unmatched.push({ inputName, elo, ranking: null });
|
||||
}
|
||||
} else {
|
||||
const match = usesRanking
|
||||
|
|
@ -376,9 +342,9 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
const participant = findParticipantMatch(inputName);
|
||||
if (participant && !seen.has(participant.id)) {
|
||||
seen.add(participant.id);
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, projection: null, inputName });
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName });
|
||||
} else if (!participant) {
|
||||
unmatched.push({ inputName, elo, ranking, projection: null });
|
||||
unmatched.push({ inputName, elo, ranking });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -394,11 +360,11 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
for (const m of parseResults.matched) {
|
||||
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
|
||||
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
||||
// The pasted number goes in as typed. Round-tripping it through the derived
|
||||
// Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
|
||||
// came back as 95.1 before anything was even saved.
|
||||
if (inputMode === 'projectedWins' && m.projection !== null) {
|
||||
newWins[m.participantId] = m.projection.toString();
|
||||
if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) {
|
||||
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||
).toFixed(1);
|
||||
}
|
||||
}
|
||||
setEloValues(newElos);
|
||||
|
|
@ -523,10 +489,7 @@ Mark Selby, 2432`
|
|||
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
||||
<span className="text-muted-foreground">{m.inputName}</span>
|
||||
<span className="font-medium">
|
||||
{m.name} →{' '}
|
||||
{m.projection !== null
|
||||
? `${m.projection} ${projectionUnit} (Elo ${m.elo})`
|
||||
: m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
||||
{m.name} → {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
||||
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -546,9 +509,7 @@ Mark Selby, 2432`
|
|||
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
||||
<span>{u.inputName}</span>
|
||||
<span className="font-medium">
|
||||
{u.projection !== null
|
||||
? `${u.projection} ${projectionUnit} (Elo ${u.elo})`
|
||||
: u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
||||
{u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
||||
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -579,7 +540,7 @@ Mark Selby, 2432`
|
|||
</CardTitle>
|
||||
<CardDescription>
|
||||
{inputMode === 'projectedWins'
|
||||
? `Enter each team's projected total season ${projectionUnit} — the number you enter is stored as-is and re-derives the Elo on every run. Mid-season it is treated as a projected final total, so the simulation spreads the difference over the games still to play. Saving will run the simulation and update expected values.`
|
||||
? `Enter each team's projected total season ${projectionUnit}. Converted to Elo automatically. Saving will run the simulation and update expected values.`
|
||||
: usesRanking
|
||||
? `Enter each ${participantLabel.toLowerCase()}'s Elo${allowsRankOnly ? ' (optional)' : ''} and ${rankLabel}. Saving will automatically run the simulation and update expected values.`
|
||||
: `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`}
|
||||
|
|
|
|||
|
|
@ -9,15 +9,12 @@ import {
|
|||
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
|
||||
import {
|
||||
findPlayoffMatchesByEventId,
|
||||
deletePlayoffMatchesByEventId,
|
||||
generateBracketFromTemplate,
|
||||
setMatchWinner,
|
||||
advanceWinnerTemplate,
|
||||
findPlayoffMatchById,
|
||||
assignParticipantsToKnockout,
|
||||
doesLoserAdvance,
|
||||
reseedAflEliminationFinals,
|
||||
reseedAflSemiFinals,
|
||||
} from "~/models/playoff-match";
|
||||
import {
|
||||
createGame,
|
||||
|
|
@ -38,7 +35,6 @@ import {
|
|||
recalculateAffectedLeagues,
|
||||
recalculateStandings,
|
||||
autoCompleteRoundIfDone,
|
||||
applyBracketEntryFloors,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||
|
|
@ -46,7 +42,6 @@ import {
|
|||
setParticipantResult,
|
||||
findParticipantResultsBySportsSeasonId,
|
||||
deleteParticipantResultsBySportsSeasonId,
|
||||
deleteParticipantResultsForParticipants,
|
||||
} from "~/models/participant-result";
|
||||
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
|
|
@ -174,7 +169,7 @@ async function scoreQualifyingBracket(
|
|||
/**
|
||||
* Mark the given participants as eliminated (finalPosition = 0) and, for fantasy
|
||||
* (non-qualifying) events, announce the teams newly eliminated by this run to the
|
||||
* affected leagues' Discord channels.
|
||||
* affected leagues' Discord channels. Returns the number of participants marked.
|
||||
*
|
||||
* "Newly eliminated" = participants with no prior result row, so re-running a
|
||||
* generation step never re-announces the same teams. The announcement is a
|
||||
|
|
@ -182,18 +177,11 @@ async function scoreQualifyingBracket(
|
|||
* the eliminations themselves are already committed. eventId is deliberately
|
||||
* omitted from the recalc call so the announcement doesn't pull in unrelated
|
||||
* completed matches as "Scored Matches".
|
||||
*
|
||||
* Returns the number of participants marked alongside whether a standings recalculation
|
||||
* actually ran. The caller banks entry floors before calling this and needs them to
|
||||
* reach teamStandings.totalPoints; it cannot infer that from the participant count,
|
||||
* because the recalc is skipped for qualifying events, when every eliminated team
|
||||
* already had a result row (the second run of a generation), and when the announcement
|
||||
* threw. `recalculated` reports the fact rather than making the caller re-derive it.
|
||||
*/
|
||||
async function markEliminatedAndAnnounce(
|
||||
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
|
||||
participantIds: string[]
|
||||
): Promise<{ markedCount: number; recalculated: boolean }> {
|
||||
): Promise<number> {
|
||||
const existingResults = await findParticipantResultsBySportsSeasonId(event.sportsSeasonId);
|
||||
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
|
||||
const newlyEliminatedIds = participantIds.filter((id) => !alreadyHadResult.has(id));
|
||||
|
|
@ -202,8 +190,6 @@ async function markEliminatedAndAnnounce(
|
|||
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
||||
}
|
||||
|
||||
let recalculated = false;
|
||||
|
||||
// QPs (e.g. tennis/CS2 majors) don't get elimination announcements.
|
||||
if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) {
|
||||
try {
|
||||
|
|
@ -211,13 +197,12 @@ async function markEliminatedAndAnnounce(
|
|||
eventName: event.name ?? undefined,
|
||||
eliminatedParticipantIds: newlyEliminatedIds,
|
||||
});
|
||||
recalculated = true;
|
||||
} catch (err) {
|
||||
logger.error("[Eliminations] Discord announcement failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return { markedCount: participantIds.length, recalculated };
|
||||
return participantIds.length;
|
||||
}
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
|
|
@ -303,50 +288,6 @@ 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");
|
||||
|
||||
|
|
@ -410,24 +351,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
try {
|
||||
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
||||
|
||||
// The template ID has to land on the event before entry floors can be derived
|
||||
// (getBracketEntryFloor reads it), so persist it here rather than after the
|
||||
// elimination pass below.
|
||||
await updateScoringEvent(params.eventId, {
|
||||
bracketTemplateId: templateId,
|
||||
scoringStartsAtRound: template.scoringStartsAtRound,
|
||||
bracketRegionConfig: regionOverride,
|
||||
});
|
||||
|
||||
// Some seedings guarantee points before a ball is bounced — an AFL top-4 seed
|
||||
// has the double chance, so the 5th-6th tier is locked in at generation. Bank
|
||||
// those provisional floors now, ahead of the elimination announcement below so
|
||||
// the standings it posts already reflect them.
|
||||
const entryFloorCount = await applyBracketEntryFloors(params.eventId);
|
||||
if (entryFloorCount > 0) {
|
||||
logger.log(`[BracketGeneration] Applied entry floors to ${entryFloorCount} participant(s)`);
|
||||
}
|
||||
|
||||
// PHASE 5.3: Mark participants NOT in the bracket as eliminated (and announce).
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (event) {
|
||||
|
|
@ -436,24 +359,17 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const toEliminate = allParticipants
|
||||
.filter((p) => !participantsInBracket.has(p.id))
|
||||
.map((p) => p.id);
|
||||
const { markedCount, recalculated } = await markEliminatedAndAnnounce(event, toEliminate);
|
||||
logger.log(`[BracketGeneration] Marked ${markedCount} participants as eliminated`);
|
||||
|
||||
// The floors banked above only reach teamStandings.totalPoints via a recalc, and
|
||||
// markEliminatedAndAnnounce runs one for its announcement in some cases but not
|
||||
// others: not for a qualifying event, not when every eliminated team already had
|
||||
// a result row (the second run of a generation, since the first wrote 0 for all
|
||||
// of them), not when there was nobody to eliminate, and not when the announcement
|
||||
// threw. Drive off what it reports rather than re-deriving it from toEliminate.
|
||||
// skipDiscord: seeding floors are not a result to announce.
|
||||
if (entryFloorCount > 0 && !recalculated) {
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
|
||||
eventName: event.name ?? undefined,
|
||||
skipDiscord: true,
|
||||
});
|
||||
}
|
||||
const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate);
|
||||
logger.log(`[BracketGeneration] Marked ${eliminatedCount} participants as eliminated`);
|
||||
}
|
||||
|
||||
// Update the event to store the template ID, scoring start round, and region config
|
||||
await updateScoringEvent(params.eventId, {
|
||||
bracketTemplateId: templateId,
|
||||
scoringStartsAtRound: template.scoringStartsAtRound,
|
||||
bracketRegionConfig: regionOverride,
|
||||
});
|
||||
|
||||
return { success: "Bracket generated successfully" };
|
||||
} catch (error) {
|
||||
logger.error("Error generating bracket:", error);
|
||||
|
|
@ -868,101 +784,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
// Re-seed the AFL Wildcard winners into the Elimination Finals they belong in.
|
||||
// Advancement does this on every Wildcard result, so this is only needed for a
|
||||
// bracket advanced before that rule existed: the winners sit in the wrong games and
|
||||
// no admin action re-runs advancement (a completed match cannot be re-submitted).
|
||||
if (intent === "reseed-afl-wildcard") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (!event) return { error: "Event not found" };
|
||||
if (event.bracketTemplateId !== "afl_10") {
|
||||
return { error: "This action only applies to AFL finals brackets" };
|
||||
}
|
||||
|
||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
const reseed = await reseedAflEliminationFinals(params.eventId);
|
||||
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
|
||||
return {
|
||||
success:
|
||||
"Elimination Finals already match the Wildcard results — nothing to re-seed.",
|
||||
};
|
||||
}
|
||||
|
||||
// Only the qualifier slots move, so there is nothing to re-score: no placement,
|
||||
// score or elimination changes, and so nothing to announce.
|
||||
const moves = reseed.filled
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
.map((slot) => `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`)
|
||||
.join(", ");
|
||||
|
||||
return {
|
||||
success: `Re-seeded the Elimination Finals: ${moves}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error re-seeding AFL Wildcard winners:", error);
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to re-seed the Elimination Finals",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Put the Elimination Final winners in the Semi-Finals they belong in. Elimination
|
||||
// Final n feeds Semi-Final n, but brackets advanced before that was fixed crossed the
|
||||
// two winners, and no admin action re-runs advancement (a completed match cannot be
|
||||
// re-submitted).
|
||||
if (intent === "reseed-afl-semifinals") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (!event) return { error: "Event not found" };
|
||||
if (event.bracketTemplateId !== "afl_10") {
|
||||
return { error: "This action only applies to AFL finals brackets" };
|
||||
}
|
||||
|
||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
const reseed = await reseedAflSemiFinals(params.eventId);
|
||||
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
|
||||
return {
|
||||
success:
|
||||
"Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
|
||||
};
|
||||
}
|
||||
|
||||
// Only the qualifier slots move, so there is nothing to re-score: no placement,
|
||||
// score or elimination changes, and so nothing to announce.
|
||||
//
|
||||
// A slot can be vacated without being refilled — un-recording an Elimination Final
|
||||
// result takes its winner back out — so report those too rather than rendering an
|
||||
// empty list.
|
||||
const filled = reseed.filled.map((slot) => ({
|
||||
matchNumber: slot.matchNumber,
|
||||
text: `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`,
|
||||
}));
|
||||
const emptied = reseed.vacated
|
||||
.filter((matchNumber) => !reseed.filled.some((slot) => slot.matchNumber === matchNumber))
|
||||
.map((matchNumber) => ({ matchNumber, text: `match ${matchNumber} is back to TBD` }));
|
||||
const moves = [...filled, ...emptied]
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
.map((move) => move.text)
|
||||
.join(", ");
|
||||
|
||||
return {
|
||||
success: `Re-seeded the Semi-Finals: ${moves}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error re-seeding AFL Elimination Finals winners:", error);
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to re-seed the Semi-Finals",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "reprocess-bracket") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
|
|
@ -1017,40 +838,15 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { success: `${baseMessage} No mirror windows to sync.` };
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { error: "No bracket to reprocess" };
|
||||
if (completed.length === 0) {
|
||||
return { error: "No completed matches to reprocess" };
|
||||
}
|
||||
|
||||
// Wipe this bracket's participants' results and rebuild from scratch. Deleting
|
||||
// only the partial rows would leave stale finalized ones, which the "never
|
||||
// un-finalize" guard in upsertParticipantResult then refuses to correct.
|
||||
//
|
||||
// Scoped to the participants this bracket actually holds, not the whole season:
|
||||
// seasonParticipantResults is keyed by sports season, not by event, so a
|
||||
// season-wide delete takes every other event's placements with it and only this
|
||||
// bracket's replay could rebuild them (the hazard clear-bracket documents).
|
||||
//
|
||||
// Unconditional, because zero completed matches is precisely the clear-bracket →
|
||||
// regenerate → reprocess repair path: the discarded bracket's finalized
|
||||
// placements are exactly what needs clearing, and there is always something to
|
||||
// rebuild from — the entry floors below, then the replay.
|
||||
// Delete ALL results for this sports season and rebuild from scratch.
|
||||
// Only deleting partial rows leaves stale finalized rows that block
|
||||
// the "never un-finalize" guard in upsertParticipantResult.
|
||||
const db = database();
|
||||
// Reused further down to decide who is *not* in the bracket and so eliminated.
|
||||
const bracketParticipantIds = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
|
||||
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
|
||||
}
|
||||
await deleteParticipantResultsForParticipants(
|
||||
event.sportsSeasonId,
|
||||
[...bracketParticipantIds],
|
||||
db
|
||||
);
|
||||
|
||||
// Re-bank the seeding-derived floors the delete above wipes (e.g. the AFL
|
||||
// top-4's 5th-6th tier). Done before the replay so real match results overwrite
|
||||
// them; a bracket with no completed matches still gets its guaranteed points.
|
||||
const entryFloorCount = await applyBracketEntryFloors(params.eventId, db);
|
||||
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
|
||||
|
||||
// Replay each completed match in bracket order (earlier rounds first).
|
||||
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||
|
|
@ -1091,6 +887,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
|
||||
// This covers teams that didn't make the playoffs/play-in tournament.
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const bracketParticipantIds = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
|
||||
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
|
||||
}
|
||||
let eliminatedCount = 0;
|
||||
for (const participant of allParticipants) {
|
||||
if (!bracketParticipantIds.has(participant.id)) {
|
||||
|
|
@ -1106,12 +907,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement.
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
|
||||
|
||||
return {
|
||||
success:
|
||||
`Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ` +
|
||||
`${entryFloorCount} seeded participant(s) given their guaranteed entry floor, ` +
|
||||
`${eliminatedCount} non-bracket participant(s) eliminated`,
|
||||
};
|
||||
return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` };
|
||||
} catch (error) {
|
||||
logger.error("Error reprocessing bracket:", error);
|
||||
return {
|
||||
|
|
@ -1296,10 +1092,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const toEliminate = allParticipants
|
||||
.filter((p) => !uniqueParticipants.has(p.id))
|
||||
.map((p) => p.id);
|
||||
const { markedCount: eliminatedCount } = await markEliminatedAndAnnounce(
|
||||
groupsEvent,
|
||||
toEliminate
|
||||
);
|
||||
const eliminatedCount = await markEliminatedAndAnnounce(groupsEvent, toEliminate);
|
||||
|
||||
return {
|
||||
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
||||
|
|
|
|||
|
|
@ -613,110 +613,6 @@ export default function EventBracket({
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Re-seed AFL Wildcard winners. Advancement pairs them by ladder position on
|
||||
every Wildcard result, so this is only for a bracket advanced before that
|
||||
rule existed — a completed match cannot be re-submitted to re-run it. */}
|
||||
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Re-seed Wildcard Winners</CardTitle>
|
||||
<CardDescription>
|
||||
Pair the Elimination Finals by ladder position: 5th hosts the
|
||||
lower-ranked Wildcard winner and 6th the higher-ranked one. Only moves
|
||||
the qualifier slots — no results, scores or placements change, and
|
||||
nothing is announced. Does nothing if the pairings are already right.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="reseed-afl-wildcard" />
|
||||
<Button type="submit" variant="outline">
|
||||
Re-seed Wildcard Winners
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Fix the Semi-Final pairings. Elimination Final n feeds Semi-Final n, but
|
||||
brackets advanced before that was fixed crossed the two winners, and no
|
||||
admin action re-runs advancement. */}
|
||||
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fix Semi-Final Pairings</CardTitle>
|
||||
<CardDescription>
|
||||
Feed each Elimination Final into the Semi-Final it belongs to: EF1
|
||||
winner into SF1 and EF2 winner into SF2. Only moves the qualifier slots
|
||||
— no results, scores or placements change, and nothing is announced.
|
||||
Does nothing if the pairings are already right.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="reseed-afl-semifinals" />
|
||||
<Button type="submit" variant="outline">
|
||||
Fix Semi-Final Pairings
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</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>
|
||||
|
|
@ -993,7 +889,7 @@ export default function EventBracket({
|
|||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Label className="w-28 text-sm text-muted-foreground shrink-0">
|
||||
<Label className="w-20 text-sm text-muted-foreground shrink-0">
|
||||
{slotLabel}
|
||||
</Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
batchUpsertParticipantEVs,
|
||||
getAllParticipantEVsForSeason
|
||||
} from "~/models/participant-expected-value";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
|
|
@ -28,6 +27,17 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
};
|
||||
}
|
||||
|
||||
const scoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 45,
|
||||
pointsFor4th: 45,
|
||||
pointsFor5th: 20,
|
||||
pointsFor6th: 20,
|
||||
pointsFor7th: 20,
|
||||
pointsFor8th: 20,
|
||||
};
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
|
||||
|
|
@ -48,7 +58,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
probSeventh: parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100,
|
||||
probEighth: parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100,
|
||||
},
|
||||
scoringRules: DEFAULT_SCORING_RULES,
|
||||
scoringRules,
|
||||
source: "manual" as const,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ import {
|
|||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { ArrowLeft, Calculator } from "lucide-react";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
import { calculateEV } from "~/services/ev-calculator";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Expected Values — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
|
|
@ -28,18 +26,9 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|||
|
||||
export { loader };
|
||||
|
||||
// EV is shown on the same reference scale the runner persists it with: a sports season
|
||||
// is shared across leagues with different scoring, so DEFAULT_SCORING_RULES is the
|
||||
// common scale and each league re-derives its own EV from the stored probabilities
|
||||
// (see getPersistenceContext in services/simulations/runner.ts).
|
||||
//
|
||||
// Scoring: 1st=100, 2nd=70, 3rd=50, 4th=40, 5th/6th=25 each, 7th/8th=15 each.
|
||||
// Sum = 100+70+50+40+25+25+15+15 = 340.
|
||||
//
|
||||
// The 5th–8th values must stay distinct rather than collapsing to a flat 20: templates
|
||||
// that split that zone into two tiers (llws_20, afl_10) put a team locked into 5th–6th
|
||||
// at probFifth=probSixth=0.5 (EV 25) and one locked into 7th–8th at
|
||||
// probSeventh=probEighth=0.5 (EV 15). A flat table reports both as 20.
|
||||
// DEFAULT scoring values — must match DEFAULT_SCORING_RULES in the simulate route.
|
||||
// Scoring: 1st=100, 2nd=70, 3rd/4th (FF losers)=45 each, 5th–8th (E8 losers)=20 each.
|
||||
// Sum = 100+70+45+45+20+20+20+20 = 340.
|
||||
//
|
||||
// Total EV invariant: Σ EV across all participants = Σ scoring values = 340,
|
||||
// because each probability column sums to 1.0 across all participants.
|
||||
|
|
@ -47,23 +36,20 @@ export { loader };
|
|||
// 1. Stale EV records from a prior simulation run (fix: re-run simulation, which now
|
||||
// zeros non-bracket participants automatically)
|
||||
// 2. DB precision truncation (numeric(6,4) = 4dp; max drift ≈ ±1 for 68 teams)
|
||||
export function evFromProbs(ev: {
|
||||
const SCORING = [100, 70, 45, 45, 20, 20, 20, 20] as const;
|
||||
|
||||
function evFromProbs(ev: {
|
||||
probFirst: string; probSecond: string; probThird: string; probFourth: string;
|
||||
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
|
||||
}): number {
|
||||
return calculateEV(
|
||||
{
|
||||
probFirst: parseFloat(ev.probFirst),
|
||||
probSecond: parseFloat(ev.probSecond),
|
||||
probThird: parseFloat(ev.probThird),
|
||||
probFourth: parseFloat(ev.probFourth),
|
||||
probFifth: parseFloat(ev.probFifth),
|
||||
probSixth: parseFloat(ev.probSixth),
|
||||
probSeventh: parseFloat(ev.probSeventh),
|
||||
probEighth: parseFloat(ev.probEighth),
|
||||
},
|
||||
DEFAULT_SCORING_RULES
|
||||
);
|
||||
return parseFloat(ev.probFirst) * SCORING[0]
|
||||
+ parseFloat(ev.probSecond) * SCORING[1]
|
||||
+ parseFloat(ev.probThird) * SCORING[2]
|
||||
+ parseFloat(ev.probFourth) * SCORING[3]
|
||||
+ parseFloat(ev.probFifth) * SCORING[4]
|
||||
+ parseFloat(ev.probSixth) * SCORING[5]
|
||||
+ parseFloat(ev.probSeventh) * SCORING[6]
|
||||
+ parseFloat(ev.probEighth) * SCORING[7];
|
||||
}
|
||||
|
||||
function fmt(val: string | number) {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ import { batchUpsertParticipantEVs } from '~/models/participant-expected-value';
|
|||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';
|
||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||
import { calculateEV } from '~/services/ev-calculator';
|
||||
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
|
||||
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
|
||||
import { recalculateStandings } from '~/models/scoring-calculator';
|
||||
import { database } from '~/database/context';
|
||||
import * as schema from '~/database/schema';
|
||||
|
|
@ -29,6 +28,17 @@ import { useEffect, useRef, useState } from 'react';
|
|||
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
|
||||
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
|
||||
|
||||
const DEFAULT_SCORING_RULES: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 45,
|
||||
pointsFor4th: 45,
|
||||
pointsFor5th: 20,
|
||||
pointsFor6th: 20,
|
||||
pointsFor7th: 20,
|
||||
pointsFor8th: 20,
|
||||
};
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Golf Skills — ${data?.sportsSeason?.name ?? 'Sports Season'} - Brackt Admin` }];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
/**
|
||||
* Pure helpers for the Simulator Setup page, split out so they can be unit tested
|
||||
* without pulling the route's server-only imports into the test.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BaseEloKey,
|
||||
ResolvedRating,
|
||||
ResolvedSourceElo,
|
||||
} from "~/services/simulations/input-policy";
|
||||
|
||||
/**
|
||||
* Short badge text for how a participant's Elo or rating was produced, or null for a
|
||||
* directly entered one — the unremarkable case, which needs no badge.
|
||||
*
|
||||
* The preview table needs this because a generated value is deliberately hidden from
|
||||
* `getParticipantSimulatorInputs`, so without the resolved value plus this label the
|
||||
* row reads as "nothing saved" and a projection losing to a raw Elo is invisible.
|
||||
*
|
||||
* Every remaining method is a missing-input fallback (`fallbackElo`,
|
||||
* `fallbackRating`, `averageKnown`, `worstKnownMinus`, `block`), which all read the
|
||||
* same way to an admin: this participant had nothing usable of its own.
|
||||
*/
|
||||
export function resolvedInputMethodLabel(
|
||||
method: ResolvedSourceElo["method"] | ResolvedRating["method"]
|
||||
): string | null {
|
||||
switch (method) {
|
||||
case "direct":
|
||||
return null;
|
||||
case "projectedWins":
|
||||
case "projectedTablePoints":
|
||||
return "from projections";
|
||||
case "sourceOdds":
|
||||
return "from futures";
|
||||
case "blend":
|
||||
return "blended";
|
||||
default:
|
||||
return "fallback";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method flag for a bulk-input row that carries a projection instead of an Elo, or
|
||||
* undefined when the row says nothing about how its Elo was produced.
|
||||
*
|
||||
* A row supplying a projection but no explicit Elo means "derive the Elo from this
|
||||
* projection". Stamping the flag marks whatever Elo is already stored as generated,
|
||||
* so `getParticipantSimulatorInputs` hides it and `resolveSourceElos` re-derives
|
||||
* from the projection — without it, the non-destructive upsert leaves a stale
|
||||
* hand-entered Elo in place, and that Elo wins the `baseEloPriority` race so the
|
||||
* projection is written to the database and then ignored on every run.
|
||||
*
|
||||
* Returning undefined (rather than an empty object) matters: the upsert only
|
||||
* preserves existing metadata, and clears a stale flag for a fresh direct Elo, when
|
||||
* the incoming metadata is null.
|
||||
*/
|
||||
export function projectionMethodMetadata(
|
||||
sourceElo: number | undefined,
|
||||
projectedWins: number | undefined,
|
||||
projectedTablePoints: number | undefined
|
||||
): Record<string, unknown> | undefined {
|
||||
if (sourceElo !== undefined) return undefined;
|
||||
if (projectedWins !== undefined) return { sourceEloMethod: "projectedWins" };
|
||||
if (projectedTablePoints !== undefined) return { sourceEloMethod: "projectedTablePoints" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the Base Elo Source select into a full `baseEloPriority` list. Only the
|
||||
* head of the list is user-facing (raw Elo vs. projections); the remaining keys keep
|
||||
* their existing relative order so a season that already has a custom ordering is
|
||||
* not silently flattened.
|
||||
*/
|
||||
export function parseBaseEloPriorityChoice(
|
||||
value: FormDataEntryValue | null,
|
||||
current: BaseEloKey[]
|
||||
): BaseEloKey[] {
|
||||
// The select only renders for simulators that can derive Elo from a projection.
|
||||
// When it was not on the form there is no choice to apply, so keep what is stored
|
||||
// rather than silently rewriting the season's ordering.
|
||||
if (value === null) return current;
|
||||
const projections = current.filter((key) => key !== "sourceElo");
|
||||
return value === "projectionsFirst"
|
||||
? [...projections, "sourceElo"]
|
||||
: ["sourceElo", ...projections];
|
||||
}
|
||||
|
|
@ -32,17 +32,10 @@ import {
|
|||
} from "~/services/simulations/manifest";
|
||||
import {
|
||||
getSimulatorInputPolicy,
|
||||
resolveRatings,
|
||||
resolveSourceElos,
|
||||
type MissingEloStrategy,
|
||||
type MissingRatingStrategy,
|
||||
} from "~/services/simulations/input-policy";
|
||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||||
import {
|
||||
parseBaseEloPriorityChoice,
|
||||
projectionMethodMetadata,
|
||||
resolvedInputMethodLabel,
|
||||
} from "./admin.sports-seasons.$id.simulator.helpers";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Simulator Setup - ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
|
|
@ -82,64 +75,13 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
...config.profile.requiredInputs,
|
||||
...config.profile.optionalInputs,
|
||||
]);
|
||||
|
||||
// The Elo each participant will actually run with, and which source produced it.
|
||||
// Without this the preview is misleading: getParticipantSimulatorInputs blanks a
|
||||
// generated Elo (so it is re-derived rather than frozen), which reads as "nothing
|
||||
// saved" — and a raw Elo silently beating a projection is invisible.
|
||||
//
|
||||
// Keyed off `relevantInputs`, not requiredInputs: the preview renders these
|
||||
// columns solely from these maps, so gating on "required" would blank a stored
|
||||
// value for every simulator that treats the input as optional (playoff_bracket
|
||||
// and ncaam_bracket for Elo, golf_qualifying_points for rating).
|
||||
const resolvedEloRows = Object.fromEntries(
|
||||
relevantInputs.has("sourceElo")
|
||||
? [...resolveSourceElos(inputs, config.profile, config.config).values()].map((resolved) => [
|
||||
resolved.participantId,
|
||||
{ sourceElo: resolved.sourceElo, method: resolved.method },
|
||||
])
|
||||
: []
|
||||
);
|
||||
// Same for ratings, which are blanked by the same rule when generated. The
|
||||
// preview's "missing a required input" marker reads both, so it agrees with
|
||||
// readiness instead of flagging every participant a projection resolved.
|
||||
const resolvedRatingRows = Object.fromEntries(
|
||||
relevantInputs.has("rating")
|
||||
? [...resolveRatings(inputs, config.profile, config.config).values()].map((resolved) => [
|
||||
resolved.participantId,
|
||||
{ rating: resolved.rating, method: resolved.method },
|
||||
])
|
||||
: []
|
||||
);
|
||||
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
|
||||
key,
|
||||
label: simulatorInputLabel(key),
|
||||
required: config.profile.requiredInputs.includes(key),
|
||||
}));
|
||||
|
||||
// The projection this simulator can derive Elo from, labelled here for the same
|
||||
// reason as inputColumns: calling simulatorInputLabel from the rendered component
|
||||
// would pull the manifest (and through it the registry and every simulator) into
|
||||
// the client bundle.
|
||||
const projectionEloKey = (config.profile.derivableInputs?.sourceElo ?? []).find(
|
||||
(key) => key === "projectedWins" || key === "projectedTablePoints"
|
||||
);
|
||||
const projectionEloOption = projectionEloKey
|
||||
? { key: projectionEloKey, label: simulatorInputLabel(projectionEloKey) }
|
||||
: null;
|
||||
|
||||
return {
|
||||
sportsSeason,
|
||||
participants,
|
||||
config,
|
||||
inputRows,
|
||||
readiness,
|
||||
inputPolicy,
|
||||
inputColumns,
|
||||
resolvedEloRows,
|
||||
resolvedRatingRows,
|
||||
projectionEloOption,
|
||||
};
|
||||
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy, inputColumns };
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
|
|
@ -182,7 +124,6 @@ const HONORED_ENGINE_KNOBS = new Set([
|
|||
"baseDrawRate",
|
||||
"drawDecay",
|
||||
"ratingScaleFactor",
|
||||
"projectedWinsWeight",
|
||||
]);
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | null {
|
||||
|
|
@ -291,28 +232,17 @@ function parseInputCsv(
|
|||
continue;
|
||||
}
|
||||
|
||||
const sourceElo = parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined;
|
||||
const projectedWins = parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined;
|
||||
const projectedTablePoints = parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined;
|
||||
|
||||
inputs.push({
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
sourceElo,
|
||||
sourceElo: parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined,
|
||||
sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined,
|
||||
worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined,
|
||||
rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined,
|
||||
projectedWins,
|
||||
projectedTablePoints,
|
||||
projectedWins: parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined,
|
||||
projectedTablePoints: parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined,
|
||||
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined,
|
||||
region: cols[indexes.get("region") ?? -1] || undefined,
|
||||
// A row that supplies a projection but no explicit Elo means "derive the Elo
|
||||
// from this projection". Stamping the method flag marks whatever Elo is
|
||||
// already stored as generated, so getParticipantSimulatorInputs hides it and
|
||||
// resolveSourceElos re-derives from the projection instead of letting a stale
|
||||
// Elo win the baseEloPriority race. Mirrors the Elo Ratings page's
|
||||
// projections mode.
|
||||
metadata: projectionMethodMetadata(sourceElo, projectedWins, projectedTablePoints),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -382,7 +312,6 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
|
|||
...currentPolicy,
|
||||
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
||||
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
||||
baseEloPriority: parseBaseEloPriorityChoice(formData.get("baseEloPriority"), currentPolicy.baseEloPriority),
|
||||
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
|
||||
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
|
||||
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
||||
|
|
@ -455,7 +384,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
const isSubmitting = navigation.state === "submitting";
|
||||
const setupSections = config.profile.setupSections;
|
||||
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
|
||||
const projectionsOutrankElo = inputPolicy.baseEloPriority[0] !== "sourceElo";
|
||||
const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
|
||||
const showsInputPolicy =
|
||||
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
|
||||
|
|
@ -469,7 +397,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
|
||||
// Preview columns are resolved server-side in the loader (see note there) and
|
||||
// arrive as plain data, so this client component never imports the manifest.
|
||||
const { inputColumns, resolvedEloRows, resolvedRatingRows, projectionEloOption } = loaderData;
|
||||
const { inputColumns } = loaderData;
|
||||
const requiredInputs = config.profile.requiredInputs;
|
||||
const gridTemplate = `2fr repeat(${Math.max(inputColumns.length, 1)}, 1fr)`;
|
||||
// For this sport the inputs live on a dedicated page, not the shared bulk paste.
|
||||
|
|
@ -481,17 +409,8 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
: null)
|
||||
: null;
|
||||
|
||||
// A required Elo/rating counts as present when the input policy resolves one,
|
||||
// not only when it is stored directly: getParticipantSimulatorInputs deliberately
|
||||
// blanks a generated value so it is re-derived each run, so reading the raw input
|
||||
// alone would mark every projection-configured participant as missing.
|
||||
const isRowIncomplete = (participantId: string, input: (typeof inputRows)[number]["input"]) =>
|
||||
requiredInputs.some((key) => {
|
||||
if (input?.[key] !== null && input?.[key] !== undefined) return false;
|
||||
if (key === "sourceElo") return resolvedEloRows[participantId] === undefined;
|
||||
if (key === "rating") return resolvedRatingRows[participantId] === undefined;
|
||||
return true;
|
||||
});
|
||||
const isRowIncomplete = (input: (typeof inputRows)[number]["input"]) =>
|
||||
requiredInputs.some((key) => input?.[key] === null || input?.[key] === undefined);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [onlyMissing, setOnlyMissing] = useState(false);
|
||||
|
|
@ -501,11 +420,11 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
const normalizedSearch = normalizeName(search);
|
||||
return inputRows.filter(({ participant, input }) => {
|
||||
if (normalizedSearch && !normalizeName(participant.name).includes(normalizedSearch)) return false;
|
||||
if (onlyMissing && !isRowIncomplete(participant.id, input)) return false;
|
||||
if (onlyMissing && !isRowIncomplete(input)) return false;
|
||||
return true;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inputRows, search, onlyMissing, requiredInputs, resolvedEloRows, resolvedRatingRows]);
|
||||
}, [inputRows, search, onlyMissing, requiredInputs]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PARTICIPANT_PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
|
|
@ -663,26 +582,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
this Elo — they are not blended again per game.
|
||||
</p>
|
||||
</div>
|
||||
{projectionEloOption && (
|
||||
<div className="space-y-2 md:col-span-5">
|
||||
<Label htmlFor="baseEloPriority">Base Elo Source</Label>
|
||||
<select
|
||||
id="baseEloPriority"
|
||||
name="baseEloPriority"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
defaultValue={projectionsOutrankElo ? "projectionsFirst" : "eloFirst"}
|
||||
>
|
||||
<option value="eloFirst">Entered Elo first, then {projectionEloOption.label}</option>
|
||||
<option value="projectionsFirst">{projectionEloOption.label} first, then entered Elo</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Raw Elo and projections are substitutes — the first one a participant has wins, and
|
||||
the other is ignored (futures odds are separate and blend on top via the weight above).
|
||||
Pick <strong>{projectionEloOption.label} first</strong> when projections are
|
||||
the source of truth for this season and a previously entered Elo should not override them.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{config.profile.requiredInputs.includes("sourceElo") && (
|
||||
<>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
|
|
@ -872,7 +771,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
</div>
|
||||
) : (
|
||||
pageRows.map(({ participant, input }) => {
|
||||
const incomplete = isRowIncomplete(participant.id, input);
|
||||
const incomplete = isRowIncomplete(input);
|
||||
return (
|
||||
<div
|
||||
key={participant.id}
|
||||
|
|
@ -885,34 +784,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
</div>
|
||||
{inputColumns.length > 0 ? (
|
||||
inputColumns.map((column) => {
|
||||
if (column.key === "sourceElo") {
|
||||
const resolved = resolvedEloRows[participant.id];
|
||||
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
|
||||
return (
|
||||
<div key={column.key} className="flex items-center gap-1.5">
|
||||
{resolved ? resolved.sourceElo : "—"}
|
||||
{methodLabel && (
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{methodLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (column.key === "rating") {
|
||||
const resolved = resolvedRatingRows[participant.id];
|
||||
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
|
||||
return (
|
||||
<div key={column.key} className="flex items-center gap-1.5">
|
||||
{resolved ? resolved.rating : "—"}
|
||||
{methodLabel && (
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{methodLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const value = input?.[column.key];
|
||||
return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ import {
|
|||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||
import { getSurfaceElosForSeason, batchUpsertSurfaceElos } from '~/models/surface-elo';
|
||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||
import { calculateEV } from '~/services/ev-calculator';
|
||||
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
|
||||
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
|
||||
import { recalculateStandings } from '~/models/scoring-calculator';
|
||||
import { database } from '~/database/context';
|
||||
import * as schema from '~/database/schema';
|
||||
|
|
@ -31,6 +30,17 @@ import { useEffect, useRef, useState } from 'react';
|
|||
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
|
||||
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
|
||||
|
||||
const DEFAULT_SCORING_RULES: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 45,
|
||||
pointsFor4th: 45,
|
||||
pointsFor5th: 20,
|
||||
pointsFor6th: 20,
|
||||
pointsFor7th: 20,
|
||||
pointsFor8th: 20,
|
||||
};
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Surface Elo — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,37 +186,6 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
|
||||
});
|
||||
|
||||
it("names a non-eliminated loser for context without @-pinging them", async () => {
|
||||
// Argentina beats England in the World Cup semifinal. Argentina scored, so it's
|
||||
// pinged; England advances to the 3rd-place playoff (not eliminated, no points
|
||||
// change), so its manager is shown by plain username but NOT @-pinged.
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Diablo League 2026",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 160, rank: 7 }],
|
||||
previousStandings: new Map([["a", 130]]),
|
||||
scoredMatches: [
|
||||
{
|
||||
winnerName: "Argentina",
|
||||
loserName: "England",
|
||||
winnerUsername: "philosohraptors",
|
||||
winnerDiscordUserId: "111",
|
||||
loserUsername: "elementsoul",
|
||||
// no loserDiscordUserId — still alive, no ping
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const payload = getPayload();
|
||||
const desc = payload.embeds[0].description as string;
|
||||
// Winner scored → rendered as an @-mention; loser is named by plain username.
|
||||
expect(desc).toContain("• **Argentina (<@111>)** def. England (elementsoul)");
|
||||
// England's manager is named but not mentioned/pinged.
|
||||
expect(desc).not.toContain("England (<@");
|
||||
expect(payload.content ?? "").toContain("<@111>");
|
||||
expect(payload.content ?? "").not.toContain("elementsoul");
|
||||
});
|
||||
|
||||
it("shows winner's manager for context when the match fires due to an owned loser", async () => {
|
||||
// Brazil beats Japan (R32, non-scoring). Japan's manager is the reason for the
|
||||
// notification; Brazil's manager is shown for context even though they didn't score.
|
||||
|
|
@ -821,9 +790,10 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
|
||||
});
|
||||
|
||||
it("omits zero-QP drafted participants entirely from the Drafted Participants section", async () => {
|
||||
// Only participants who have actually scored (qpTotal > 0) are listed; a 0-QP drafted
|
||||
// player no longer appears anywhere in the standings section.
|
||||
it("lists non-scoring participants (outside top 8) as one comma-separated line below Top 8", async () => {
|
||||
// Non-scoring now draws from the full scoreboard: every drafted participant NOT in
|
||||
// the top 8 (rank 9+ or unranked), rendered on a single line "Name (total, manager)"
|
||||
// ordered by season total desc — not just this event's zero-earners.
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
|
|
@ -838,19 +808,45 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Drafted Participants**");
|
||||
// Both scorers appear as ranked rows...
|
||||
expect(desc).toContain("1\\. Champ (alex) — 100 QP");
|
||||
expect(desc).toContain("9\\. Also Ran (chris) — 5 QP");
|
||||
// ...with a Points Bubble divider separating the rank-9 scorer.
|
||||
expect(desc).toContain("**═══ Points Bubble ═══**");
|
||||
// The 0-QP player is omitted entirely.
|
||||
expect(desc).not.toContain("Winless Wonder");
|
||||
// The old Non-scoring section is gone.
|
||||
expect(desc).not.toContain("Non-scoring Participants");
|
||||
expect(desc).toContain("**Non-scoring Participants**");
|
||||
// Single comma-separated line: "Name (seasonTotal, manager)", higher total first.
|
||||
expect(desc).toContain("Also Ran (5, chris), Winless Wonder (0, sam)");
|
||||
// Champ is in the top 8, so it must NOT appear in the non-scoring line.
|
||||
expect(desc).not.toContain("Champ (100");
|
||||
// Non-scoring appears below the Top 8 section.
|
||||
expect(desc.indexOf("Drafted Participants in Top 8")).toBeLessThan(
|
||||
desc.indexOf("Non-scoring Participants")
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the Drafted Participants section for scored participants sorted by rank", async () => {
|
||||
it("keeps zero-QP participants tied into the top-8 rank band out of the Top 8 section", async () => {
|
||||
// Regression: early in a season, standard competition ranking ties every winless
|
||||
// player into a low rank (here rank 2). A rank-only Top 8 filter would flood the
|
||||
// section with "T2. Name — 0 QP" rows; the qpTotal>0 guard drops them to Non-scoring.
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [
|
||||
{ participantName: "Only Scorer", qpEarned: 10, qpTotal: 10, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
|
||||
],
|
||||
scoreboard: [
|
||||
{ participantName: "Only Scorer", qpEarned: 10, qpTotal: 10, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
|
||||
{ participantName: "Winless A", qpEarned: 0, qpTotal: 0, globalRank: 2, globalRankTied: true, ownerUsername: "chris" },
|
||||
{ participantName: "Winless B", qpEarned: 0, qpTotal: 0, globalRank: 2, globalRankTied: true, ownerUsername: "sam" },
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
// Top 8 contains only the real scorer.
|
||||
expect(desc).toContain("1\\. Only Scorer (alex) — 10 QP");
|
||||
// The tied 0-QP players must NOT appear as top-8 rows.
|
||||
expect(desc).not.toContain("T2\\. Winless A");
|
||||
expect(desc).not.toContain("T2\\. Winless B");
|
||||
// They land in the Non-scoring line instead.
|
||||
expect(desc).toContain("Winless A (0, chris), Winless B (0, sam)");
|
||||
});
|
||||
|
||||
it("shows Drafted Participants in Top 8 for scoreboard sorted by qpTotal desc", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
|
|
@ -859,61 +855,12 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Drafted Participants**");
|
||||
expect(desc).toContain("**Drafted Participants in Top 8**");
|
||||
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
|
||||
expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP");
|
||||
expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP");
|
||||
// Djokovic should rank above Alcaraz
|
||||
expect(desc.indexOf("1\\. Novak")).toBeLessThan(desc.indexOf("2\\. Carlos"));
|
||||
// Everyone is rank <= 8, so no divider is emitted.
|
||||
expect(desc).not.toContain("Points Bubble");
|
||||
});
|
||||
|
||||
it("inserts a Points Bubble divider between the rank-8 and rank-9 scorers", async () => {
|
||||
const scoreboard = [
|
||||
{ participantName: "Player Eight", qpEarned: 5, qpTotal: 12, globalRank: 8, globalRankTied: false, ownerUsername: "chris" },
|
||||
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
|
||||
];
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: scoreboard,
|
||||
scoreboard,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
const eightIdx = desc.indexOf("8\\. Player Eight");
|
||||
const bubbleIdx = desc.indexOf("**═══ Points Bubble ═══**");
|
||||
const nineIdx = desc.indexOf("9\\. Player Nine");
|
||||
expect(eightIdx).toBeGreaterThan(-1);
|
||||
expect(bubbleIdx).toBeGreaterThan(-1);
|
||||
expect(nineIdx).toBeGreaterThan(-1);
|
||||
// Divider sits between the rank-8 and rank-9 rows.
|
||||
expect(eightIdx).toBeLessThan(bubbleIdx);
|
||||
expect(bubbleIdx).toBeLessThan(nineIdx);
|
||||
});
|
||||
|
||||
it("omits the Points Bubble divider when every scorer is below the cutoff", async () => {
|
||||
// globalRank is a season-wide rank but the scoreboard is scoped to one league's drafts,
|
||||
// so a league can have drafted nobody in the global top 8. The divider must not lead the
|
||||
// section with nothing above it.
|
||||
const scoreboard = [
|
||||
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
|
||||
{ participantName: "Player Ten", qpEarned: 2, qpTotal: 5, globalRank: 10, globalRankTied: false, ownerUsername: "chris" },
|
||||
];
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: scoreboard,
|
||||
scoreboard,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Drafted Participants**");
|
||||
expect(desc).toContain("9\\. Player Nine (alex) — 8 QP");
|
||||
expect(desc).toContain("10\\. Player Ten (chris) — 5 QP");
|
||||
// No rank <= 8 row exists, so the divider must not appear.
|
||||
expect(desc).not.toContain("Points Bubble");
|
||||
});
|
||||
|
||||
it("uses T-prefix for tied QP totals in standings", async () => {
|
||||
|
|
@ -968,9 +915,9 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
expect(desc).not.toContain("1.50");
|
||||
});
|
||||
|
||||
it("lists a rank-9 scorer below the Points Bubble but omits a 0-QP participant", async () => {
|
||||
// Rank-9 scorers now appear in the standings section (below the bubble), while a drafted
|
||||
// participant with no points is dropped entirely.
|
||||
it("excludes participants ranked outside the top 8 from the Drafted Participants section", async () => {
|
||||
// Only the top 8 (plus ties) of the full season field belong in this section, so a
|
||||
// rank-9 scorer must NOT appear in it — even though it still shows in Points Awarded.
|
||||
const both = [
|
||||
{
|
||||
participantName: "Player Eight",
|
||||
|
|
@ -988,14 +935,6 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
globalRankTied: false,
|
||||
ownerUsername: "ninthowner",
|
||||
},
|
||||
{
|
||||
participantName: "Player Winless",
|
||||
qpEarned: 0,
|
||||
qpTotal: 0,
|
||||
globalRank: 10,
|
||||
globalRankTied: false,
|
||||
ownerUsername: "winlessowner",
|
||||
},
|
||||
];
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
|
|
@ -1007,14 +946,13 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Drafted Participants**");
|
||||
expect(desc).toContain("**Drafted Participants in Top 8**");
|
||||
expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP");
|
||||
// The rank-9 scorer now appears as a ranked row below the bubble.
|
||||
expect(desc).toContain("**═══ Points Bubble ═══**");
|
||||
expect(desc).toContain("9\\. Player Nine (ninthowner) — 8 QP");
|
||||
// The 0-QP player is omitted from the standings section entirely.
|
||||
expect(desc).not.toContain("Player Winless");
|
||||
// ...but both scorers still earned points, so both remain in Points Awarded.
|
||||
// The rank-9 player is filtered out of the standings section entirely.
|
||||
expect(desc).not.toContain("9\\. Player Nine");
|
||||
// ...instead the rank-9 player drops into the Non-scoring line.
|
||||
expect(desc).toContain("Player Nine (8, ninthowner)");
|
||||
// ...and both still earned points, so both remain in Points Awarded.
|
||||
expect(desc).toContain("• **Player Nine (ninthowner)** — 3 QP");
|
||||
});
|
||||
|
||||
|
|
@ -1044,7 +982,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
const desc = getDescription();
|
||||
// Points Awarded (a pinged section) uses the Discord mention...
|
||||
expect(desc).toContain("• **Novak Djokovic (<@111222333>)** — 20 QP");
|
||||
// ...while the (non-pinged) Drafted Participants standings section uses the plain username.
|
||||
// ...while the (non-pinged) Top 8 standings section uses the plain username.
|
||||
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
|
||||
});
|
||||
|
||||
|
|
@ -1099,7 +1037,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
expect(fetch).toHaveBeenCalledOnce();
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Knocked Out**");
|
||||
expect(desc).not.toContain("**Drafted Participants**");
|
||||
expect(desc).not.toContain("**Drafted Participants in Top 8**");
|
||||
expect(desc).not.toContain("**Points Awarded**");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import {
|
|||
convertAmericanOddsToProbability,
|
||||
convertDecimalOddsToProbability,
|
||||
normalizeProbabilities,
|
||||
devigPower,
|
||||
decompressProbability,
|
||||
mapToElo,
|
||||
eloWinProbability,
|
||||
|
|
@ -95,64 +94,6 @@ 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%
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@ import * as participantEVModel from "~/models/participant-expected-value";
|
|||
// Mock the dependencies
|
||||
vi.mock("~/models/participant-result");
|
||||
vi.mock("~/models/participant-expected-value");
|
||||
vi.mock("~/models/simulator");
|
||||
vi.mock("~/models/sports-season");
|
||||
vi.mock("~/services/simulations/runner");
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: () => ({
|
||||
query: {
|
||||
|
|
@ -21,9 +18,6 @@ vi.mock("~/database/context", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
// vi.mock above is hoisted over the imports, so this is already the mocked function.
|
||||
const upsertEV = vi.mocked(participantEVModel.upsertParticipantEV);
|
||||
|
||||
describe("probability-updater", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -272,262 +266,5 @@ describe("probability-updater", () => {
|
|||
expect(callArgs.probabilities.probSeventh).toBe(0);
|
||||
expect(callArgs.probabilities.probEighth).toBe(0);
|
||||
});
|
||||
|
||||
it("does NOT treat a provisional floor as finished — the team is still playing", async () => {
|
||||
// An AFL top-4 seed banks a provisional 5th-6th floor at seeding. Pinning them
|
||||
// to 100% at 5th would erase their championship odds before they have played.
|
||||
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
||||
{
|
||||
id: "result-1",
|
||||
participantId: "participant-1",
|
||||
sportsSeasonId: "season-1",
|
||||
finalPosition: 5,
|
||||
isPartialScore: true,
|
||||
qualifyingPoints: null,
|
||||
notes: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
participant: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
|
||||
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
|
||||
|
||||
const result = await updateProbabilitiesAfterResult("season-1", false);
|
||||
|
||||
expect(result.finishedParticipants).toBe(0);
|
||||
expect(upsertSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still finalizes a 0-position elimination — those rows are not partial", async () => {
|
||||
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
||||
{
|
||||
id: "result-1",
|
||||
participantId: "participant-1",
|
||||
sportsSeasonId: "season-1",
|
||||
finalPosition: 0,
|
||||
isPartialScore: false,
|
||||
qualifyingPoints: null,
|
||||
notes: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
participant: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
|
||||
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
|
||||
|
||||
const result = await updateProbabilitiesAfterResult("season-1", false);
|
||||
|
||||
expect(result.finishedParticipants).toBe(1);
|
||||
expect(upsertSpy.mock.calls[0][0].probabilities.probFirst).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bracket-aware simulator seasons ──────────────────────────────────────────
|
||||
//
|
||||
// The ICM branch re-derives a whole distribution from P(1st) alone and knows nothing about
|
||||
// who is playing whom or what has already been decided, so it cannot see the placement floors
|
||||
// an afl_10 seeding or a non-scoring-round win has already banked — it will happily value a
|
||||
// team below points the league has paid out. Whenever the season has a simulator that reads
|
||||
// its bracket, that simulator is the better answer and is re-run instead. Only a season whose
|
||||
// simulator is bracket-blind (or has none) still goes through ICM.
|
||||
|
||||
const evRow = (participantId: string, source: string) => ({
|
||||
id: `ev-${participantId}`,
|
||||
participantId,
|
||||
sportsSeasonId: "season-1",
|
||||
probFirst: "0.1000",
|
||||
probSecond: "0.1000",
|
||||
probThird: "0.1000",
|
||||
probFourth: "0.1000",
|
||||
probFifth: "0.1000",
|
||||
probSixth: "0.1000",
|
||||
probSeventh: "0.1000",
|
||||
probEighth: "0.1000",
|
||||
expectedValue: "34.00",
|
||||
source,
|
||||
sourceOdds: null,
|
||||
calculatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const finishedResult = (participantId: string, finalPosition: number) => ({
|
||||
id: `result-${participantId}`,
|
||||
participantId,
|
||||
sportsSeasonId: "season-1",
|
||||
finalPosition,
|
||||
isPartialScore: false,
|
||||
qualifyingPoints: null,
|
||||
notes: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
participant: null,
|
||||
});
|
||||
|
||||
describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => {
|
||||
/** Wire up a season: which teams are done, what wrote the EVs, which simulator it has. */
|
||||
async function setup(opts: {
|
||||
evSource: string;
|
||||
simulatorType: string | null;
|
||||
results?: ReturnType<typeof finishedResult>[];
|
||||
seasonStatus?: string;
|
||||
}) {
|
||||
const simulatorModel = await import("~/models/simulator");
|
||||
const sportsSeasonModel = await import("~/models/sports-season");
|
||||
const runner = await import("~/services/simulations/runner");
|
||||
|
||||
vi.mocked(sportsSeasonModel.findSportsSeasonById).mockResolvedValue({
|
||||
id: "season-1",
|
||||
status: opts.seasonStatus ?? "active",
|
||||
} as never);
|
||||
|
||||
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue(
|
||||
opts.results ?? []
|
||||
);
|
||||
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([
|
||||
evRow("alive-1", opts.evSource),
|
||||
evRow("alive-2", opts.evSource),
|
||||
] as never);
|
||||
vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
|
||||
vi.mocked(simulatorModel.getSportsSeasonSimulatorConfig).mockResolvedValue(
|
||||
opts.simulatorType ? ({ simulatorType: opts.simulatorType, config: {} } as never) : null
|
||||
);
|
||||
const runSim = vi.mocked(runner.runSportsSeasonSimulation);
|
||||
runSim.mockResolvedValue({} as never);
|
||||
|
||||
return { runner, runSim };
|
||||
}
|
||||
|
||||
/** The ICM branch is the only thing that writes unfinished rows with this source. */
|
||||
const icmWrites = () =>
|
||||
upsertEV.mock.calls.filter(([arg]) => arg.source === "futures_odds");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("re-runs a bracket-aware simulator instead of recalculating ICM", async () => {
|
||||
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
|
||||
|
||||
const result = await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
|
||||
expect(icmWrites()).toHaveLength(0);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("asks the run for probabilities only, leaving standings and snapshots to the caller", async () => {
|
||||
// recalculateAffectedLeagues detects change by diffing teamStandings across its own
|
||||
// recalculation, and that diff gates the Discord standings post. A recalculation in here
|
||||
// runs before it takes its "before" snapshot, so the diff comes back empty and the post is
|
||||
// silently dropped — and previousRank gets rolled forward twice, erasing rank movement.
|
||||
const { runSim } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
|
||||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runSim).toHaveBeenCalledWith("season-1", {
|
||||
skipStandingsRecalc: true,
|
||||
skipSnapshots: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls through to ICM on a completed season rather than failing every time", async () => {
|
||||
// finalizeQualifyingPoints marks the season completed immediately before calling here, and
|
||||
// runSportsSeasonSimulation rejects a completed season outright. Treating that as a failure
|
||||
// would strand anyone still unfinished on stale probabilities forever.
|
||||
const { runner } = await setup({
|
||||
evSource: "elo_simulation",
|
||||
simulatorType: "cs2_major_qualifying_points",
|
||||
seasonStatus: "completed",
|
||||
});
|
||||
|
||||
const result = await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
||||
expect(icmWrites().length).toBeGreaterThan(0);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("still pins finished participants before re-running the simulator", async () => {
|
||||
const { runner } = await setup({
|
||||
evSource: "elo_simulation",
|
||||
simulatorType: "afl_bracket",
|
||||
results: [finishedResult("done-1", 2)],
|
||||
});
|
||||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
const pinned = upsertEV.mock.calls.find(([arg]) => arg.participantId === "done-1");
|
||||
expect(pinned?.[0].probabilities.probSecond).toBe(1.0);
|
||||
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("writes a finalized pin after the re-run, so the pin wins over the simulation", async () => {
|
||||
// runSportsSeasonSimulation rewrites every participant in the season, finalized ones
|
||||
// included. A finalized placement is a fact, not a projection, so it has to land last.
|
||||
const { runSim } = await setup({
|
||||
evSource: "elo_simulation",
|
||||
simulatorType: "afl_bracket",
|
||||
results: [finishedResult("done-1", 0)],
|
||||
});
|
||||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
const pinIndex = upsertEV.mock.calls.findIndex(([arg]) => arg.participantId === "done-1");
|
||||
expect(pinIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(upsertEV.mock.invocationCallOrder[pinIndex]).toBeGreaterThan(
|
||||
runSim.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves probabilities alone, and does not fall back to ICM, when the re-run fails", async () => {
|
||||
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
|
||||
vi.mocked(runner.runSportsSeasonSimulation).mockRejectedValue(
|
||||
new Error("A simulation is already running for this sports season.")
|
||||
);
|
||||
|
||||
const result = await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(icmWrites()).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatch(/Failed to re-run simulator/);
|
||||
});
|
||||
|
||||
it("re-runs the simulator whatever wrote the EVs originally", async () => {
|
||||
// The alternative is not leaving them alone — ICM would overwrite them either way — so
|
||||
// futures-odds EVs are no reason to prefer the bracket-blind overwrite.
|
||||
const { runner } = await setup({ evSource: "futures_odds", simulatorType: "afl_bracket" });
|
||||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
|
||||
expect(icmWrites()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the ICM path for a bracket-blind simulator", async () => {
|
||||
// ncaa_football_bracket declares a "bracket" setup section but never reads playoff_matches,
|
||||
// so re-running it would re-draw the field and hand equity back to eliminated teams.
|
||||
const { runner } = await setup({
|
||||
evSource: "elo_simulation",
|
||||
simulatorType: "ncaa_football_bracket",
|
||||
});
|
||||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
||||
expect(icmWrites().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps the ICM path when the season has no simulator configured", async () => {
|
||||
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: null });
|
||||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
||||
expect(icmWrites().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ describe("notifyQualifyingPointsUpdate", () => {
|
|||
});
|
||||
|
||||
it("scoreboard includes every drafted participant even when entries are filtered", async () => {
|
||||
// The scoreboard powers the Drafted Participants section and must reflect the full
|
||||
// The scoreboard powers the Top 8 / Non-scoring sections and must reflect the full
|
||||
// drafted field, not just this sync's changed participants. Here Nadal (p-2) did not
|
||||
// change this sync (filtered out of entries) but is drafted, so he belongs on the
|
||||
// scoreboard with his running total and no QP earned this event.
|
||||
|
|
|
|||
|
|
@ -288,6 +288,20 @@ export interface QPEliminatedEntry {
|
|||
ownerDiscordUserId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scoreboard entry belongs in the "Drafted Participants in Top 8" section.
|
||||
* Requires the participant to have actually scored (qpTotal > 0) AND sit in the top 8
|
||||
* of the full season standings. The qpTotal>0 guard matters early in a season: when
|
||||
* fewer than 8 players have scored, standard competition ranking ties every 0-QP player
|
||||
* into a rank <= 8 band (e.g. everyone winless is "T5"), and a rank-only filter would
|
||||
* flood the section with "T5. Name — 0 QP" rows. Rank ties among real scorers at rank 8
|
||||
* are still kept ("top 8 plus ties"); globalRank 0 means unranked. The Non-scoring
|
||||
* section is the exact complement (`!inTopEight`), so the field is partitioned cleanly.
|
||||
*/
|
||||
function inTopEight(e: QPEventEntry): boolean {
|
||||
return e.qpTotal > 0 && e.globalRank >= 1 && e.globalRank <= 8;
|
||||
}
|
||||
|
||||
export async function sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl,
|
||||
seasonName,
|
||||
|
|
@ -306,8 +320,8 @@ export async function sendQualifyingPointsUpdateNotification({
|
|||
eliminated?: QPEliminatedEntry[];
|
||||
/**
|
||||
* The full current scoreboard for the league — every drafted participant, not just
|
||||
* those whose QP changed this sync. Drives the "Drafted Participants" standings section.
|
||||
* `entries`/`eliminated` remain scoped to this sync's changes and drive the
|
||||
* those whose QP changed this sync. Drives the "Top 8" and "Non-scoring Participants"
|
||||
* sections. `entries`/`eliminated` remain scoped to this sync's changes and drive the
|
||||
* "Points Awarded"/"Knocked Out" sections and the ping list.
|
||||
*/
|
||||
scoreboard?: QPEventEntry[];
|
||||
|
|
@ -358,39 +372,48 @@ export async function sendQualifyingPointsUpdateNotification({
|
|||
}
|
||||
}
|
||||
|
||||
// Drafted Participants: every drafted participant that has actually scored (qpTotal > 0),
|
||||
// drawn from the FULL drafted field (`scoreboard`) not just this sync's movers, so it reads
|
||||
// as a live standings snapshot. Rendered as ranked lines ordered by full-season standing. A
|
||||
// "Points Bubble" divider marks the cutoff between those currently in the points (rank <= 8)
|
||||
// and those below it (rank >= 9). Participants with 0 QP are omitted entirely. Never pinged,
|
||||
// so managers are shown by plain username, never as a <@id> mention.
|
||||
const scored = [...scoreboard]
|
||||
.filter((e) => e.qpTotal > 0)
|
||||
// Scoreboard sections below draw from the FULL drafted field (`scoreboard`), not just
|
||||
// this sync's movers, so they read as a live standings snapshot. The two sections
|
||||
// partition the field exactly via `inTopEight`, so nobody is dropped or listed twice.
|
||||
|
||||
// Top 8 = a drafted participant that has actually scored (qpTotal > 0) AND sits in the
|
||||
// top 8 of the FULL season standings (see `inTopEight`).
|
||||
const topEight = [...scoreboard]
|
||||
.filter(inTopEight)
|
||||
.toSorted((a, b) => a.globalRank - b.globalRank);
|
||||
|
||||
// Skip the section entirely when no drafted participant has scored (e.g. a sync that only
|
||||
// reported knockouts) so we don't emit an empty header.
|
||||
if (scored.length > 0) {
|
||||
sections.push("\n**Drafted Participants**");
|
||||
// Insert the divider once, before the first below-the-cutoff (rank >= 9) row. `>= 9`
|
||||
// (not `> 8`) keeps a tie AT rank 8 above the bubble ("top 8 plus ties"). Only emit it
|
||||
// after at least one above-the-bubble row exists: globalRank is a season-wide rank while
|
||||
// this list is scoped to one league's drafts, so a league can have drafted nobody in the
|
||||
// global top 8 — guarding on rowsAbove avoids a leading divider with nothing above it.
|
||||
let bubbleInserted = false;
|
||||
let rowsAbove = 0;
|
||||
for (const e of scored) {
|
||||
if (!bubbleInserted && rowsAbove > 0 && e.globalRank >= 9) {
|
||||
sections.push("**═══ Points Bubble ═══**");
|
||||
bubbleInserted = true;
|
||||
}
|
||||
if (e.globalRank <= 8) rowsAbove++;
|
||||
// Skip the section entirely when no drafted participant is in the top 8 (e.g. a
|
||||
// sync that only reported knockouts) so we don't emit an empty header.
|
||||
if (topEight.length > 0) {
|
||||
sections.push("\n**Drafted Participants in Top 8**");
|
||||
for (const e of topEight) {
|
||||
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
|
||||
// Plain manager username (no <@id> mention): this section is not pinged.
|
||||
const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : "";
|
||||
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${formatQPValue(e.qpTotal)} QP`);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-scoring section — the rest of the drafted field (everyone not in the top 8, i.e.
|
||||
// the exact complement of `inTopEight`: rank 9+, unranked, or scored-nothing players
|
||||
// tied into the top-8 rank band). Rendered as a single compact comma-separated line of
|
||||
// "Name (points, manager)", highest QP total first. Never pinged, so managers are shown
|
||||
// by plain username, never as a <@id> mention.
|
||||
const nonTopEight = [...scoreboard]
|
||||
.filter((e) => !inTopEight(e))
|
||||
.toSorted((a, b) => b.qpTotal - a.qpTotal);
|
||||
|
||||
if (nonTopEight.length > 0) {
|
||||
sections.push("\n**Non-scoring Participants**");
|
||||
const parts = nonTopEight.map((e) => {
|
||||
const details = e.ownerUsername
|
||||
? `${formatQPValue(e.qpTotal)}, ${escapeMarkdown(e.ownerUsername)}`
|
||||
: `${formatQPValue(e.qpTotal)}`;
|
||||
return `${escapeMarkdown(e.participantName)} (${details})`;
|
||||
});
|
||||
sections.push(parts.join(", "));
|
||||
}
|
||||
|
||||
const MAX_DESCRIPTION = 4096;
|
||||
let description = sections.join("\n");
|
||||
if (description.length > MAX_DESCRIPTION) {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import {
|
|||
processQualifyingBracketEvent,
|
||||
recalculateAffectedLeagues,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
import {
|
||||
|
|
@ -284,11 +283,6 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
|||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchId: playoffMatch.id,
|
||||
// The probability refresh is season-wide and idempotent, and for a bracket-aware
|
||||
// sport it is a full Monte Carlo run — doing it per match would repeat that for
|
||||
// every match in the sync. It runs once after the loop instead. Standings and the
|
||||
// per-match Discord post still happen here as before.
|
||||
skipProbabilities: true,
|
||||
loserAdvances: event.bracketTemplateId
|
||||
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
|
||||
: false,
|
||||
|
|
@ -306,15 +300,6 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The refresh skipped inside the loop, run once for the whole sync.
|
||||
if (playoffUpdated > 0) {
|
||||
try {
|
||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||
} catch (err) {
|
||||
logger.error(`[match-sync] Error updating probabilities after bracket sync:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
|
||||
|
|
|
|||
|
|
@ -109,65 +109,6 @@ 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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -20,11 +20,6 @@ import type { ProbabilityDistribution } from "./ev-calculator";
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
|
||||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
import { getManifestSimulatorProfile } from "~/services/simulations/manifest";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
||||
/**
|
||||
* Result of probability update operation
|
||||
|
|
@ -101,40 +96,6 @@ function createFinishedProbabilities(finalPosition: number): number[] {
|
|||
return probs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this season's still-alive participants should be refreshed by re-running its
|
||||
* simulator instead of by the ICM recalculation below.
|
||||
*
|
||||
* If the season has a simulator that reads its bracket, that simulator is simply a better
|
||||
* answer than ICM to "what happens from here": it seeds from the real draw and replays every
|
||||
* completed match, where ICM re-derives a whole distribution from P(1st) alone and knows
|
||||
* nothing about who is playing whom or what has already been decided. That blindness is what
|
||||
* makes ICM report a placement floor the league has already paid out as worth less than its
|
||||
* awarded points.
|
||||
*
|
||||
* Where the EVs originally came from is not consulted, because the alternative here is not
|
||||
* leaving them alone — the ICM branch overwrites them either way. Given the choice between
|
||||
* two overwrites, the bracket-aware one wins.
|
||||
*
|
||||
* The gate is `bracketAware`, not merely "has a simulator": re-running a bracket-blind
|
||||
* simulator would re-draw the field and hand equity back to teams already knocked out.
|
||||
*/
|
||||
async function shouldRerunSimulator(sportsSeasonId: string): Promise<boolean> {
|
||||
// A completed season cannot be simulated — runSportsSeasonSimulation rejects it outright —
|
||||
// and finalizeQualifyingPoints marks the season completed immediately before calling here,
|
||||
// so taking this branch there would fail every single time and leave anyone still in the
|
||||
// unfinished set on permanently stale probabilities. It is not a failure, it is not this
|
||||
// branch's case: the season is over, every placement is final, and the floor this branch
|
||||
// exists to protect can no longer be contradicted. Fall through to ICM as before.
|
||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||
if (sportsSeason?.status === "completed") return false;
|
||||
|
||||
const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||||
if (!simulatorConfig) return false;
|
||||
|
||||
return getManifestSimulatorProfile(simulatorConfig.simulatorType)?.bracketAware === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update probabilities for a sports season after results come in
|
||||
*
|
||||
|
|
@ -142,8 +103,7 @@ async function shouldRerunSimulator(sportsSeasonId: string): Promise<boolean> {
|
|||
* 1. Get all participant results (finished participants)
|
||||
* 2. Get all existing participant EVs
|
||||
* 3. For finished participants: set 100% at their placement
|
||||
* 4. For unfinished participants: re-run the season's bracket-aware simulator if it has one,
|
||||
* otherwise recalculate using ICM with remaining participants
|
||||
* 4. For unfinished participants: recalculate using ICM with remaining participants
|
||||
*
|
||||
* @param sportsSeasonId Sports season to update
|
||||
* @param recalculateUnfinished Whether to recalculate unfinished participants (default true)
|
||||
|
|
@ -163,63 +123,55 @@ export async function updateProbabilitiesAfterResult(
|
|||
// Get all existing EVs
|
||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||
|
||||
// Create map of participantId -> finalPosition.
|
||||
//
|
||||
// Provisional rows (isPartialScore) are NOT finished: they are the guaranteed
|
||||
// minimum for someone still alive — a bracket entry floor, or the floor banked
|
||||
// by winning a round. Treating them as finished pins the participant to 100% at
|
||||
// that floor and drops them from the ICM recalculation below, which would zero
|
||||
// the championship odds of every team still playing. They belong in the
|
||||
// unfinished set until a real result lands.
|
||||
// Create map of participantId -> finalPosition
|
||||
const finishedMap = new Map(
|
||||
results
|
||||
.filter(r => r.finalPosition !== null && !r.isPartialScore)
|
||||
.filter(r => r.finalPosition !== null)
|
||||
.map(r => [r.participantId, r.finalPosition ?? 0])
|
||||
);
|
||||
|
||||
// Update finished participants
|
||||
// Use default scoring rules (we only care about setting probabilities, not EV for finished)
|
||||
const defaultScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 50,
|
||||
pointsFor4th: 40,
|
||||
pointsFor5th: 25,
|
||||
pointsFor6th: 25,
|
||||
pointsFor7th: 15,
|
||||
pointsFor8th: 15,
|
||||
};
|
||||
|
||||
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
|
||||
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
|
||||
// Running these in parallel would race on that shared state.
|
||||
for (const [participantId, finalPosition] of finishedMap.entries()) {
|
||||
try {
|
||||
const probs = createFinishedProbabilities(finalPosition);
|
||||
const probabilities = arrayToProbabilityDistribution(probs);
|
||||
|
||||
await upsertParticipantEV({
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
probabilities,
|
||||
scoringRules: defaultScoringRules,
|
||||
source: 'manual', // Result is from actual outcome
|
||||
});
|
||||
|
||||
updated++;
|
||||
} catch (error) {
|
||||
errors.push(`Failed to update participant ${participantId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate unfinished participants if requested
|
||||
if (recalculateUnfinished) {
|
||||
const unfinishedEVs = existingEVs.filter(
|
||||
ev => !finishedMap.has(ev.participantId)
|
||||
);
|
||||
|
||||
if (unfinishedEVs.length > 0 && (await shouldRerunSimulator(sportsSeasonId))) {
|
||||
// The simulator reads the bracket, so it already knows this result: it seeds from the
|
||||
// real draw and replays every completed match. Re-running it keeps each participant's
|
||||
// distribution consistent with the games actually played — including the placement
|
||||
// floors a bracket entry or a non-scoring-round win has already banked, which the ICM
|
||||
// branch below cannot see and would value below points the league has paid out.
|
||||
//
|
||||
// Imported lazily: probability-updater → runner → scoring-calculator →
|
||||
// probability-updater is a module cycle, and a static import leaves the binding
|
||||
// undefined at module-init time.
|
||||
try {
|
||||
const { runSportsSeasonSimulation } = await import("~/services/simulations/runner");
|
||||
// Probabilities only. Our callers recalculate standings themselves right after this,
|
||||
// and recalculateAffectedLeagues detects change by diffing teamStandings across its
|
||||
// own recalculation — a recalculation slipped in here empties that diff and silently
|
||||
// suppresses the Discord standings post, and rolls previousRank forward a second time
|
||||
// so rank movement disappears. The daily EV snapshot is not ours to write either: it
|
||||
// is keyed by date, so writing it per result overwrites the day with intra-day values.
|
||||
await runSportsSeasonSimulation(sportsSeasonId, {
|
||||
skipStandingsRecalc: true,
|
||||
skipSnapshots: true,
|
||||
});
|
||||
updated += unfinishedEVs.length;
|
||||
} catch (error) {
|
||||
// A run already in flight, failed readiness, or a bracket the simulator refuses to
|
||||
// read (afl_10 seeded into only some of its slots). Leave the existing probabilities
|
||||
// alone rather than falling back to ICM — for these seasons ICM is precisely the
|
||||
// thing being replaced, and reintroducing it here would reintroduce sub-floor EVs.
|
||||
// Completed seasons never reach this: shouldRerunSimulator excludes them.
|
||||
logger.error(
|
||||
`[ProbabilityUpdater] Failed to re-run simulator for sports season ${sportsSeasonId}; ` +
|
||||
`leaving existing probabilities in place:`,
|
||||
error
|
||||
);
|
||||
errors.push(`Failed to re-run simulator for sports season ${sportsSeasonId}: ${error}`);
|
||||
}
|
||||
} else if (unfinishedEVs.length > 0) {
|
||||
if (unfinishedEVs.length > 0) {
|
||||
// Get their current championship probabilities (use existing P(1st) as proxy)
|
||||
const unfinishedOdds = unfinishedEVs.map(ev => {
|
||||
const pFirst = parseFloat(ev.probFirst);
|
||||
|
|
@ -257,7 +209,7 @@ export async function updateProbabilitiesAfterResult(
|
|||
participantId,
|
||||
sportsSeasonId,
|
||||
probabilities,
|
||||
scoringRules: DEFAULT_SCORING_RULES,
|
||||
scoringRules: defaultScoringRules,
|
||||
source: 'futures_odds', // Recalculated from remaining odds
|
||||
});
|
||||
|
||||
|
|
@ -269,38 +221,6 @@ export async function updateProbabilitiesAfterResult(
|
|||
}
|
||||
}
|
||||
|
||||
// Update finished participants. The shared default table is used because we only
|
||||
// care about setting probabilities here, not the EV — each league re-derives its own
|
||||
// EV from the stored probabilities in calculateTeamProjectedScore.
|
||||
//
|
||||
// This runs *after* the recalculation above, not before, because re-running a simulator
|
||||
// rewrites every participant in the season — the finalized ones included. A finalized
|
||||
// placement is a fact, not a projection, so it is written last and wins: if a simulator
|
||||
// ever puts a knocked-out team back in contention (a bracket-aware one whose bracket has
|
||||
// since been cleared and not re-seeded, say), the pin still zeroes them.
|
||||
|
||||
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
|
||||
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
|
||||
// Running these in parallel would race on that shared state.
|
||||
for (const [participantId, finalPosition] of finishedMap.entries()) {
|
||||
try {
|
||||
const probs = createFinishedProbabilities(finalPosition);
|
||||
const probabilities = arrayToProbabilityDistribution(probs);
|
||||
|
||||
await upsertParticipantEV({
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
probabilities,
|
||||
scoringRules: DEFAULT_SCORING_RULES,
|
||||
source: 'manual', // Result is from actual outcome
|
||||
});
|
||||
|
||||
updated++;
|
||||
} catch (error) {
|
||||
errors.push(`Failed to update participant ${participantId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
finishedParticipants: finishedMap.size,
|
||||
unfishedParticipants: existingEVs.length - finishedMap.size,
|
||||
|
|
|
|||
|
|
@ -126,8 +126,8 @@ export async function notifyQualifyingPointsUpdate(
|
|||
const seasonMap = new Map(seasons.map((s) => [s.id, s]));
|
||||
|
||||
// Batch-fetch participant display names once (same participants across all leagues).
|
||||
// Includes every drafted participant — the Drafted Participants scoreboard section
|
||||
// lists the whole scored field, not just this sync's changed participants.
|
||||
// Includes every drafted participant — the scoreboard sections (Top 8 / Non-scoring)
|
||||
// list the whole drafted field, not just this sync's changed participants.
|
||||
const allParticipantIds = [
|
||||
...new Set([...qpEarnedById.keys(), ...eliminatedIds, ...draftedParticipantIds]),
|
||||
];
|
||||
|
|
@ -137,7 +137,7 @@ export async function notifyQualifyingPointsUpdate(
|
|||
const participantNameById = new Map(participants.map((p) => [p.id, p.name]));
|
||||
// A league's draft picks span every sport in that fantasy season, so the scoreboard
|
||||
// must be scoped to participants belonging to the sports season being announced —
|
||||
// otherwise a golf pick would surface in a tennis event's standings section.
|
||||
// otherwise a golf pick would surface in a tennis event's Non-scoring line.
|
||||
const sportsSeasonParticipantIds = new Set(
|
||||
participants.filter((p) => p.sportsSeasonId === sportsSeasonId).map((p) => p.id)
|
||||
);
|
||||
|
|
@ -219,8 +219,8 @@ export async function notifyQualifyingPointsUpdate(
|
|||
});
|
||||
|
||||
// Full current scoreboard for this league: every drafted participant, regardless of
|
||||
// whether their QP changed this sync. Drives the Drafted Participants section so
|
||||
// it reads as a season-standings snapshot rather than only this event's movers.
|
||||
// whether their QP changed this sync. Drives the Top 8 and Non-scoring sections so
|
||||
// they read as a season-standings snapshot rather than only this event's movers.
|
||||
// Never pinged, so ownerDiscordUserId is intentionally omitted.
|
||||
const scoreboard: QPEventEntry[] = [...teamByParticipantId.keys()]
|
||||
.filter((participantId) => sportsSeasonParticipantIds.has(participantId))
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||
import {
|
||||
getTeamData,
|
||||
eloWinProbability,
|
||||
AFLSimulator,
|
||||
readAflBracketSeeds,
|
||||
simAFLFinals,
|
||||
type BracketMatch,
|
||||
} from "../afl-simulator";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
import { calculateEV, type ProbabilityDistribution } from "~/services/ev-calculator";
|
||||
import { getTeamData, eloWinProbability, AFLSimulator } from "../afl-simulator";
|
||||
|
||||
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -134,82 +125,8 @@ const PARTICIPANT_ROWS = AFL_TEAMS.map((name, i) => ({
|
|||
|
||||
const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id);
|
||||
|
||||
/**
|
||||
* Build the playoff_matches rows generateAFL10Bracket writes, seeded with `seedIds` in
|
||||
* ladder order (index 0 = minor premier). `completed` overrides individual matches with a
|
||||
* recorded result.
|
||||
*/
|
||||
function aflBracketMatches(
|
||||
seedIds: string[],
|
||||
completed: Array<{ round: string; matchNumber: number; winnerId: string; loserId: string }> = []
|
||||
): BracketMatch[] {
|
||||
const seed = (n: number) => seedIds[n - 1] ?? null;
|
||||
const rows: BracketMatch[] = [
|
||||
{ round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
|
||||
{ round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
|
||||
{ round: "Qualifying Finals", matchNumber: 1, participant1Id: seed(1), participant2Id: seed(4) },
|
||||
{ round: "Qualifying Finals", matchNumber: 2, participant1Id: seed(2), participant2Id: seed(3) },
|
||||
// participant2 is TBD until a Wildcard winner advances into it.
|
||||
{ round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
|
||||
{ round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
|
||||
{ round: "Semi-Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||||
{ round: "Semi-Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
|
||||
{ round: "Preliminary Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||||
{ round: "Preliminary Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
|
||||
{ round: "Grand Final", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||||
].map((m) => ({ ...m, winnerId: null, loserId: null, isComplete: false }));
|
||||
|
||||
for (const done of completed) {
|
||||
const row = rows.find((r) => r.round === done.round && r.matchNumber === done.matchNumber);
|
||||
if (!row) throw new Error(`no such match: ${done.round} #${done.matchNumber}`);
|
||||
row.isComplete = true;
|
||||
row.winnerId = done.winnerId;
|
||||
row.loserId = done.loserId;
|
||||
// A Wildcard winner is advanced into the Elimination Final it feeds.
|
||||
if (done.round === "Wildcard Round") {
|
||||
const ef = rows.find(
|
||||
(r) => r.round === "Elimination Finals" && r.matchNumber === (done.matchNumber === 1 ? 2 : 1)
|
||||
);
|
||||
if (ef) ef.participant2Id = done.winnerId;
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The one bracket row for a round/match, failing loudly if the fixture changes shape. */
|
||||
function matchIn(matches: BracketMatch[], round: string, matchNumber: number): BracketMatch {
|
||||
const found = matches.find((m) => m.round === round && m.matchNumber === matchNumber);
|
||||
if (!found) throw new Error(`no such match: ${round} #${matchNumber}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Look up one participant's result, failing loudly rather than silently passing on undefined. */
|
||||
function resultFor<T extends { participantId: string }>(results: T[], participantId: string): T {
|
||||
const found = results.find((r) => r.participantId === participantId);
|
||||
if (!found) throw new Error(`no simulation result for ${participantId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** EV on the reference scale the runner persists with. */
|
||||
function evOf(result: { probabilities: ProbabilityDistribution }): number {
|
||||
return calculateEV(result.probabilities, DEFAULT_SCORING_RULES);
|
||||
}
|
||||
|
||||
describe("AFLSimulator.simulate()", () => {
|
||||
let mockDb: {
|
||||
select: MockInstance;
|
||||
query: {
|
||||
scoringEvents: { findMany: MockInstance };
|
||||
playoffMatches: { findMany: MockInstance };
|
||||
};
|
||||
};
|
||||
|
||||
/** Put a seeded afl_10 bracket in front of the simulator. */
|
||||
function seedBracket(matches: BracketMatch[]) {
|
||||
mockDb.query.scoringEvents.findMany.mockResolvedValue([{ id: "event-1" }]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||||
}
|
||||
let mockDb: { select: MockInstance };
|
||||
|
||||
beforeEach(async () => {
|
||||
const { database } = await import("~/database/context");
|
||||
|
|
@ -219,11 +136,6 @@ describe("AFLSimulator.simulate()", () => {
|
|||
|
||||
let selectCallCount = 0;
|
||||
mockDb = {
|
||||
// Default: no bracket generated yet, so the ladder-projection path runs.
|
||||
query: {
|
||||
scoringEvents: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) {
|
||||
|
|
@ -443,259 +355,4 @@ describe("AFLSimulator.simulate()", () => {
|
|||
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
|
||||
expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst);
|
||||
});
|
||||
|
||||
// ─── Bracket-aware mode ─────────────────────────────────────────────────────
|
||||
//
|
||||
// afl_10 banks points on seeding alone (entryFloor 5 for seeds 1-4, 7 for seeds 5-6) and
|
||||
// on winning a non-scoring round (nonScoringWinnerFloor 7 for the Wildcard Round, 3 for a
|
||||
// Qualifying Final). Those floors are paid out as real fantasy points, so a simulator that
|
||||
// re-draws the ladder every iteration — putting a seeded team back in the Wildcard Round or
|
||||
// out of the finals, where it scores 0 — reports an EV below points already awarded. Each
|
||||
// EV assertion below is that floor.
|
||||
|
||||
describe("bracket-aware mode", () => {
|
||||
/**
|
||||
* Seeds 1-10 in ladder order, drawn from the ten *weakest* clubs by Elo. Seeding the
|
||||
* strongest ten would let the ladder-projection path produce much the same field by
|
||||
* accident, so the floor assertions below would pass even with the bracket ignored.
|
||||
*/
|
||||
const SEEDS = PARTICIPANT_IDS.slice(8);
|
||||
|
||||
it("never values a seed below the entry floor its seeding already banked", async () => {
|
||||
seedBracket(aflBracketMatches(SEEDS));
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
// Seeds 1-4 enter a Qualifying Final: lose it, lose the Semi-Final, still 5th-6th (25).
|
||||
for (const seed of [1, 2, 3, 4]) {
|
||||
expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(25);
|
||||
}
|
||||
// Seeds 5-6 enter an Elimination Final: lose it and they are 7th-8th (15).
|
||||
for (const seed of [5, 6]) {
|
||||
expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(15);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a Qualifying Final entrant out of the 7th-8th tier entirely", async () => {
|
||||
seedBracket(aflBracketMatches(SEEDS));
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
// A seed 1-4 loses the QF into a Semi-Final, so 5th-6th is its worst finish. The
|
||||
// 7th-8th tier is reachable only by losing an Elimination Final.
|
||||
for (const seed of [1, 2, 3, 4]) {
|
||||
expect(resultFor(results, SEEDS[seed - 1]).probabilities.probSeventh, `seed ${seed}`).toBe(0);
|
||||
}
|
||||
// Seeds 5-10 all reach an Elimination Final only by playing one, so they can.
|
||||
expect(resultFor(results, SEEDS[4]).probabilities.probSeventh).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses the bracket's draw rather than a re-projected ladder", async () => {
|
||||
// Deliberately inverted: the weakest club is the minor premier and the strongest
|
||||
// scrapes in 10th. On the ladder-projection path Elo decides the seeding, so this only
|
||||
// holds if the bracket's own slots are being read.
|
||||
const inverted = [
|
||||
"team-18", "team-17", "team-16", "team-15", "team-14",
|
||||
"team-13", "team-12", "team-11", "team-10", "team-1",
|
||||
];
|
||||
seedBracket(aflBracketMatches(inverted));
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
// West Coast (weakest Elo) is seeded 1, so it holds the double chance and can never
|
||||
// finish 7th-8th, and its EV clears the seed 1-4 floor.
|
||||
expect(resultFor(results, "team-18").probabilities.probSeventh).toBe(0);
|
||||
expect(evOf(resultFor(results, "team-18"))).toBeGreaterThanOrEqual(25);
|
||||
|
||||
// Western Bulldogs (strongest Elo) is seeded 10, so it starts in the Wildcard Round
|
||||
// with nothing banked and can be knocked out for 0.
|
||||
expect(resultFor(results, "team-1").probabilities.probSeventh).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("zeroes every participant outside the bracket", async () => {
|
||||
seedBracket(aflBracketMatches(SEEDS));
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
for (const r of results.filter((x) => !SEEDS.includes(x.participantId))) {
|
||||
expect(evOf(r), r.participantId).toBe(0);
|
||||
}
|
||||
expect(results).toHaveLength(18);
|
||||
});
|
||||
|
||||
it("still normalizes every column to 1.0 and the field to 340 total EV", async () => {
|
||||
seedBracket(aflBracketMatches(SEEDS));
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
const keys = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
] as const;
|
||||
for (const key of keys) {
|
||||
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
expect(colSum, `${key} column sum`).toBeCloseTo(1.0, 6);
|
||||
}
|
||||
expect(results.reduce((s, r) => s + evOf(r), 0)).toBeCloseTo(340, 4);
|
||||
});
|
||||
|
||||
it("replays a completed Wildcard Round instead of re-simulating it", async () => {
|
||||
// Seed 10 beat seed 7, which banks seed 10 a 7th-place floor (15 points).
|
||||
seedBracket(
|
||||
aflBracketMatches(SEEDS, [
|
||||
{ round: "Wildcard Round", matchNumber: 1, winnerId: SEEDS[9], loserId: SEEDS[6] },
|
||||
])
|
||||
);
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
expect(evOf(resultFor(results, SEEDS[9]))).toBeGreaterThanOrEqual(15);
|
||||
// The loser is out with nothing, in every iteration.
|
||||
expect(evOf(resultFor(results, SEEDS[6]))).toBe(0);
|
||||
});
|
||||
|
||||
it("replays a completed Qualifying Final, banking the winner's 3rd-4th floor", async () => {
|
||||
// Seed 1 beat seed 4: the winner byes into a Preliminary Final (floor 3rd, 45 points)
|
||||
// and the loser drops into a Semi-Final (floor 5th, 25 points).
|
||||
seedBracket(
|
||||
aflBracketMatches(SEEDS, [
|
||||
{ round: "Qualifying Finals", matchNumber: 1, winnerId: SEEDS[0], loserId: SEEDS[3] },
|
||||
])
|
||||
);
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
const winner = resultFor(results, SEEDS[0]);
|
||||
expect(evOf(winner)).toBeGreaterThanOrEqual(45);
|
||||
// Already through to a Preliminary Final, so the 5th-6th tier is behind it.
|
||||
expect(winner.probabilities.probFifth).toBe(0);
|
||||
|
||||
expect(evOf(resultFor(results, SEEDS[3]))).toBeGreaterThanOrEqual(25);
|
||||
});
|
||||
|
||||
it("falls back to the ladder projection when the bracket carries no seeds", async () => {
|
||||
seedBracket(aflBracketMatches([]));
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
// Every club is back in contention, so nobody is structurally zeroed.
|
||||
expect(results.filter((r) => evOf(r) > 0).length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── readAflBracketSeeds ──────────────────────────────────────────────────────
|
||||
|
||||
describe("readAflBracketSeeds", () => {
|
||||
const teamsById = new Map(
|
||||
PARTICIPANT_IDS.map((id) => [id, { id, name: id, elo: 1500, currentWins: 0, remainingGames: 0, winProb: 0.5 }])
|
||||
);
|
||||
const SEEDS = PARTICIPANT_IDS.slice(0, 10);
|
||||
|
||||
it("returns null when there is no bracket at all", () => {
|
||||
expect(readAflBracketSeeds([], teamsById as never)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a generated but unseeded bracket", () => {
|
||||
expect(readAflBracketSeeds(aflBracketMatches([]), teamsById as never)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the 10 seeds in ladder order", () => {
|
||||
const bracket = readAflBracketSeeds(aflBracketMatches(SEEDS), teamsById as never);
|
||||
expect(bracket?.seeds.map((t) => t.id)).toEqual(SEEDS);
|
||||
});
|
||||
|
||||
it("does not treat the TBD Elimination Final slots as missing seeds", () => {
|
||||
const matches = aflBracketMatches(SEEDS);
|
||||
for (const m of matches.filter((r) => r.round === "Elimination Finals")) {
|
||||
expect(m.participant2Id).toBeNull();
|
||||
}
|
||||
expect(readAflBracketSeeds(matches, teamsById as never)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("throws on a partially seeded bracket rather than discarding the draw", () => {
|
||||
const matches = aflBracketMatches(SEEDS);
|
||||
// ON DELETE SET NULL empties a slot when a participant is removed and re-added.
|
||||
matchIn(matches, "Qualifying Finals", 1).participant2Id = null;
|
||||
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/partially seeded.*seed\(s\) 4/s);
|
||||
});
|
||||
|
||||
it("throws when one participant holds two slots", () => {
|
||||
const matches = aflBracketMatches(SEEDS);
|
||||
matchIn(matches, "Wildcard Round", 1).participant2Id = SEEDS[0];
|
||||
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/more than one slot/);
|
||||
});
|
||||
|
||||
it("throws when the bracket references a participant outside the season", () => {
|
||||
const matches = aflBracketMatches(SEEDS);
|
||||
matchIn(matches, "Wildcard Round", 1).participant2Id = "ghost";
|
||||
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/not in this sports season/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── simAFLFinals ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("simAFLFinals bracket pathways", () => {
|
||||
const finalists = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `s${i + 1}`,
|
||||
name: `s${i + 1}`,
|
||||
elo: 1500,
|
||||
currentWins: 0,
|
||||
remainingGames: 0,
|
||||
winProb: 0.5,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Play the finals with the Wildcard Round forced to the given winners (every other
|
||||
* game goes to whoever was routed in first), and report who met whom.
|
||||
*/
|
||||
function pairingsWith(wc1Winner: string, wc2Winner: string): Map<string, [string, string]> {
|
||||
const pairings = new Map<string, [string, string]>();
|
||||
const play = (
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
t1: { id: string },
|
||||
t2: { id: string }
|
||||
) => {
|
||||
pairings.set(`${round}#${matchNumber}`, [t1.id, t2.id]);
|
||||
if (round === "Wildcard Round") {
|
||||
const forced = matchNumber === 1 ? wc1Winner : wc2Winner;
|
||||
return t1.id === forced ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
|
||||
}
|
||||
return { winner: t1, loser: t2 };
|
||||
};
|
||||
|
||||
simAFLFinals(finalists as never, play as never);
|
||||
return pairings;
|
||||
}
|
||||
|
||||
it("draws the Wildcard Round 7v10 and 8v9", () => {
|
||||
const pairings = pairingsWith("s7", "s8");
|
||||
expect(pairings.get("Wildcard Round#1")).toEqual(["s7", "s10"]);
|
||||
expect(pairings.get("Wildcard Round#2")).toEqual(["s8", "s9"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ wc1: "s7", wc2: "s8", ef1: "s8", ef2: "s7" },
|
||||
{ wc1: "s7", wc2: "s9", ef1: "s9", ef2: "s7" },
|
||||
// 10th beating 7th is where a fixed crossover misfires: it would send 10th to 6th
|
||||
// and leave 5th with the stronger survivor.
|
||||
{ wc1: "s10", wc2: "s8", ef1: "s10", ef2: "s8" },
|
||||
{ wc1: "s10", wc2: "s9", ef1: "s10", ef2: "s9" },
|
||||
])(
|
||||
"pairs 5th with $ef1 and 6th with $ef2 when $wc1 and $wc2 win through",
|
||||
({ wc1, wc2, ef1, ef2 }) => {
|
||||
const pairings = pairingsWith(wc1, wc2);
|
||||
expect(pairings.get("Elimination Finals#1")).toEqual(["s5", ef1]);
|
||||
expect(pairings.get("Elimination Finals#2")).toEqual(["s6", ef2]);
|
||||
}
|
||||
);
|
||||
|
||||
// The pathway out of the Elimination Finals is fixed (EF n → SF n) — unlike the
|
||||
// Wildcard Round's re-seed. The crossover lands a round later, at the Prelims, so a
|
||||
// Qualifying Final loser cannot meet the side that just beat it. `play` here hands
|
||||
// every non-Wildcard game to participant1, so QF1 sends s1 through and s4 down.
|
||||
it("feeds each Elimination Final into the Semi-Final of the same number", () => {
|
||||
const pairings = pairingsWith("s7", "s8");
|
||||
expect(pairings.get("Semi-Finals#1")).toEqual(["s4", "s5"]);
|
||||
expect(pairings.get("Semi-Finals#2")).toEqual(["s3", "s6"]);
|
||||
});
|
||||
|
||||
it("crosses the Semi-Final winners over into the Preliminary Finals", () => {
|
||||
const pairings = pairingsWith("s7", "s8");
|
||||
expect(pairings.get("Preliminary Finals#1")).toEqual(["s1", "s3"]);
|
||||
expect(pairings.get("Preliminary Finals#2")).toEqual(["s2", "s4"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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(),
|
||||
|
|
@ -14,18 +13,18 @@ vi.mock("~/models/participant-expected-value", () => ({
|
|||
getAllParticipantEVsForSeason: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/season-races", () => ({
|
||||
countSeasonRaces: 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,
|
||||
};
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const DRIVERS = ["d1", "d2", "d3", "d4", "d5"].map((id) => ({ id }));
|
||||
|
||||
const PROB_KEYS = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
] as const;
|
||||
function makeEvent(isComplete: boolean, eventType = "race") {
|
||||
return { isComplete, eventType };
|
||||
}
|
||||
|
||||
function makeSeasonResult(participantId: string, currentPoints: string) {
|
||||
return { participant: { id: participantId }, currentPoints };
|
||||
|
|
@ -35,66 +34,53 @@ function makeEv(participantId: string, sourceOdds: number | null) {
|
|||
return { participantId, sourceOdds };
|
||||
}
|
||||
|
||||
function mockDb(drivers: { id: string }[] = DRIVERS) {
|
||||
function mockDb(events: ReturnType<typeof makeEvent>[]) {
|
||||
return {
|
||||
query: {
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue(drivers),
|
||||
findMany: vi.fn().mockResolvedValue(DRIVERS),
|
||||
},
|
||||
scoringEvents: {
|
||||
findMany: vi.fn().mockResolvedValue(events),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 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,
|
||||
});
|
||||
}
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let db: ReturnType<typeof mockDb>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { database } = await import("~/database/context");
|
||||
(database as unknown as MockInstance).mockReturnValue(mockDb());
|
||||
await setStandings([]);
|
||||
await setOdds([]);
|
||||
await setRaceCounts(0, 0);
|
||||
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([]);
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AutoRacingSimulator", () => {
|
||||
it("throws when no participants are found", async () => {
|
||||
await useDrivers([]);
|
||||
db.query.seasonParticipants.findMany.mockResolvedValue([]);
|
||||
await expect(
|
||||
new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1")
|
||||
).rejects.toThrow(/No participants found/);
|
||||
});
|
||||
|
||||
describe("pre-season path (no races run, none remaining)", () => {
|
||||
describe("pre-season path (remainingRaces === 0)", () => {
|
||||
beforeEach(async () => {
|
||||
await setRaceCounts(0, 0);
|
||||
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);
|
||||
// Heavy favourite: d1 at −500, all others at +1000
|
||||
await setOdds([
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
|
||||
makeEv("d1", -500),
|
||||
makeEv("d2", 1000),
|
||||
makeEv("d3", 1000),
|
||||
|
|
@ -110,7 +96,11 @@ describe("AutoRacingSimulator", () => {
|
|||
|
||||
it("normalizes each position column to sum to 1.0", async () => {
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
for (const key of PROB_KEYS) {
|
||||
const keys = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
] as const;
|
||||
for (const key of keys) {
|
||||
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
|
||||
}
|
||||
|
|
@ -127,7 +117,8 @@ describe("AutoRacingSimulator", () => {
|
|||
});
|
||||
|
||||
it("drivers without odds get equal fallback probability", async () => {
|
||||
await setOdds([]);
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([]);
|
||||
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) {
|
||||
|
|
@ -135,145 +126,30 @@ 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("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"),
|
||||
]);
|
||||
// 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)", () => {
|
||||
describe("in-season path (remainingRaces > 0)", () => {
|
||||
beforeEach(async () => {
|
||||
await setRaceCounts(10, 5);
|
||||
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)),
|
||||
]);
|
||||
(database as unknown as MockInstance).mockReturnValue(db);
|
||||
});
|
||||
|
||||
it("normalizes each position column to sum to 1.0", async () => {
|
||||
await setStandings(DRIVERS.map((d, i) => makeSeasonResult(d.id, String((5 - i) * 50))));
|
||||
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)))
|
||||
);
|
||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||
for (const key of PROB_KEYS) {
|
||||
const keys = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
] as const;
|
||||
for (const key of keys) {
|
||||
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
|
||||
}
|
||||
|
|
@ -281,9 +157,17 @@ 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%
|
||||
await setRaceCounts(20, 5);
|
||||
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");
|
||||
// d1 leads with 400 pts; d2 is a distant 2nd with 50 pts
|
||||
await setStandings([
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
|
||||
makeSeasonResult("d1", "400"),
|
||||
makeSeasonResult("d2", "50"),
|
||||
makeSeasonResult("d3", "40"),
|
||||
|
|
@ -291,7 +175,7 @@ describe("AutoRacingSimulator", () => {
|
|||
makeSeasonResult("d5", "20"),
|
||||
]);
|
||||
// Futures odds heavily favour d2 (pretend markets disagree)
|
||||
await setOdds([
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
|
||||
makeEv("d1", 5000), // very long shot per futures
|
||||
makeEv("d2", -500), // heavy favourite per futures
|
||||
]);
|
||||
|
|
@ -308,7 +192,11 @@ describe("AutoRacingSimulator", () => {
|
|||
|
||||
it("falls back to odds for all drivers when no standings data exists", async () => {
|
||||
// totalCurrentPoints = 0 → standings signal disabled, odds take over
|
||||
await setOdds([makeEv("d1", -500), makeEv("d2", 1000)]);
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
|
||||
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");
|
||||
|
|
@ -320,17 +208,27 @@ describe("AutoRacingSimulator", () => {
|
|||
});
|
||||
|
||||
it("a driver with 0 points mid-season is not penalized beyond their odds weight", async () => {
|
||||
// Early season → standings gap is small
|
||||
await setRaceCounts(2, 20);
|
||||
// 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");
|
||||
// d1-d4 have a modest lead; d5 is absent (0 pts, new entry)
|
||||
await setStandings([
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
|
||||
makeSeasonResult("d1", "10"),
|
||||
makeSeasonResult("d2", "8"),
|
||||
makeSeasonResult("d3", "6"),
|
||||
makeSeasonResult("d4", "4"),
|
||||
// d5 intentionally absent → falls back to odds weight
|
||||
]);
|
||||
await setOdds([makeEv("d5", -500)]); // strong odds favourite despite 0 pts
|
||||
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
|
||||
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
|
||||
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
|
||||
|
|
@ -342,8 +240,9 @@ 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
|
||||
await setStandings([
|
||||
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
|
||||
makeSeasonResult("d1", "100"),
|
||||
makeSeasonResult("d2", "80"),
|
||||
makeSeasonResult("d3", "60"),
|
||||
|
|
@ -354,97 +253,19 @@ describe("AutoRacingSimulator", () => {
|
|||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
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("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]]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,33 +26,6 @@ describe("simulator input policy", () => {
|
|||
expect(resolved.get("team-1")).toMatchObject({ sourceElo: 1600, method: "direct" });
|
||||
});
|
||||
|
||||
it("puts projections ahead of a stored Elo when baseEloPriority says so", () => {
|
||||
// The season-level escape hatch for "projections are the source of truth here":
|
||||
// without it a stale hand-entered Elo silently beats a fresh projection.
|
||||
const resolved = resolveSourceElos(
|
||||
[{ participantId: "team-1", sourceElo: 1600, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],
|
||||
profile,
|
||||
{ seasonGames: 82, parityFactor: 400, inputPolicy: { baseEloPriority: ["projectedWins", "sourceElo"] } }
|
||||
);
|
||||
|
||||
expect(resolved.get("team-1")?.method).toBe("projectedWins");
|
||||
expect(resolved.get("team-1")?.sourceElo).not.toBe(1600);
|
||||
});
|
||||
|
||||
it("still falls back to the stored Elo for participants without a projection", () => {
|
||||
const resolved = resolveSourceElos(
|
||||
[
|
||||
{ participantId: "projected", sourceElo: 1600, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null },
|
||||
{ participantId: "elo-only", sourceElo: 1600, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
||||
],
|
||||
profile,
|
||||
{ seasonGames: 82, parityFactor: 400, inputPolicy: { baseEloPriority: ["projectedWins", "sourceElo"] } }
|
||||
);
|
||||
|
||||
expect(resolved.get("projected")?.method).toBe("projectedWins");
|
||||
expect(resolved.get("elo-only")).toMatchObject({ sourceElo: 1600, method: "direct" });
|
||||
});
|
||||
|
||||
it("derives Elo from projected wins when Elo is missing", () => {
|
||||
const resolved = resolveSourceElos(
|
||||
[{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||
import {
|
||||
LLWSSimulator,
|
||||
makePlayGame,
|
||||
playCrossoverGame,
|
||||
readBracketSlots,
|
||||
} from "../llws-simulator";
|
||||
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
|
||||
import type { SimulationResult } from "../types";
|
||||
import { LLWSSimulator } from "../llws-simulator";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
|
|
@ -34,168 +27,28 @@ 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;
|
||||
query: {
|
||||
scoringEvents: { findMany: MockInstance };
|
||||
playoffMatches: { findMany: MockInstance };
|
||||
};
|
||||
};
|
||||
let mockDb: { select: MockInstance };
|
||||
let selectCallCount: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
selectCallCount = 0;
|
||||
const { database } = await import("~/database/context");
|
||||
mockDb = {
|
||||
select: vi.fn(),
|
||||
query: {
|
||||
scoringEvents: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
};
|
||||
mockDb = { select: vi.fn() };
|
||||
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
||||
});
|
||||
|
||||
function setupMockDb(
|
||||
participants: { id: string; name?: string; externalId: string | null }[],
|
||||
evRows: { participantId: string; sourceOdds: number | null }[],
|
||||
bracketMatches?: Partial<PlayoffMatchRow>[]
|
||||
evRows: { participantId: string; sourceOdds: number | null }[]
|
||||
) {
|
||||
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") {
|
||||
|
|
@ -273,49 +126,23 @@ describe("LLWSSimulator", () => {
|
|||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("probFifth sums to ~1.0 (2 Elimination Final losers per sim, split over 5th/6th)", async () => {
|
||||
it("sum of probFifth across participants equals ~1.0 (4 bracket losers, split evenly)", async () => {
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||
// 4 bracket losers per sim, each assigned bracketLoser/(4*N) → sum = 1.0
|
||||
const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
|
||||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("probSeventh sums to ~1.0 (2 Elimination Round 4 losers per sim, split over 7th/8th)", async () => {
|
||||
it("probFifth through probEighth are equal for every participant (even bracket-loser split)", async () => {
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||
const total = results.reduce((s, r) => s + r.probabilities.probSeventh, 0);
|
||||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("ties 5th with 6th and 7th with 8th, but keeps the two tiers separate", async () => {
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS, { includeOdds: true }));
|
||||
const results = await new LLWSSimulator(2_000).simulate("season-1");
|
||||
for (const r of results) {
|
||||
const p = r.probabilities;
|
||||
// Within a tier the two positions are tied.
|
||||
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
|
||||
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10);
|
||||
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
|
||||
}
|
||||
// The tiers are distinct outcomes (losing the Elimination Final vs losing
|
||||
// Elimination Round 4), so they must not be forced equal across the field.
|
||||
const differs = results.some(
|
||||
(r) => Math.abs(r.probabilities.probFifth - r.probabilities.probSeventh) > 1e-9
|
||||
);
|
||||
expect(differs).toBe(true);
|
||||
});
|
||||
|
||||
it("gives every team a total placement probability of at most 1", async () => {
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||
for (const r of results) {
|
||||
const p = r.probabilities;
|
||||
// Each sim assigns a team at most one placement, so summing the distinct
|
||||
// tiers (5th/6th and 7th/8th each count once) cannot exceed 1.
|
||||
const total =
|
||||
p.probFirst + p.probSecond + p.probThird + p.probFourth +
|
||||
p.probFifth * 2 + p.probSeventh * 2;
|
||||
expect(total).toBeLessThanOrEqual(1 + 1e-9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -352,13 +179,10 @@ describe("LLWSSimulator", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Legacy externalId formats ─────────────────────────────────────────────
|
||||
//
|
||||
// The tournament no longer has pool play, but seasons configured for the old
|
||||
// format still carry pool suffixes. Those must keep loading, read as the side alone.
|
||||
// ── Pool assignment modes ─────────────────────────────────────────────────
|
||||
|
||||
describe("legacy pool-suffix externalIds", () => {
|
||||
it("accepts US:A / US:B / Intl:A / Intl:B, ignoring the pool part", async () => {
|
||||
describe("pool assignment modes", () => {
|
||||
it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => {
|
||||
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
|
||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||
expect(results).toHaveLength(20);
|
||||
|
|
@ -366,10 +190,10 @@ describe("LLWSSimulator", () => {
|
|||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("accepts a mix of suffixed and bare side ids", async () => {
|
||||
it("mixed mode: US fixed pools, Intl randomized", async () => {
|
||||
const participants = [
|
||||
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
||||
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
|
||||
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
|
||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||
];
|
||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||
|
|
@ -378,17 +202,6 @@ describe("LLWSSimulator", () => {
|
|||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("accepts an uneven suffix split (pools no longer constrain anything)", async () => {
|
||||
const participants = [
|
||||
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
||||
...US_IDS.slice(6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
|
||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||
];
|
||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||
expect(results).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error cases ───────────────────────────────────────────────────────────
|
||||
|
|
@ -449,502 +262,26 @@ describe("LLWSSimulator", () => {
|
|||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
||||
});
|
||||
|
||||
it("throws when International team count is not 10", async () => {
|
||||
it("throws when fixed pools have unequal A/B split", async () => {
|
||||
const participants = [
|
||||
...Array.from({ length: 9 }, (_, i) => ({ id: `us-${i + 1}`, name: `US Team ${i + 1}`, externalId: "US" })),
|
||||
...Array.from({ length: 11 }, (_, i) => ({ id: `intl-${i + 1}`, name: `Team ${i + 1}`, externalId: "Intl" })),
|
||||
// 6 in Pool A, 4 in Pool B
|
||||
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
||||
...US_IDS.slice(6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
|
||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||
];
|
||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||
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);
|
||||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/exactly 5 teams each/);
|
||||
});
|
||||
|
||||
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.
|
||||
it("throws when US externalIds mix pool suffixes and bare side", async () => {
|
||||
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" },
|
||||
// Some US:A, some "US" (no pool suffix) → mixed
|
||||
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
||||
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })), // no pool
|
||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||
];
|
||||
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);
|
||||
});
|
||||
|
||||
/**
|
||||
* Play out the entire U.S. side, so two of its teams are locked into a scoring tier:
|
||||
* us-7 loses Elimination Round 4 (the 7th-8th tier) and us-9 loses the Elimination
|
||||
* Final (the 5th-6th tier). Every game feeding those two is recorded, which is what
|
||||
* makes the results honorable — makePlayGame only replays a result when the teams
|
||||
* the simulation routed into the game are the pair the result was recorded between.
|
||||
*
|
||||
* Slot order per side is ids[0..7] into the four Opening Round games and ids[8..9]
|
||||
* as the byes, so the U.S. draw is us-1 v us-2, us-3 v us-4, us-5 v us-6,
|
||||
* us-7 v us-8, with us-9 and us-10 entering at Winners Round 2.
|
||||
*/
|
||||
function usSidePlayedOut(): PlayoffMatchRow[] {
|
||||
let matches = seededBracket();
|
||||
const play = (round: string, matchNumber: number, winnerId: string, loserId: string) => {
|
||||
matches = completeMatch(matches, round, matchNumber, winnerId, loserId);
|
||||
};
|
||||
|
||||
// Winners bracket
|
||||
play("Opening Round", 1, "us-1", "us-2");
|
||||
play("Opening Round", 2, "us-3", "us-4");
|
||||
play("Opening Round", 3, "us-5", "us-6");
|
||||
play("Opening Round", 4, "us-7", "us-8");
|
||||
play("Winners Round 2", 1, "us-9", "us-1"); // bye us-9 v OP1 winner
|
||||
play("Winners Round 2", 2, "us-10", "us-3"); // bye us-10 v OP2 winner
|
||||
play("Winners Semifinals", 1, "us-5", "us-9");
|
||||
play("Winners Semifinals", 2, "us-10", "us-7");
|
||||
play("Winners Final", 1, "us-5", "us-10");
|
||||
|
||||
// Elimination bracket, including the deliberate cross-overs
|
||||
play("Elimination Round 1", 1, "us-4", "us-6"); // OP2 loser v OP3 loser
|
||||
play("Elimination Round 1", 2, "us-2", "us-8"); // OP1 loser v OP4 loser
|
||||
play("Elimination Round 2", 1, "us-1", "us-4");
|
||||
play("Elimination Round 2", 2, "us-3", "us-2");
|
||||
play("Elimination Round 3", 1, "us-9", "us-3");
|
||||
play("Elimination Round 3", 2, "us-7", "us-1");
|
||||
play("Elimination Round 4", 1, "us-9", "us-7"); // us-7 out: 7th-8th tier
|
||||
play("Elimination Final", 1, "us-10", "us-9"); // us-9 out: 5th-6th tier
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
it("puts a team locked into the 5th-6th tier at exactly 50/50 across those two spots", async () => {
|
||||
setupMockDb(defaultParticipants(), favouredEvRows, usSidePlayedOut());
|
||||
const results = await new LLWSSimulator(2_000).simulate("season-1");
|
||||
const locked = probsFor(results, "us-9");
|
||||
|
||||
// The tier is two tied positions, so its probability splits evenly across them.
|
||||
// Under DEFAULT_SCORING_RULES that is 0.5 x 25 + 0.5 x 25 = 25 points of EV —
|
||||
// the 5th-6th tier value, not the flat 5th-8th average of 20.
|
||||
expect(locked.probFifth).toBe(0.5);
|
||||
expect(locked.probSixth).toBe(0.5);
|
||||
expect(locked.probSeventh).toBe(0);
|
||||
expect(locked.probEighth).toBe(0);
|
||||
expect(locked.probFirst + locked.probSecond + locked.probThird + locked.probFourth).toBe(0);
|
||||
});
|
||||
|
||||
it("puts a team locked into the 7th-8th tier at exactly 50/50 across those two spots", async () => {
|
||||
setupMockDb(defaultParticipants(), favouredEvRows, usSidePlayedOut());
|
||||
const results = await new LLWSSimulator(2_000).simulate("season-1");
|
||||
const locked = probsFor(results, "us-7");
|
||||
|
||||
// 0.5 x 15 + 0.5 x 15 = 15 points of EV, again distinct from the flat 20.
|
||||
expect(locked.probSeventh).toBe(0.5);
|
||||
expect(locked.probEighth).toBe(0.5);
|
||||
expect(locked.probFifth).toBe(0);
|
||||
expect(locked.probSixth).toBe(0);
|
||||
expect(locked.probFirst + locked.probSecond + locked.probThird + locked.probFourth).toBe(0);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── 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"]));
|
||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/mixed externalId formats/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,33 +30,6 @@ describe("simulator manifest", () => {
|
|||
}
|
||||
});
|
||||
|
||||
// updateProbabilitiesAfterResult sends a season down the re-run path or the ICM path purely
|
||||
// on this flag, and getting it wrong is silent in both directions: set it on a simulator
|
||||
// that re-plays decided games and eliminated teams come back to life; leave it off a
|
||||
// bracket-aware one and ICM keeps reporting placement floors as worth less than the points
|
||||
// already awarded. Pinning the set makes a new simulator an explicit decision rather than a
|
||||
// default. To add one, confirm it reads playoff_matches AND honors isComplete/winnerId.
|
||||
it("pins which simulators are bracket-aware", () => {
|
||||
const bracketAware = SIMULATOR_TYPES.filter((t) => SIMULATOR_MANIFEST[t].bracketAware);
|
||||
expect(bracketAware.toSorted()).toEqual(
|
||||
[
|
||||
"afl_bracket",
|
||||
"college_hockey_bracket",
|
||||
"cs2_major_qualifying_points",
|
||||
"darts_bracket",
|
||||
"llws_bracket",
|
||||
"nba_bracket",
|
||||
"ncaam_bracket",
|
||||
"ncaaw_bracket",
|
||||
"nhl_bracket",
|
||||
"nll_bracket",
|
||||
"snooker_bracket",
|
||||
"ucl_bracket",
|
||||
"world_cup",
|
||||
].toSorted()
|
||||
);
|
||||
});
|
||||
|
||||
it("only derives inputs from declared optional inputs", () => {
|
||||
for (const simulatorType of SIMULATOR_TYPES) {
|
||||
const profile = SIMULATOR_MANIFEST[simulatorType];
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import {
|
|||
rawWinRateFromElo,
|
||||
rdifWinProbability,
|
||||
eloToRDif,
|
||||
projectionForSeeding,
|
||||
seedingWinRateFor,
|
||||
sampleBinomial,
|
||||
simBo3,
|
||||
simBo5,
|
||||
|
|
@ -283,8 +281,8 @@ describe("sampleBinomial", () => {
|
|||
|
||||
// ─── Series simulators ────────────────────────────────────────────────────────
|
||||
|
||||
const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0, projectedWins: null };
|
||||
const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0, projectedWins: null };
|
||||
const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0 };
|
||||
const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0 };
|
||||
const alwaysA = () => 1.0; // team A always wins each game
|
||||
const alwaysB = () => 0.0; // team B always wins each game
|
||||
const coinFlip = () => 0.5;
|
||||
|
|
@ -348,159 +346,9 @@ describe("eloToRDif", () => {
|
|||
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
|
||||
});
|
||||
|
||||
it("lands on the same run-differential scale as the hardcoded TEAMS_DATA rdif", () => {
|
||||
// 95 projected wins out of 162 → Elo ≈ 1561. On the TEAMS_DATA scale that is a
|
||||
// ~+140 run differential, right alongside the Dodgers' hardcoded +137 — not the
|
||||
// ~+686 the old RDIF_DIVISOR scaling produced.
|
||||
const winRate = 95 / 162;
|
||||
const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate);
|
||||
expect(eloToRDif(elo)).toBeGreaterThan(120);
|
||||
expect(eloToRDif(elo)).toBeLessThan(160);
|
||||
});
|
||||
|
||||
it("is compressed by winRateFromRDif for playoff matchups, like a hardcoded rdif", () => {
|
||||
// The whole point of RDIF_DIVISOR: playoff series are near coin-flips between
|
||||
// playoff-calibre teams. An Elo-rated team must not skip that compression.
|
||||
const winRate = 95 / 162;
|
||||
const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate);
|
||||
const playoffRate = winRateFromRDif(eloToRDif(elo));
|
||||
expect(playoffRate).toBeCloseTo(0.517, 2);
|
||||
// Strictly compressed relative to the team's raw season win rate.
|
||||
expect(playoffRate).toBeLessThan(rawWinRateFromElo(elo));
|
||||
});
|
||||
|
||||
it("agrees with the hardcoded rdif path for a team of equivalent strength", () => {
|
||||
// Dodgers: hardcoded +137. An Elo carrying the same seeding win rate should
|
||||
// produce a comparable playoff win rate rather than a wildly more dominant one.
|
||||
const dodgers = getTeamData("Los Angeles Dodgers");
|
||||
const eloEquivalent = 1500 + 400 * Math.log10(
|
||||
rawWinRateFromRDif(dodgers?.rdif ?? 0) / (1 - rawWinRateFromRDif(dodgers?.rdif ?? 0))
|
||||
);
|
||||
expect(winRateFromRDif(eloToRDif(eloEquivalent))).toBeCloseTo(
|
||||
winRateFromRDif(dodgers?.rdif ?? 0),
|
||||
3
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── seedingWinRateFor ────────────────────────────────────────────────────────
|
||||
|
||||
describe("seedingWinRateFor", () => {
|
||||
const eloRate = 95 / 162; // ≈ 0.5864 — the rate a 95-win projection implies
|
||||
|
||||
it("is a no-op pre-season: the target equals the Elo-implied rate", () => {
|
||||
expect(seedingWinRateFor(eloRate, 95, 0, 162)).toBeCloseTo(eloRate, 6);
|
||||
});
|
||||
|
||||
it("spreads the shortfall over the remaining games mid-season", () => {
|
||||
// 60-50 and projected for 95: 35 wins needed in 52 games ≈ .673, well above the
|
||||
// .586 the season-long Elo implies. Without this the sim finishes around 90.5.
|
||||
expect(seedingWinRateFor(eloRate, 95, 60, 52)).toBeCloseTo(35 / 52, 6);
|
||||
});
|
||||
|
||||
it("reaches the projection in expectation", () => {
|
||||
const currentWins = 60;
|
||||
const remaining = 52;
|
||||
const rate = seedingWinRateFor(eloRate, 95, currentWins, remaining);
|
||||
expect(currentWins + rate * remaining).toBeCloseTo(95, 6);
|
||||
});
|
||||
|
||||
it("falls back to the Elo rate once a team has passed its projection", () => {
|
||||
// Clamping to a floor instead would simulate a 96-40 team to go 0-26 for the
|
||||
// rest of the season and drop out of the field. The projection is stale, so it
|
||||
// is dropped rather than obeyed.
|
||||
expect(seedingWinRateFor(eloRate, 95, 96, 26)).toBe(eloRate);
|
||||
});
|
||||
|
||||
it("falls back to the Elo rate when a team has exactly met its projection", () => {
|
||||
expect(seedingWinRateFor(eloRate, 95, 95, 26)).toBe(eloRate);
|
||||
});
|
||||
|
||||
it("falls back to the Elo rate when the projection is unreachable", () => {
|
||||
// 40-70 projected for 95 needs better than 1.000 — the mirror image of the
|
||||
// case above, and dropped for the same reason.
|
||||
expect(seedingWinRateFor(eloRate, 95, 40, 52)).toBe(eloRate);
|
||||
});
|
||||
|
||||
it("falls back to the Elo rate when the target is exactly 1.000", () => {
|
||||
expect(seedingWinRateFor(eloRate, 95, 43, 52)).toBe(eloRate);
|
||||
});
|
||||
|
||||
it("keeps a target just inside the reachable range", () => {
|
||||
expect(seedingWinRateFor(eloRate, 95, 94, 26)).toBeCloseTo(1 / 26, 6);
|
||||
});
|
||||
|
||||
it("clamps a weight above 1 rather than extrapolating past the target", () => {
|
||||
const target = 35 / 52;
|
||||
expect(seedingWinRateFor(eloRate, 95, 60, 52, 3)).toBeCloseTo(target, 6);
|
||||
expect(seedingWinRateFor(eloRate, 95, 60, 52, 3)).toBe(
|
||||
seedingWinRateFor(eloRate, 95, 60, 52, 1)
|
||||
);
|
||||
});
|
||||
|
||||
it("never returns a rate outside (0, 1) for any weight", () => {
|
||||
for (const weight of [0.25, 0.5, 0.75, 1, 5]) {
|
||||
for (const [current, remaining] of [[0, 162], [60, 52], [94, 26], [10, 152]]) {
|
||||
const rate = seedingWinRateFor(eloRate, 95, current, remaining, weight);
|
||||
expect(rate).toBeGreaterThan(0);
|
||||
expect(rate).toBeLessThan(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the Elo rate with no projection", () => {
|
||||
expect(seedingWinRateFor(eloRate, null, 60, 52)).toBe(eloRate);
|
||||
});
|
||||
|
||||
it("falls back to the Elo rate when the season is over", () => {
|
||||
expect(seedingWinRateFor(eloRate, 95, 95, 0)).toBe(eloRate);
|
||||
});
|
||||
|
||||
it("falls back to the Elo rate at weight 0", () => {
|
||||
expect(seedingWinRateFor(eloRate, 95, 60, 52, 0)).toBe(eloRate);
|
||||
});
|
||||
|
||||
it("blends target and Elo rate at an intermediate weight", () => {
|
||||
const target = 35 / 52;
|
||||
expect(seedingWinRateFor(eloRate, 95, 60, 52, 0.5)).toBeCloseTo(
|
||||
0.5 * target + 0.5 * eloRate,
|
||||
6
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── projectionForSeeding ─────────────────────────────────────────────────────
|
||||
|
||||
describe("projectionForSeeding", () => {
|
||||
it("uses the projection when it alone produced the resolved Elo", () => {
|
||||
expect(projectionForSeeding(95, { sourceEloMethod: "projectedWins" })).toBe(95);
|
||||
});
|
||||
|
||||
it("ignores a projection that lost the baseEloPriority race", () => {
|
||||
// The season resolved its Elo from a hand-entered value. Seeding off the
|
||||
// projection anyway would ignore it as the Elo source while still letting it
|
||||
// dictate the standings.
|
||||
expect(projectionForSeeding(95, { sourceEloMethod: "direct" })).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a projection that was blended with futures odds", () => {
|
||||
// The blend lives in the Elo; seeding off the raw projection would discard it
|
||||
// and run seeding and playoff matchups on different strength scales.
|
||||
expect(projectionForSeeding(95, { sourceEloMethod: "blend" })).toBeNull();
|
||||
expect(projectionForSeeding(95, { sourceEloMethod: "sourceOdds" })).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a projection on a participant resolved by a fallback", () => {
|
||||
expect(projectionForSeeding(95, { sourceEloMethod: "averageKnown" })).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a projection with no method recorded", () => {
|
||||
expect(projectionForSeeding(95, null)).toBeNull();
|
||||
expect(projectionForSeeding(95, undefined)).toBeNull();
|
||||
expect(projectionForSeeding(95, {})).toBeNull();
|
||||
});
|
||||
|
||||
it("passes a null projection through", () => {
|
||||
expect(projectionForSeeding(null, { sourceEloMethod: "projectedWins" })).toBeNull();
|
||||
it("round-trips through winRateFromRDif: winRate(eloToRDif(elo)) ≈ eloWinProb(elo, 1500)", () => {
|
||||
const elo = 1620;
|
||||
const expectedWinRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
|
||||
expect(winRateFromRDif(eloToRDif(elo))).toBeCloseTo(expectedWinRate, 4);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,8 +50,6 @@ import {
|
|||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
||||
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
||||
import { recalculateStandings } from "~/models/scoring-calculator";
|
||||
import { database } from "~/database/context";
|
||||
import { getSimulator } from "~/services/simulations/registry";
|
||||
import { normalizeSimulationResultColumns } from "~/services/simulations/simulation-probabilities";
|
||||
|
||||
|
|
@ -128,42 +126,6 @@ describe("runSportsSeasonSimulation", () => {
|
|||
expect(vi.mocked(updateSportsSeason).mock.calls[1]).toEqual(["season-1", { simulationStatus: "idle" }]);
|
||||
});
|
||||
|
||||
/** The default mock has no linked leagues, so nothing to recalculate. Give it one. */
|
||||
function withLinkedLeague() {
|
||||
vi.mocked(database).mockReturnValue({
|
||||
query: {
|
||||
seasonSports: { findMany: vi.fn().mockResolvedValue([{ seasonId: "fantasy-1" }]) },
|
||||
seasons: { findFirst: vi.fn() },
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("recalculates standings and writes the daily snapshot by default", async () => {
|
||||
withLinkedLeague();
|
||||
|
||||
await runSportsSeasonSimulation("season-1");
|
||||
|
||||
expect(recalculateStandings).toHaveBeenCalledWith("fantasy-1");
|
||||
expect(batchUpsertParticipantEvSnapshots).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips standings and snapshots when the caller owns them", async () => {
|
||||
withLinkedLeague();
|
||||
|
||||
// updateProbabilitiesAfterResult runs inside the result path, where the caller
|
||||
// recalculates standings straight afterwards. A recalculation here lands before
|
||||
// recalculateAffectedLeagues takes its "before" snapshot, emptying the diff that gates the
|
||||
// Discord standings post and rolling previousRank forward twice. EVs are still written.
|
||||
await runSportsSeasonSimulation("season-1", {
|
||||
skipStandingsRecalc: true,
|
||||
skipSnapshots: true,
|
||||
});
|
||||
|
||||
expect(recalculateStandings).not.toHaveBeenCalled();
|
||||
expect(batchUpsertParticipantEvSnapshots).not.toHaveBeenCalled();
|
||||
expect(batchUpsertParticipantEVs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when the sports season is not found", async () => {
|
||||
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,48 +3,29 @@
|
|||
*
|
||||
* Monte Carlo simulation of the AFL regular season and finals for 2026.
|
||||
*
|
||||
* Two modes:
|
||||
* 1. Pre-bracket mode: no afl_10 bracket exists yet, or it carries no seeds. The ladder is
|
||||
* re-projected from Elo every iteration and its top 10 are seeded 1-10, so the draw is
|
||||
* modelled as still uncertain.
|
||||
* 2. Bracket-aware mode: a seeded afl_10 bracket exists. Its slots are the seeding, fixed
|
||||
* across every iteration, and games already played are replayed from their recorded
|
||||
* result instead of being re-simulated.
|
||||
*
|
||||
* Bracket-aware mode is what makes a banked floor hold. afl_10 is the only template that
|
||||
* awards points on seeding alone (entryFloor: seeds 1-4 bank 5th, seeds 5-6 bank 7th), and a
|
||||
* simulator that re-draws the ladder every iteration puts those teams back in the Wildcard
|
||||
* Round — or out of the finals entirely — where they score 0, pulling EV below points the
|
||||
* league has already paid out. Reading the real draw removes that by construction: a team
|
||||
* seeded into an Elimination Final is in that game in 100% of iterations, so its worst
|
||||
* outcome is the 7th-8th tier.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Load all participants for the sports season from DB
|
||||
* 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
|
||||
* Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
|
||||
* 3. Load current regular season standings (wins, gamesPlayed) — if available
|
||||
* 4. Load the afl_10 bracket, if one has been generated, for its draw and results so far
|
||||
* 5. For each simulation:
|
||||
* a. Pre-bracket mode only: for each team, simulate remaining regular season games
|
||||
* (TOTAL_GAMES - gamesPlayed) using Elo win probability vs. an average opponent
|
||||
* (Elo 1500) → projectedPoints = currentWins*4 + simulatedRemainingWins*4
|
||||
* b. Pre-bracket mode only: sort all 18 teams by projected points desc + random
|
||||
* tiebreaker → final ladder → top 10 advance to the AFL Finals Series.
|
||||
* In bracket-aware mode the bracket's own 10 seeds are used as-is.
|
||||
* c. Simulate the AFL Finals Series (AFL_10 bracket), replaying any completed match:
|
||||
* 4. For each simulation:
|
||||
* a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed)
|
||||
* using Elo win probability vs. an average opponent (Elo 1500)
|
||||
* → projectedPoints = currentWins*4 + simulatedRemainingWins*4
|
||||
* b. Sort all 18 teams by projected points desc + random tiebreaker → final ladder
|
||||
* → Top 10 advance to the AFL Finals Series
|
||||
* c. Simulate AFL Finals Series (AFL_10 bracket):
|
||||
*
|
||||
* Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts)
|
||||
* Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye)
|
||||
* losers → Semi-Finals (2nd chance)
|
||||
* Elimination Finals: #5 vs lower WC winner, → losers exit (7th/8th)
|
||||
* #6 vs higher WC winner
|
||||
* Semi-Finals: QF1L vs EF1w, QF2L vs EF2w → losers exit (5th/6th)
|
||||
* Elimination Finals: #5 vs WC2w, #6 vs WC1w → losers exit (7th/8th)
|
||||
* Semi-Finals: QF1L vs EF2w, QF2L vs EF1w → losers exit (5th/6th)
|
||||
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
||||
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
||||
*
|
||||
* 6. Track placement counts per scoring tier
|
||||
* 7. Convert counts to probability distributions
|
||||
* 5. Track placement counts per scoring tier
|
||||
* 6. Convert counts to probability distributions
|
||||
*
|
||||
* Win probability (Elo, PARITY_FACTOR = 450):
|
||||
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 450))
|
||||
|
|
@ -72,7 +53,7 @@
|
|||
* probFifth/Sixth = Semi-Finals losers (2 per sim — split evenly)
|
||||
* probSeventh/Eighth = Elimination Finals losers (2 per sim — split evenly)
|
||||
* Wildcard losers → all 0 (score 0 points, same as 9th/10th)
|
||||
* Missed finals → all 0 (in bracket-aware mode, every team outside the bracket)
|
||||
* Missed finals → all 0
|
||||
*
|
||||
* NOTE: AFL uses the AFL_10 bracket template which splits the 5–8 tier into two
|
||||
* separate pairs (5/6 and 7/8). This is already handled by scoring-rules.ts
|
||||
|
|
@ -81,7 +62,7 @@
|
|||
*/
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||
|
|
@ -94,9 +75,6 @@ import { positiveConfigNumber } from "./config-access";
|
|||
|
||||
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
||||
|
||||
/** The bracket template the AFL finals are scored against. */
|
||||
const AFL_TEMPLATE_ID = "afl_10";
|
||||
|
||||
/**
|
||||
* Elo parity factor for AFL single-game win probability.
|
||||
* 450 reflects moderate variance — lower than NHL (1000) to account for
|
||||
|
|
@ -214,232 +192,6 @@ function simulateProjectedWins(entry: TeamEntry): number {
|
|||
return entry.currentWins + extra;
|
||||
}
|
||||
|
||||
/** The playoff_matches columns the simulator actually reads. */
|
||||
export type BracketMatch = Pick<
|
||||
typeof schema.playoffMatches.$inferSelect,
|
||||
"round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete"
|
||||
>;
|
||||
|
||||
interface LoadedBracket {
|
||||
/** The 10 finalists in seed order — index 0 is the minor premier. */
|
||||
seeds: TeamEntry[];
|
||||
/** Every bracket match, keyed by `${round}#${matchNumber}`. */
|
||||
matches: Map<string, BracketMatch>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays one finals game. `round`/`matchNumber` identify it within the bracket so an
|
||||
* already-played result can be looked up; `t1`/`t2` are the teams routed into it.
|
||||
*/
|
||||
type PlayGame = (
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
t1: TeamEntry,
|
||||
t2: TeamEntry
|
||||
) => { winner: TeamEntry; loser: TeamEntry };
|
||||
|
||||
function matchKey(round: string, matchNumber: number): string {
|
||||
return `${round}#${matchNumber}`;
|
||||
}
|
||||
|
||||
function simGame(t1: TeamEntry, t2: TeamEntry, parityFactor: number): { winner: TeamEntry; loser: TeamEntry } {
|
||||
return Math.random() < eloWinProbability(t1.elo, t2.elo, parityFactor)
|
||||
? { winner: t1, loser: t2 }
|
||||
: { winner: t2, loser: t1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where generateAFL10Bracket (models/playoff-match.ts) writes each seed.
|
||||
*
|
||||
* The two Elimination Final participant2 slots are deliberately absent: they are TBD by
|
||||
* design until a Wildcard winner advances into them, so they are never a missing seed.
|
||||
* That leaves exactly 10 named slots for the 10 finalists.
|
||||
*/
|
||||
const SEED_SLOTS: ReadonlyArray<{ round: string; matchNumber: number; slot: 1 | 2; seed: number }> = [
|
||||
{ round: "Qualifying Finals", matchNumber: 1, slot: 1, seed: 1 },
|
||||
{ round: "Qualifying Finals", matchNumber: 2, slot: 1, seed: 2 },
|
||||
{ round: "Qualifying Finals", matchNumber: 2, slot: 2, seed: 3 },
|
||||
{ round: "Qualifying Finals", matchNumber: 1, slot: 2, seed: 4 },
|
||||
{ round: "Elimination Finals", matchNumber: 1, slot: 1, seed: 5 },
|
||||
{ round: "Elimination Finals", matchNumber: 2, slot: 1, seed: 6 },
|
||||
{ round: "Wildcard Round", matchNumber: 1, slot: 1, seed: 7 },
|
||||
{ round: "Wildcard Round", matchNumber: 2, slot: 1, seed: 8 },
|
||||
{ round: "Wildcard Round", matchNumber: 2, slot: 2, seed: 9 },
|
||||
{ round: "Wildcard Round", matchNumber: 1, slot: 2, seed: 10 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Read the seeded afl_10 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
|
||||
* projecting the ladder.
|
||||
*
|
||||
* A *partially* seeded bracket is an error rather than a fallback. Falling back there would
|
||||
* throw away the real draw and every recorded result 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 one participant
|
||||
* mid-finals empties a slot. A duplicated or unknown participant fails loudly for the same
|
||||
* reason.
|
||||
*/
|
||||
export function readAflBracketSeeds(
|
||||
matches: BracketMatch[],
|
||||
teamsById: Map<string, TeamEntry>
|
||||
): LoadedBracket | null {
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m]));
|
||||
|
||||
const drawn = SEED_SLOTS.map(({ round, matchNumber, slot }) => {
|
||||
const match = byKey.get(matchKey(round, matchNumber));
|
||||
if (!match) return null;
|
||||
return (slot === 1 ? match.participant1Id : match.participant2Id) ?? null;
|
||||
});
|
||||
|
||||
const seededCount = drawn.filter((id) => id !== null).length;
|
||||
|
||||
// Generated but not yet filled in — no draw to honor.
|
||||
if (seededCount === 0) return null;
|
||||
|
||||
if (seededCount < drawn.length) {
|
||||
const missing = SEED_SLOTS.filter((_, i) => drawn[i] === null)
|
||||
.map((s) => s.seed)
|
||||
.toSorted((a, b) => a - b)
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
`AFL bracket is only partially seeded (${seededCount} of ${drawn.length} slots filled; ` +
|
||||
`missing seed(s) ${missing}). Re-seed the bracket in Admin → Bracket before simulating; ` +
|
||||
`simulating around the gap would discard the draw and every recorded result.`
|
||||
);
|
||||
}
|
||||
|
||||
// Filled by seed number below; SEED_SLOTS covers seeds 1-10 exactly once each.
|
||||
const seeds: TeamEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i < SEED_SLOTS.length; i++) {
|
||||
const participantId = drawn[i] as string;
|
||||
if (seen.has(participantId)) {
|
||||
throw new Error(`AFL bracket seeds participant ${participantId} into more than one slot.`);
|
||||
}
|
||||
seen.add(participantId);
|
||||
|
||||
const team = teamsById.get(participantId);
|
||||
if (!team) {
|
||||
throw new Error(
|
||||
`AFL bracket references participant ${participantId}, which is not in this sports season.`
|
||||
);
|
||||
}
|
||||
seeds[SEED_SLOTS[i].seed - 1] = team;
|
||||
}
|
||||
|
||||
return { seeds, matches: byKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 a bracket.
|
||||
*
|
||||
* 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 result stick across all iterations, and what stops a banked floor from being
|
||||
* re-litigated at 50/50. 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(bracket: LoadedBracket | null, parityFactor: number): PlayGame {
|
||||
if (!bracket) {
|
||||
return (_round, _matchNumber, t1, t2) => simGame(t1, t2, parityFactor);
|
||||
}
|
||||
|
||||
return (round, matchNumber, t1, t2) => {
|
||||
const match = bracket.matches.get(matchKey(round, matchNumber));
|
||||
if (match?.isComplete && match.winnerId) {
|
||||
const loserId = completedLoser(match);
|
||||
const arrived = [t1.id, t2.id];
|
||||
if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) {
|
||||
return match.winnerId === t1.id ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
|
||||
}
|
||||
}
|
||||
return simGame(t1, t2, parityFactor);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate the AFL Finals Series from a seeded list of 10 teams.
|
||||
*
|
||||
* Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a
|
||||
* recorded result is looked up against the game it was actually played in:
|
||||
* SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner,
|
||||
* PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner.
|
||||
*
|
||||
* Returns the placement for each team:
|
||||
* "gf_winner" → 1st
|
||||
* "gf_loser" → 2nd
|
||||
* "pf_loser" → 3rd/4th (two teams per sim)
|
||||
* "sf_loser" → 5th/6th (two teams per sim)
|
||||
* "ef_loser" → 7th/8th (two teams per sim)
|
||||
* "wc_loser" → 9th/10th (zero scoring points)
|
||||
*/
|
||||
export function simAFLFinals(
|
||||
finalists: TeamEntry[],
|
||||
play: PlayGame
|
||||
): {
|
||||
gfWinner: TeamEntry;
|
||||
gfLoser: TeamEntry;
|
||||
pfLosers: [TeamEntry, TeamEntry];
|
||||
sfLosers: [TeamEntry, TeamEntry];
|
||||
efLosers: [TeamEntry, TeamEntry];
|
||||
} {
|
||||
const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists;
|
||||
|
||||
// Wildcard Round: #7 vs #10, #8 vs #9
|
||||
const wc1 = play("Wildcard Round", 1, s7, s10);
|
||||
const wc2 = play("Wildcard Round", 2, s8, s9);
|
||||
|
||||
// Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get a bye to a PF)
|
||||
const qf1 = play("Qualifying Finals", 1, s1, s4);
|
||||
const qf2 = play("Qualifying Finals", 2, s2, s3);
|
||||
|
||||
// Elimination Finals: the Wildcard winners are re-seeded by ladder position, so #5
|
||||
// hosts whichever finished lower and #6 the other — not a fixed crossover.
|
||||
const wc1Seed = wc1.winner === s7 ? 7 : 10;
|
||||
const wc2Seed = wc2.winner === s8 ? 8 : 9;
|
||||
const [betterWc, worseWc] =
|
||||
wc1Seed < wc2Seed ? [wc1.winner, wc2.winner] : [wc2.winner, wc1.winner];
|
||||
const ef1 = play("Elimination Finals", 1, s5, worseWc);
|
||||
const ef2 = play("Elimination Finals", 2, s6, betterWc);
|
||||
|
||||
// Semi-Finals: QF losers (second chance) vs EF winners. Elimination Final n feeds
|
||||
// Semi-Final n — a fixed pathway; the crossover is a round later, at the Prelims.
|
||||
const sf1 = play("Semi-Finals", 1, qf1.loser, ef1.winner);
|
||||
const sf2 = play("Semi-Finals", 2, qf2.loser, ef2.winner);
|
||||
|
||||
// Preliminary Finals: QF winners vs SF winners
|
||||
const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);
|
||||
const pf2 = play("Preliminary Finals", 2, qf2.winner, sf1.winner);
|
||||
|
||||
// Grand Final
|
||||
const gf = play("Grand Final", 1, pf1.winner, pf2.winner);
|
||||
|
||||
return {
|
||||
gfWinner: gf.winner,
|
||||
gfLoser: gf.loser,
|
||||
pfLosers: [pf1.loser, pf2.loser],
|
||||
sfLosers: [sf1.loser, sf2.loser],
|
||||
efLosers: [ef1.loser, ef2.loser],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class AFLSimulator implements Simulator {
|
||||
|
|
@ -518,35 +270,12 @@ export class AFLSimulator implements Simulator {
|
|||
};
|
||||
});
|
||||
|
||||
const teamsById = new Map(teams.map((t) => [t.id, t]));
|
||||
|
||||
// 4. Load the real bracket (draw + results so far), if one has been generated.
|
||||
// Events are filtered on bracketTemplateId rather than eventType and taken most
|
||||
// recent first, matching getBracketTemplateIdsForSportsSeasons: a season can own
|
||||
// several events, and landing on a stale or template-less row would silently
|
||||
// discard the real draw and every recorded result. createdAt can tie when a bracket
|
||||
// is generated alongside a sibling event, so id breaks the tie.
|
||||
const playoffEvents = await db.query.scoringEvents.findMany({
|
||||
where: and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.scoringEvents.bracketTemplateId, AFL_TEMPLATE_ID)
|
||||
),
|
||||
columns: { id: true },
|
||||
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
|
||||
});
|
||||
const bracketEvent = playoffEvents[0];
|
||||
|
||||
const bracketMatches = bracketEvent
|
||||
? await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||||
})
|
||||
: [];
|
||||
|
||||
const bracket = readAflBracketSeeds(bracketMatches, teamsById);
|
||||
const play = makePlayGame(bracket, parityFactor);
|
||||
|
||||
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
||||
|
||||
/** Simulate a single AFL game. Returns the winner. */
|
||||
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
||||
Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b;
|
||||
|
||||
/**
|
||||
* Project end-of-season ladder and return the top 10 finalists seeded 1–10.
|
||||
*
|
||||
|
|
@ -564,7 +293,70 @@ export class AFLSimulator implements Simulator {
|
|||
return projected.slice(0, 10).map((x) => x.team);
|
||||
};
|
||||
|
||||
// 5. Integer placement count maps — initialized to 0 for all participants.
|
||||
/**
|
||||
* Simulate the AFL Finals Series from a seeded list of 10 teams.
|
||||
*
|
||||
* Returns the placement for each team:
|
||||
* "gf_winner" → 1st
|
||||
* "gf_loser" → 2nd
|
||||
* "pf_loser" → 3rd/4th (two teams per sim)
|
||||
* "sf_loser" → 5th/6th (two teams per sim)
|
||||
* "ef_loser" → 7th/8th (two teams per sim)
|
||||
* "wc_loser" → 9th/10th (zero scoring points)
|
||||
*/
|
||||
const simAFLFinals = (
|
||||
finalists: TeamEntry[]
|
||||
): {
|
||||
gfWinner: TeamEntry;
|
||||
gfLoser: TeamEntry;
|
||||
pfLosers: [TeamEntry, TeamEntry];
|
||||
sfLosers: [TeamEntry, TeamEntry];
|
||||
efLosers: [TeamEntry, TeamEntry];
|
||||
} => {
|
||||
const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists;
|
||||
|
||||
// Wildcard Round: #7 vs #10, #8 vs #9
|
||||
const wc1Winner = simGame(s7, s10);
|
||||
const wc2Winner = simGame(s8, s9);
|
||||
|
||||
// Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get bye to PF)
|
||||
const qf1Winner = simGame(s1, s4);
|
||||
const qf1Loser = qf1Winner === s1 ? s4 : s1;
|
||||
const qf2Winner = simGame(s2, s3);
|
||||
const qf2Loser = qf2Winner === s2 ? s3 : s2;
|
||||
|
||||
// Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner
|
||||
const ef1Winner = simGame(s5, wc2Winner);
|
||||
const ef1Loser = ef1Winner === s5 ? wc2Winner : s5;
|
||||
const ef2Winner = simGame(s6, wc1Winner);
|
||||
const ef2Loser = ef2Winner === s6 ? wc1Winner : s6;
|
||||
|
||||
// Semi-Finals: QF losers (2nd chance) vs EF winners
|
||||
const sf1Winner = simGame(qf1Loser, ef2Winner);
|
||||
const sf1Loser = sf1Winner === qf1Loser ? ef2Winner : qf1Loser;
|
||||
const sf2Winner = simGame(qf2Loser, ef1Winner);
|
||||
const sf2Loser = sf2Winner === qf2Loser ? ef1Winner : qf2Loser;
|
||||
|
||||
// Preliminary Finals: QF winners vs SF winners
|
||||
const pf1Winner = simGame(qf1Winner, sf2Winner);
|
||||
const pf1Loser = pf1Winner === qf1Winner ? sf2Winner : qf1Winner;
|
||||
const pf2Winner = simGame(qf2Winner, sf1Winner);
|
||||
const pf2Loser = pf2Winner === qf2Winner ? sf1Winner : qf2Winner;
|
||||
|
||||
// Grand Final
|
||||
const gfWinner = simGame(pf1Winner, pf2Winner);
|
||||
const gfLoser = gfWinner === pf1Winner ? pf2Winner : pf1Winner;
|
||||
|
||||
return {
|
||||
gfWinner,
|
||||
gfLoser,
|
||||
pfLosers: [pf1Loser, pf2Loser ],
|
||||
sfLosers: [sf1Loser, sf2Loser ],
|
||||
efLosers: [ef1Loser, ef2Loser ],
|
||||
};
|
||||
};
|
||||
|
||||
// 3. Integer placement count maps — initialized to 0 for all participants.
|
||||
//
|
||||
// AFL scoring uses the AFL_10 bracket template which splits 5–8 into two
|
||||
// separate pairs: Semi-Finals losers share 5th/6th (higher value), and
|
||||
|
|
@ -576,12 +368,10 @@ export class AFLSimulator implements Simulator {
|
|||
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
|
||||
// 6. Monte Carlo simulation loop.
|
||||
// 4. Monte Carlo simulation loop.
|
||||
for (let s = 0; s < numSimulations; s++) {
|
||||
// With a real bracket the draw is fixed and its played games are replayed from their
|
||||
// recorded result; without one the ladder is re-projected every iteration.
|
||||
const finalists = bracket ? bracket.seeds : buildFinalsList();
|
||||
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists, play);
|
||||
const finalists = buildFinalsList();
|
||||
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists);
|
||||
|
||||
championCounts.set(gfWinner.id, (championCounts.get(gfWinner.id) ?? 0) + 1);
|
||||
finalistCounts.set(gfLoser.id, (finalistCounts.get(gfLoser.id) ?? 0) + 1);
|
||||
|
|
@ -598,7 +388,7 @@ export class AFLSimulator implements Simulator {
|
|||
// Wildcard losers and non-finalists are not counted (0 points per scoring rules).
|
||||
}
|
||||
|
||||
// 7. Convert integer counts to probability distributions.
|
||||
// 5. Convert integer counts to probability distributions.
|
||||
//
|
||||
// Exact denominators guarantee column sums of 1.0 by construction:
|
||||
// probFirst/Second → / NUM_SIMULATIONS (1 per sim)
|
||||
|
|
@ -631,8 +421,8 @@ export class AFLSimulator implements Simulator {
|
|||
};
|
||||
});
|
||||
|
||||
// 8. Per-position normalization — belt-and-suspenders guard against floating-point
|
||||
// division residuals. Columns are already near-exactly 1.0 after step 7.
|
||||
// 6. Per-position normalization — belt-and-suspenders guard against floating-point
|
||||
// division residuals. Columns are already near-exactly 1.0 after step 5.
|
||||
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
|
|
|
|||
|
|
@ -7,17 +7,16 @@
|
|||
*
|
||||
* Algorithm:
|
||||
* 1. Load participants + current championship points from DB
|
||||
* 2. Count completed/remaining races (see `countSeasonRaces`)
|
||||
* 2. Count remaining races (incomplete non-schedule scoring events)
|
||||
* 3. Convert sourceOdds → vig-removed probability weights
|
||||
* 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.
|
||||
* 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
|
||||
* 5. Convert finish counts → probability distributions + normalize columns
|
||||
*
|
||||
* Notes:
|
||||
* - Drivers without odds are priced at the longest price in the book
|
||||
* - Drivers without odds fall back to uniform probability (1/N)
|
||||
* - PARTICIPANT_VOLATILITY and RACE_NOISE only apply to the in-season path
|
||||
*/
|
||||
|
||||
|
|
@ -26,8 +25,6 @@ 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";
|
||||
|
||||
|
|
@ -129,24 +126,20 @@ 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
|
||||
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.`
|
||||
);
|
||||
// 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++;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -156,33 +149,22 @@ export class AutoRacingSimulator implements Simulator {
|
|||
const ids = participants.map((p) => p.id);
|
||||
|
||||
// 5. Build raw implied championship win probabilities from odds.
|
||||
// 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.
|
||||
// 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.
|
||||
const fallbackProb = 1 / participants.length;
|
||||
const pricedImplied = new Map<string, number>();
|
||||
const rawProbs = new Map<string, number>();
|
||||
|
||||
for (const p of participants) {
|
||||
const odds = evMap.get(p.id)?.sourceOdds;
|
||||
if (odds !== null && odds !== undefined) {
|
||||
pricedImplied.set(p.id, americanToImpliedProb(odds));
|
||||
}
|
||||
const ev = evMap.get(p.id);
|
||||
rawProbs.set(p.id, ev !== undefined && ev.sourceOdds !== null && ev.sourceOdds !== undefined ? americanToImpliedProb(ev.sourceOdds) : fallbackProb);
|
||||
}
|
||||
|
||||
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]])
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0)
|
||||
const baseProbs = new Map<string, number>();
|
||||
|
|
@ -202,12 +184,9 @@ export class AutoRacingSimulator implements Simulator {
|
|||
rankCounts.set(id, Array.from({ length: 8 }, () => 0));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (remainingRaces === 0) {
|
||||
// Pre-season: no races to simulate, derive placement probabilities
|
||||
// from sourceOdds via pure weighted draws.
|
||||
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
|
||||
for (let sim = 0; sim < numSimulations; sim++) {
|
||||
const finishOrder = weightedDrawWithoutReplacement(ids, weights);
|
||||
|
|
@ -235,6 +214,7 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,85 +1,65 @@
|
|||
/**
|
||||
* Little League World Series (LLWS) Bracket Simulator
|
||||
*
|
||||
* Monte Carlo simulation of the LLWS (20-team double-elimination format, 2025+).
|
||||
*
|
||||
* The tournament is two independent 10-team double-elimination brackets — United
|
||||
* States and International — each producing a side champion, then a World
|
||||
* Championship game and a Consolation game between the side runners-up. There is no
|
||||
* 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.
|
||||
* Monte Carlo simulation of the LLWS (20-team format, 2022–present).
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Load all 20 participants for the sports season from DB
|
||||
* 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
|
||||
* (must be exactly 10 US + 10 International, identified by externalId)
|
||||
* 2. Load championship futures odds from participantExpectedValues.sourceOdds
|
||||
* (entered via Admin → Futures Odds; American format)
|
||||
* 4. Convert those futures to Elo via the shared probability engine, then drive
|
||||
* each game with the Elo win probability (see "Why Elo" below)
|
||||
* 3. Convert odds to normalized championship probabilities (vig removed).
|
||||
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50.
|
||||
* 4. Determine pool assignment mode from externalId:
|
||||
* - Fixed pools: externalId is "US:A", "US:B", "Intl:A", or "Intl:B"
|
||||
* → use these exact pool assignments every simulation.
|
||||
* - Randomized pools: externalId is "US" or "Intl" only
|
||||
* → randomly shuffle each side into Pool A / Pool B each simulation.
|
||||
* 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
|
||||
* 6. Track placement counts across all simulations
|
||||
* 7. Convert counts to probability distributions
|
||||
* a. Assign pools (fixed or random)
|
||||
* b. Simulate pool play round-robin within each pool (10 games/pool)
|
||||
* Top 2 by W-L record advance. Ties broken randomly.
|
||||
* c. Simulate 4-team double-elimination bracket per side:
|
||||
* G1: A1 vs B2 (WB)
|
||||
* G2: B1 vs A2 (WB)
|
||||
* G3: G1W vs G2W (WB Final)
|
||||
* G4: G1L vs G2L (LB R1 — loser eliminated)
|
||||
* G5: G3L vs G4W (LB Final — loser eliminated)
|
||||
* G6: G3W vs G5W (Side Championship — loser eliminated)
|
||||
* d. Consolation game: US loser vs Intl loser → 3rd / 4th
|
||||
* e. World Series: US champion vs Intl champion → 1st / 2nd
|
||||
* 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.
|
||||
* Pool assignment (externalId format):
|
||||
* "US:A" / "US:B" / "Intl:A" / "Intl:B" → fixed pools (post-draw mode)
|
||||
* "US" / "Intl" → randomized pools (pre-draw mode)
|
||||
* Mixed: if ANY US or Intl team has a pool suffix, ALL teams on that side must
|
||||
* have one (throws otherwise). Sides can differ — US fixed while Intl randomized.
|
||||
*
|
||||
* 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. 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)
|
||||
* probSecond = World Championship loser (1 per sim)
|
||||
* probThird = Consolation winner (1 per sim)
|
||||
* probFourth = Consolation loser (1 per sim)
|
||||
* probFifth/probSixth = Elimination Final losers (2 per sim — 1 per side)
|
||||
* probSeventh/probEighth = Elimination Round 4 losers (2 per sim — 1 per side)
|
||||
* Everyone else → all 0 (12 teams out in Elimination Rounds 1–3)
|
||||
* Placement tiers → SimulationProbabilities mapping:
|
||||
* probFirst = World Series Champion (1 per sim)
|
||||
* probSecond = World Series Runner-up (1 per sim)
|
||||
* probThird = Consolation game winner / 3rd place (1 per sim)
|
||||
* probFourth = Consolation game loser / 4th place (1 per sim)
|
||||
* probFifth–probEighth = Double-elim bracket losers before side championships
|
||||
* (4 per sim — split evenly: 2 US + 2 Intl)
|
||||
* Pool play losers → all 0 (12 teams, did not advance from pool play)
|
||||
*
|
||||
* Admin setup:
|
||||
* 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).
|
||||
* Once the bracket is generated and seeded this is no longer used.
|
||||
* 3. Set externalId on each participant via Admin → Manage Participants (optional if names follow the convention):
|
||||
* Pre-draw: "US" or "Intl" (or leave null — names starting with "US " infer US, all others infer Intl)
|
||||
* Post-draw: "US:A", "US:B", "Intl:A", or "Intl:B"
|
||||
* 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds)
|
||||
* 5. Run simulation via Admin → Simulate
|
||||
*/
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
import {
|
||||
convertAmericanOddsToProbability,
|
||||
decompressProbability,
|
||||
eloWinProbabilityWithParity,
|
||||
} from "~/services/probability-engine";
|
||||
import { llwsMatchNumber } from "~/lib/bracket-templates";
|
||||
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
import { positiveConfigNumber } from "./config-access";
|
||||
|
||||
|
|
@ -88,61 +68,19 @@ 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;
|
||||
const POOL_SIZE = 5; // teams per pool within each side
|
||||
|
||||
// ─── 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;
|
||||
/** Single-game strength on an Elo scale, decompressed from championship futures. */
|
||||
elo: number;
|
||||
/** Explicit pool ("A" or "B") if set in externalId; null if randomized. */
|
||||
fixedPool: "A" | "B" | null;
|
||||
/** Normalized championship win probability (0–1, vig removed). */
|
||||
oddsProb: number;
|
||||
}
|
||||
|
||||
interface PlacementCounts {
|
||||
|
|
@ -150,46 +88,21 @@ interface PlacementCounts {
|
|||
finalist: number;
|
||||
thirdPlace: number;
|
||||
fourthPlace: number;
|
||||
/** Lost the Elimination Final — the 5th–6th tier (1 per side per sim). */
|
||||
elimFinalLoser: number;
|
||||
/** Lost Elimination Round 4 — the 7th–8th tier (1 per side per sim). */
|
||||
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>;
|
||||
bracketLoser: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function zeroCounts(): PlacementCounts {
|
||||
return {
|
||||
champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0,
|
||||
elimFinalLoser: 0, elimRound4Loser: 0,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
return Math.random() < p1Win ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
|
||||
}
|
||||
|
||||
|
|
@ -205,163 +118,101 @@ function shuffle<T>(arr: T[]): T[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Assign teams to Pool A / Pool B for one side.
|
||||
* In fixed mode, respects the pre-set pool. In randomized mode, shuffles then splits.
|
||||
*/
|
||||
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);
|
||||
function assignPools(teams: Team[], randomized: boolean): [Team[], Team[]] {
|
||||
if (!randomized) {
|
||||
return [teams.filter((t) => t.fixedPool === "A"), teams.filter((t) => t.fixedPool === "B")];
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
const shuffled = shuffle([...teams]);
|
||||
return [shuffled.slice(0, 5), shuffled.slice(5)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Simulate round-robin pool play among 5 teams.
|
||||
* Returns the top 2 teams by win count (ties broken randomly).
|
||||
*/
|
||||
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 };
|
||||
function simulatePoolPlay(pool: Team[]): [Team, Team] {
|
||||
const wins = new Map<string, number>(pool.map((t) => [t.participantId, 0]));
|
||||
|
||||
// Each pair plays once.
|
||||
for (let i = 0; i < pool.length; i++) {
|
||||
for (let j = i + 1; j < pool.length; j++) {
|
||||
const { winner } = simGame(pool[i], pool[j]);
|
||||
wins.set(winner.participantId, (wins.get(winner.participantId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return simGame(t1, t2, parityFactor);
|
||||
|
||||
// Sort by wins descending; pre-generate a stable random tiebreaker per team so
|
||||
// the comparator is consistent (Math.random() inside a comparator is a bug — the
|
||||
// engine may call it multiple times per pair and get contradictory results).
|
||||
const tiebreaker = new Map(pool.map((t) => [t.participantId, Math.random()]));
|
||||
const ranked = pool.toSorted((a, b) => {
|
||||
const diff = (wins.get(b.participantId) ?? 0) - (wins.get(a.participantId) ?? 0);
|
||||
return diff !== 0 ? diff : (tiebreaker.get(a.participantId) ?? 0) - (tiebreaker.get(b.participantId) ?? 0);
|
||||
});
|
||||
|
||||
return [ranked[0], ranked[1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate one side's 10-team double-elimination bracket.
|
||||
* Simulate a 4-team double-elimination bracket for one side.
|
||||
*
|
||||
* `slots` holds the side's teams in bracket order, matching the llws_20 participant
|
||||
* 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.
|
||||
* Seeds (pool results):
|
||||
* poolA1 = Pool A winner, poolA2 = Pool A runner-up
|
||||
* poolB1 = Pool B winner, poolB2 = Pool B runner-up
|
||||
*
|
||||
* 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
|
||||
* WSF1 OP3w v WR2-1w WSF2 WR2-2w v OP4w
|
||||
* WF WSF1w v WSF2w → winner to the side championship
|
||||
* Elimination bracket (a loss here is final)
|
||||
* ER1-1 OP2l v OP3l ER1-2 OP1l v OP4l
|
||||
* ER2-1 WR2-1l v ER1-1w ER2-2 WR2-2l v ER1-2w
|
||||
* ER3-1 WSF1l v ER2-2w ER3-2 WSF2l v ER2-1w (cross-over)
|
||||
* ER4 ER3-2w v ER3-1w → loser is the 7th–8th tier
|
||||
* EF WFl v ER4w → loser is the 5th–6th tier
|
||||
* Side championship: WFw v EFw → loser drops to the consolation game
|
||||
* Bracket:
|
||||
* G1 (WB): A1 vs B2
|
||||
* G2 (WB): B1 vs A2
|
||||
* G3 (WB Final): G1W vs G2W
|
||||
* G4 (LB R1): G1L vs G2L → loser eliminated (bracketLoser)
|
||||
* G5 (LB Final): G3L vs G4W → loser eliminated (bracketLoser)
|
||||
* G6 (Side Championship): G3W vs G5W → loser eliminated (sideLoser)
|
||||
*
|
||||
* Note the double-chance path: the Winners Final loser is NOT out, it drops to the
|
||||
* 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.
|
||||
* Returns: { sideChampion, sideLoser }
|
||||
* bracketLosers (2) are bumped into counts directly.
|
||||
*/
|
||||
function simulateSideBracket(
|
||||
slots: Team[],
|
||||
bump: (id: string, key: keyof PlacementCounts) => void,
|
||||
play: PlayGame
|
||||
poolA1: Team,
|
||||
poolA2: Team,
|
||||
poolB1: Team,
|
||||
poolB2: Team,
|
||||
bump: (id: string, key: keyof PlacementCounts) => void
|
||||
): { sideChampion: Team; sideLoser: Team } {
|
||||
// ── Winners bracket ────────────────────────────────────────────────────────
|
||||
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 = play("Winners Round 2", 1, slots[8], op1.winner);
|
||||
const wr22 = play("Winners Round 2", 2, slots[9], op2.winner);
|
||||
// Winners bracket
|
||||
const g1 = simGame(poolA1, poolB2);
|
||||
const g2 = simGame(poolB1, poolA2);
|
||||
const g3 = simGame(g1.winner, g2.winner); // WB Final
|
||||
|
||||
const wsf1 = play("Winners Semifinals", 1, op3.winner, wr21.winner);
|
||||
const wsf2 = play("Winners Semifinals", 2, wr22.winner, op4.winner);
|
||||
// Losers bracket
|
||||
const g4 = simGame(g1.loser, g2.loser); // LB R1 — g4.loser eliminated
|
||||
bump(g4.loser.participantId, "bracketLoser");
|
||||
|
||||
const wf = play("Winners Final", 1, wsf1.winner, wsf2.winner);
|
||||
const g5 = simGame(g3.loser, g4.winner); // LB Final — g5.loser eliminated
|
||||
bump(g5.loser.participantId, "bracketLoser");
|
||||
|
||||
// ── Elimination bracket ────────────────────────────────────────────────────
|
||||
const er11 = play("Elimination Round 1", 1, op2.loser, op3.loser);
|
||||
const er12 = play("Elimination Round 1", 2, op1.loser, op4.loser);
|
||||
// Side championship
|
||||
const g6 = simGame(g3.winner, g5.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 = play("Elimination Round 3", 1, wsf1.loser, er22.winner);
|
||||
const er32 = play("Elimination Round 3", 2, wsf2.loser, er21.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 = play("Elimination Final", 1, wf.loser, er4.winner);
|
||||
bump(ef.loser.participantId, "elimFinalLoser"); // 5th–6th tier
|
||||
|
||||
// ── Side championship ──────────────────────────────────────────────────────
|
||||
const sideChampionship = play("Bracket Championship", 1, wf.winner, ef.winner);
|
||||
|
||||
return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser };
|
||||
return { sideChampion: g6.winner, sideLoser: g6.loser };
|
||||
}
|
||||
|
||||
// ─── Validation helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a participant's externalId into a side.
|
||||
*
|
||||
* The legacy pool-play suffixes ("US:A", "Intl:B", …) are still accepted so seasons
|
||||
* configured before the format change keep loading; the pool part is ignored because
|
||||
* the tournament no longer has pools.
|
||||
*/
|
||||
function parseExternalId(raw: string | null): { side: Side } | null {
|
||||
type PoolSuffix = "A" | "B" | null;
|
||||
|
||||
function parseExternalId(raw: string | null): { side: Side; pool: PoolSuffix } | null {
|
||||
if (!raw) return null;
|
||||
const side = raw.toUpperCase().split(":")[0];
|
||||
if (side === "US") return { side: "US" };
|
||||
if (side === "INTL") return { side: "Intl" };
|
||||
const upper = raw.toUpperCase();
|
||||
if (upper === "US") return { side: "US", pool: null };
|
||||
if (upper === "INTL") return { side: "Intl", pool: null };
|
||||
if (upper === "US:A") return { side: "US", pool: "A" };
|
||||
if (upper === "US:B") return { side: "US", pool: "B" };
|
||||
if (upper === "INTL:A") return { side: "Intl", pool: "A" };
|
||||
if (upper === "INTL:B") return { side: "Intl", pool: "B" };
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -369,155 +220,41 @@ 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.)
|
||||
* Determine whether pool assignments should be randomized for one side.
|
||||
* - If ALL teams on the side have a pool suffix → fixed pools (returns false).
|
||||
* - If NO teams have a pool suffix → randomized (returns true).
|
||||
* - Mixed → throws.
|
||||
* Also validates that fixed pools are split exactly POOL_SIZE / POOL_SIZE.
|
||||
*/
|
||||
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) {
|
||||
function determineRandomized(sideTeams: Team[], sideName: string): boolean {
|
||||
const withPool = sideTeams.filter((t) => t.fixedPool !== null);
|
||||
const withoutPool = sideTeams.filter((t) => t.fixedPool === null);
|
||||
if (withPool.length > 0 && withoutPool.length > 0) {
|
||||
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.`
|
||||
`${sideName} teams have mixed externalId formats: some have pool suffixes (e.g. "US:A") ` +
|
||||
`and some don't. Either all ${sideName} teams must have pool suffixes or none should.`
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
if (withPool.length === sideTeams.length) {
|
||||
const poolA = sideTeams.filter((t) => t.fixedPool === "A");
|
||||
const poolB = sideTeams.filter((t) => t.fixedPool === "B");
|
||||
if (poolA.length !== POOL_SIZE || poolB.length !== POOL_SIZE) {
|
||||
throw new Error(
|
||||
`${sideName} fixed pools must have exactly ${POOL_SIZE} teams each. ` +
|
||||
`Found Pool A: ${poolA.length}, Pool B: ${poolB.length}.`
|
||||
);
|
||||
}
|
||||
return false; // fixed pools
|
||||
}
|
||||
|
||||
return { slots, matches: byKey };
|
||||
return true; // randomized
|
||||
}
|
||||
|
||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -527,7 +264,6 @@ 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.
|
||||
|
|
@ -552,124 +288,104 @@ export class LLWSSimulator implements Simulator {
|
|||
.from(schema.seasonParticipantExpectedValues)
|
||||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||||
|
||||
// 3. Decompress the futures into single-game Elo ratings.
|
||||
const { elos, unpricedElo } = buildLLWSElos(evRows);
|
||||
const rawOddsMap = new Map<string, number>();
|
||||
for (const row of evRows) {
|
||||
if (row.sourceOdds !== null) {
|
||||
rawOddsMap.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Parse externalId for each participant to determine side and fixed pool.
|
||||
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) unparseableSides.push({ id: p.id, externalId: p.externalId });
|
||||
if (!parsed) {
|
||||
throw new Error(
|
||||
`Participant ${p.id} has invalid externalId "${p.externalId}". ` +
|
||||
`Expected: "US", "Intl", "US:A", "US:B", "Intl:A", or "Intl:B".`
|
||||
);
|
||||
}
|
||||
teams.push({
|
||||
// Provisional: a seeded bracket overwrites this below.
|
||||
participantId: p.id,
|
||||
side: parsed?.side ?? "Intl",
|
||||
elo: elos.get(p.id) ?? unpricedElo,
|
||||
side: parsed.side,
|
||||
fixedPool: parsed.pool,
|
||||
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
const teamsById = new Map(teams.map((t) => [t.participantId, t]));
|
||||
// Validate team counts per side.
|
||||
const usTeams = teams.filter((t) => t.side === "US");
|
||||
const intlTeams = teams.filter((t) => t.side === "Intl");
|
||||
|
||||
// 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}.`);
|
||||
}
|
||||
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}.`);
|
||||
}
|
||||
|
||||
const usPool = bracket ? bracket.slots.US : teams.filter((t) => t.side === "US");
|
||||
const intlPool = bracket ? bracket.slots.Intl : teams.filter((t) => t.side === "Intl");
|
||||
// Determine pool assignment mode for each side.
|
||||
const usRandomized = determineRandomized(usTeams, "US");
|
||||
const intlRandomized = determineRandomized(intlTeams, "International");
|
||||
|
||||
const playUS = makePlayGame(SIDE_INDEX.US, bracket, parityFactor);
|
||||
const playIntl = makePlayGame(SIDE_INDEX.Intl, bracket, parityFactor);
|
||||
|
||||
// 6. Initialise placement count accumulators for all participants.
|
||||
// 5. 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 counts = new Map<string, PlacementCounts>(
|
||||
allIds.map((id) => [id, { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }])
|
||||
);
|
||||
const bump = (id: string, key: keyof PlacementCounts) => {
|
||||
const entry = counts.get(id);
|
||||
if (entry) entry[key]++;
|
||||
};
|
||||
|
||||
// 7. Run Monte Carlo simulations.
|
||||
// 6. Run Monte Carlo simulations.
|
||||
for (let s = 0; s < numSimulations; s++) {
|
||||
// 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]);
|
||||
// Assign pools for this simulation.
|
||||
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
|
||||
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
|
||||
|
||||
// Pool play: top 2 from each pool advance.
|
||||
const [usA1, usA2] = simulatePoolPlay(usPoolA);
|
||||
const [usB1, usB2] = simulatePoolPlay(usPoolB);
|
||||
const [intlA1, intlA2] = simulatePoolPlay(intlPoolA);
|
||||
const [intlB1, intlB2] = simulatePoolPlay(intlPoolB);
|
||||
|
||||
// Double-elimination bracket per side.
|
||||
const { sideChampion: usChamp, sideLoser: usLose } =
|
||||
simulateSideBracket(usSlots, bump, playUS);
|
||||
simulateSideBracket(usA1, usA2, usB1, usB2, bump);
|
||||
const { sideChampion: intlChamp, sideLoser: intlLose } =
|
||||
simulateSideBracket(intlSlots, bump, playIntl);
|
||||
simulateSideBracket(intlA1, intlA2, intlB1, intlB2, bump);
|
||||
|
||||
// Consolation game: 3rd / 4th place.
|
||||
const consolation = playCrossoverGame(
|
||||
"Consolation Third Place", bracket, parityFactor, usLose, intlLose
|
||||
);
|
||||
const consolation = simGame(usLose, intlLose);
|
||||
bump(consolation.winner.participantId, "thirdPlace");
|
||||
bump(consolation.loser.participantId, "fourthPlace");
|
||||
|
||||
// World Championship: 1st / 2nd place.
|
||||
const ws = playCrossoverGame(
|
||||
"World Championship", bracket, parityFactor, usChamp, intlChamp
|
||||
);
|
||||
// World Series: 1st / 2nd place.
|
||||
const ws = simGame(usChamp, intlChamp);
|
||||
bump(ws.winner.participantId, "champion");
|
||||
bump(ws.loser.participantId, "finalist");
|
||||
}
|
||||
|
||||
// 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.
|
||||
const tierDivisor = 2 * numSimulations;
|
||||
// 7. Convert counts to probability distributions.
|
||||
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
|
||||
const bracketLosersPerSim = 4;
|
||||
const bracketDivisor = bracketLosersPerSim * numSimulations;
|
||||
|
||||
const empty = zeroCounts();
|
||||
const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 };
|
||||
return allIds.map((id) => {
|
||||
const c = counts.get(id) ?? empty;
|
||||
const upperTier = c.elimFinalLoser / tierDivisor; // 5th–6th
|
||||
const lowerTier = c.elimRound4Loser / tierDivisor; // 7th–8th
|
||||
const c = counts.get(id) ?? zeroCounts;
|
||||
const bracketProb = c.bracketLoser / bracketDivisor;
|
||||
return {
|
||||
participantId: id,
|
||||
probabilities: {
|
||||
|
|
@ -677,10 +393,10 @@ export class LLWSSimulator implements Simulator {
|
|||
probSecond: c.finalist / numSimulations,
|
||||
probThird: c.thirdPlace / numSimulations,
|
||||
probFourth: c.fourthPlace / numSimulations,
|
||||
probFifth: upperTier,
|
||||
probSixth: upperTier,
|
||||
probSeventh: lowerTier,
|
||||
probEighth: lowerTier,
|
||||
probFifth: bracketProb,
|
||||
probSixth: bracketProb,
|
||||
probSeventh: bracketProb,
|
||||
probEighth: bracketProb,
|
||||
},
|
||||
source: "llws_monte_carlo",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,25 +34,6 @@ export interface SimulatorManifestProfile {
|
|||
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
|
||||
setupSections: SimulatorSetupSection[];
|
||||
minParticipantInputs?: number;
|
||||
/**
|
||||
* The simulator reads the season's generated bracket: it seeds from the real draw and
|
||||
* replays completed matches from their recorded result, rather than re-drawing the field
|
||||
* and re-playing decided games every iteration.
|
||||
*
|
||||
* updateProbabilitiesAfterResult reads this to decide whether a result should be absorbed
|
||||
* by re-running the simulator or by the generic ICM recalculation. Re-running is both more
|
||||
* accurate and the only option that respects a banked placement floor, but it is only safe
|
||||
* here: re-running a bracket-blind simulator would re-draw the field and hand equity back
|
||||
* to teams already knocked out.
|
||||
*
|
||||
* Both halves are required. A simulator that reads the draw but re-simulates games already
|
||||
* played is NOT bracket-aware for this purpose — it resurrects eliminated teams just the
|
||||
* same. Check for an `isComplete`/`winnerId` replay before setting this on a new simulator.
|
||||
*
|
||||
* This is deliberately separate from `setupSections: ["bracket"]`, which only drives admin
|
||||
* links and a readiness warning and does not track this accurately in either direction.
|
||||
*/
|
||||
bracketAware?: boolean;
|
||||
}
|
||||
|
||||
const BASE_CONFIG = {
|
||||
|
|
@ -90,7 +71,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds"],
|
||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
ncaam_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
|
||||
|
|
@ -98,7 +78,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
||||
derivableInputs: { rating: ["sourceOdds"] },
|
||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
ncaaw_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
|
||||
|
|
@ -106,7 +85,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds", "seed", "region"],
|
||||
derivableInputs: { rating: ["sourceOdds"] },
|
||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
nba_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
|
||||
|
|
@ -114,7 +92,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
nhl_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
|
||||
|
|
@ -122,7 +99,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
nfl_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
|
||||
|
|
@ -136,10 +112,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins"] },
|
||||
// The bracket is optional — before one exists the ladder is projected from Elo — but once
|
||||
// it is drawn the simulator seeds from it and honors completed results.
|
||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||
bracketAware: true,
|
||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||
},
|
||||
epl_standings: {
|
||||
defaultConfig: {
|
||||
|
|
@ -162,7 +135,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["worldRanking", "seed"],
|
||||
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
tennis_qualifying_points: {
|
||||
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
|
||||
|
|
@ -171,7 +143,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
setupSections: ["participants", "surfaceElo", "events"],
|
||||
},
|
||||
mlb_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, projectedWinsWeight: 1, inputPolicy: { oddsWeight: 0.3 } },
|
||||
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, inputPolicy: { oddsWeight: 0.3 } },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
|
|
@ -190,21 +162,18 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
|
||||
bracketAware: true,
|
||||
},
|
||||
darts_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
|
||||
requiredInputs: ["sourceElo", "worldRanking"],
|
||||
optionalInputs: ["seed"],
|
||||
setupSections: ["participants", "eloRatings", "rankings"],
|
||||
bracketAware: true,
|
||||
},
|
||||
cs2_major_qualifying_points: {
|
||||
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["worldRanking", "metadata"],
|
||||
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
||||
bracketAware: true,
|
||||
},
|
||||
ncaa_football_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
|
||||
|
|
@ -214,13 +183,10 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
|
||||
},
|
||||
llws_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 550, usTeamCount: 10, internationalTeamCount: 10 },
|
||||
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10, poolSize: 5 },
|
||||
requiredInputs: ["sourceOdds"],
|
||||
optionalInputs: ["metadata"],
|
||||
// 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"],
|
||||
bracketAware: true,
|
||||
setupSections: ["participants", "futuresOdds"],
|
||||
},
|
||||
college_hockey_bracket: {
|
||||
// College hockey blends odds into Elo internally (and also uses NPI rank,
|
||||
|
|
@ -233,7 +199,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
brackt: {
|
||||
defaultConfig: { iterations: 20_000 },
|
||||
|
|
@ -257,7 +222,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
mls_bracket: {
|
||||
defaultConfig: {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@
|
|||
* 1. Load all participants for the sports season from DB
|
||||
* 2. Load current standings (wins, gamesPlayed) from regularSeasonStandings
|
||||
* 3. Load sourceElo ratings from seasonParticipantExpectedValues
|
||||
* 4. Load raw projected win totals from seasonParticipantSimulatorInputs
|
||||
* 5. Match participant names to hardcoded team data (RDif + league/division)
|
||||
* 6. For each simulation:
|
||||
* 4. Match participant names to hardcoded team data (RDif + league/division)
|
||||
* 5. For each simulation:
|
||||
* a. For each league (AL/NL), simulate remaining regular season games for
|
||||
* every team using Binomial sampling, giving final projected wins.
|
||||
* b. Division winner = best record in each division (3 per league).
|
||||
|
|
@ -22,8 +21,8 @@
|
|||
* - Division Series (best-of-5): 1 vs lowest WC survivor, 2 vs other
|
||||
* - League Championship Series (best-of-7)
|
||||
* e. World Series (best-of-7): AL champ vs NL champ
|
||||
* 7. Track placement counts per scoring tier
|
||||
* 8. Convert counts to probability distributions
|
||||
* 6. Track placement counts per scoring tier
|
||||
* 7. Convert counts to probability distributions
|
||||
*
|
||||
* Win probability (log5 formula):
|
||||
* Step 1 — convert projected RDif to win rate for playoff matchups:
|
||||
|
|
@ -33,24 +32,17 @@
|
|||
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
|
||||
*
|
||||
* Regular season simulation (seeding):
|
||||
* Each team's base per-game win rate is derived from sourceElo (if set) or
|
||||
* Each team's raw per-game win rate is derived from sourceElo (if set) or
|
||||
* from the hardcoded RDif using SEEDING_RDIF_SCALE ≈ 10 runs/win × 162 games.
|
||||
* When the resolved Elo came from a projected win total and nothing else, that
|
||||
* base rate is replaced by the rest-of-season rate that reaches the projection:
|
||||
* target = (projectedWins − currentWins) / remainingGames
|
||||
* (see seedingWinRateFor; config `projectedWinsWeight` blends it back toward the
|
||||
* base rate). Pre-season the two rates coincide, so this is a no-op then. A
|
||||
* projection that lost the baseEloPriority race, or that was blended with futures
|
||||
* odds, is left to the resolved Elo — see projectionForSeeding.
|
||||
* Remaining games = TOTAL_SEASON_GAMES − gamesPlayed are drawn from a
|
||||
* Binomial distribution. This makes playoff seeding respond to both current
|
||||
* standings and user-entered projected wins.
|
||||
*
|
||||
* Input resolution:
|
||||
* sourceElo is the single Elo produced by the shared input policy — already a
|
||||
* blend of any raw Elo / projections / futures odds, written by
|
||||
* prepareSimulatorInputsForRun before the run. This simulator does not blend
|
||||
* futures odds itself.
|
||||
* Futures blending:
|
||||
* If sourceOdds are stored in participantExpectedValues for this season,
|
||||
* the per-game win probability for playoff series is blended:
|
||||
* P(game) = RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb
|
||||
* RDIF_WEIGHT = 0.7, ODDS_WEIGHT = 0.3.
|
||||
*
|
||||
* Placement tiers → SimulationProbabilities mapping:
|
||||
* probFirst = World Series champion (1 per sim)
|
||||
|
|
@ -82,10 +74,9 @@ import { database } from "~/database/context";
|
|||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
import { configNumber, positiveConfigNumber } from "./config-access";
|
||||
import { positiveConfigNumber } from "./config-access";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||
import { getParticipantSimulatorInputs } from "~/models/simulator";
|
||||
|
||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -109,13 +100,6 @@ const RDIF_DIVISOR = 8000;
|
|||
*/
|
||||
const SEEDING_RDIF_SCALE = 1620;
|
||||
|
||||
/**
|
||||
* Default weight given to a user-entered projected win total when deriving the
|
||||
* rest-of-season win rate. 1 = the projection is authoritative; 0 = ignore it and
|
||||
* use the Elo-implied rate. Overridable per season via config `projectedWinsWeight`.
|
||||
*/
|
||||
const DEFAULT_PROJECTED_WINS_WEIGHT = 1;
|
||||
|
||||
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
|
||||
//
|
||||
// rdif: Projected run differential from FanGraphs Depth Charts.
|
||||
|
|
@ -222,93 +206,13 @@ export function rawWinRateFromElo(elo: number): number {
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert an Elo rating to an equivalent projected run differential, on the same
|
||||
* scale as the hardcoded TEAMS_DATA.rdif values.
|
||||
*
|
||||
* Uses the standard Elo win probability formula (parity factor 400, average Elo
|
||||
* 1500) and inverts rawWinRateFromRDif: rdif = (winRate − 0.5) × SEEDING_RDIF_SCALE.
|
||||
*
|
||||
* SEEDING_RDIF_SCALE — not RDIF_DIVISOR — is deliberate. Scaling by RDIF_DIVISOR
|
||||
* would make this the exact algebraic inverse of winRateFromRDif, so a team with
|
||||
* an Elo would skip the playoff-parity compression that every hardcoded-rdif team
|
||||
* gets: a 95-win projection (Elo ≈ 1561) mapped to RDif +686 and played playoff
|
||||
* games at .586 instead of the ~.517 documented on RDIF_DIVISOR. On this scale it
|
||||
* maps to ≈ +140 — right alongside the Dodgers' hardcoded +137 — and
|
||||
* winRateFromRDif then compresses it to ≈ .5175 like any other team.
|
||||
*
|
||||
* Convert an Elo rating to an equivalent projected run differential.
|
||||
* Uses the standard Elo win probability formula (parity factor 400, average Elo 1500),
|
||||
* then inverts the winRateFromRDif formula: rdif = (winRate − 0.5) × RDIF_DIVISOR.
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function eloToRDif(elo: number): number {
|
||||
return (rawWinRateFromElo(elo) - 0.5) * SEEDING_RDIF_SCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The projected win total seeding should use, or null to leave seeding on the Elo.
|
||||
*
|
||||
* `prepareSimulatorInputsForRun` records which source won the base-Elo race in
|
||||
* `metadata.sourceEloMethod`, and only `"projectedWins"` means the resolved Elo is
|
||||
* the projection and nothing else. Every other method has to be left alone:
|
||||
*
|
||||
* - `"direct"` — the season's `baseEloPriority` put a hand-entered Elo ahead of
|
||||
* the projection. Honouring the projection here anyway would ignore it as the
|
||||
* Elo source while still letting it dictate seeding.
|
||||
* - `"blend"` / `"sourceOdds"` — futures odds are folded into the Elo at
|
||||
* `oddsWeight` (0.3 for MLB). Seeding off the raw projection would discard that
|
||||
* blend and run seeding and playoff matchups on two different strength scales.
|
||||
* - a fallback — the participant had no usable input of its own.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function projectionForSeeding(
|
||||
projectedWins: number | null,
|
||||
metadata: Record<string, unknown> | null | undefined
|
||||
): number | null {
|
||||
return metadata?.sourceEloMethod === "projectedWins" ? projectedWins : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-game win rate to use for a team's remaining regular-season games.
|
||||
*
|
||||
* A user-entered `projectedWins` is a projected *final* season win total, so the
|
||||
* rate that reproduces it is spread over the games still to play:
|
||||
*
|
||||
* target = (projectedWins − currentWins) / remainingGames
|
||||
*
|
||||
* Pre-season this is a no-op — with currentWins 0 and remainingGames 162 the
|
||||
* target equals projectedWins / 162, which is exactly the rate the Elo derived
|
||||
* from that projection already encodes. Mid-season it is what makes the
|
||||
* simulation actually land on the projection: a team at 60-50 projected for 95
|
||||
* needs .673 over its last 52 games, not the .586 its season-long Elo implies.
|
||||
*
|
||||
* A target outside (0, 1) is proof the projection has gone stale rather than a
|
||||
* reason to bet everything on it: a 96-40 team projected for 95 would need a
|
||||
* negative rate, and a 40-70 team projected for 95 would need better than 1.000.
|
||||
* Both fall back to the Elo rate — clamping them instead would simulate a team to
|
||||
* stop winning entirely, or to win out. NLL takes the same escape hatch
|
||||
* (`nll-simulator.ts` clamps its prior at 0 and then uses the Elo rate outright).
|
||||
*
|
||||
* `weight` (config `projectedWinsWeight`, default 1) blends the target back toward
|
||||
* the Elo-implied rate. At 1 the projection is authoritative wherever it is still
|
||||
* reachable; lower values hedge it; 0 or less ignores it. Values above 1 are
|
||||
* clamped — this is a blend weight, like inputPolicy.oddsWeight, and above 1 it
|
||||
* would extrapolate past the target rather than blending toward it.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function seedingWinRateFor(
|
||||
eloRate: number,
|
||||
projectedWins: number | null,
|
||||
currentWins: number,
|
||||
remainingGames: number,
|
||||
weight: number = DEFAULT_PROJECTED_WINS_WEIGHT
|
||||
): number {
|
||||
if (projectedWins === null || remainingGames <= 0 || weight <= 0) return eloRate;
|
||||
const target = (projectedWins - currentWins) / remainingGames;
|
||||
if (target <= 0 || target >= 1) return eloRate;
|
||||
// Clamped here rather than at the call site so the blend cannot be turned into an
|
||||
// extrapolation by a stray config value, whichever caller supplies it.
|
||||
const blend = Math.min(1, weight);
|
||||
return blend * target + (1 - blend) * eloRate;
|
||||
return (rawWinRateFromElo(elo) - 0.5) * RDIF_DIVISOR;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -366,8 +270,6 @@ interface TeamEntry {
|
|||
originalSeed?: number;
|
||||
currentWins: number; // from regularSeasonStandings (0 pre-season)
|
||||
remainingGames: number; // TOTAL_SEASON_GAMES - gamesPlayed
|
||||
/** User-entered projected *final* season win total, or null when not set. */
|
||||
projectedWins: number | null;
|
||||
}
|
||||
|
||||
/** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */
|
||||
|
|
@ -541,10 +443,6 @@ function simLeagueBracket(
|
|||
export class MLBSimulator implements Simulator {
|
||||
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||
// configNumber (not positiveConfigNumber) so an explicit 0 — ignore projections,
|
||||
// use the Elo-implied rate — is honored rather than falling back to the default.
|
||||
// seedingWinRateFor clamps the upper end; the knob is free-form on the Engine card.
|
||||
const projectedWinsWeight = configNumber(config, "projectedWinsWeight", DEFAULT_PROJECTED_WINS_WEIGHT);
|
||||
const db = database();
|
||||
|
||||
// 1. Load all participants for this sports season.
|
||||
|
|
@ -567,18 +465,6 @@ export class MLBSimulator implements Simulator {
|
|||
const standings = await getRegularSeasonStandings(sportsSeasonId);
|
||||
const standingsByParticipantId = new Map(standings.map((s) => [s.participantId, s]));
|
||||
|
||||
// 3. Load the raw projected win totals, keeping only those that actually
|
||||
// produced the resolved Elo. The Elo encodes the projection as a season-long
|
||||
// rate; the raw total is what lets seeding spread the *remaining* wins
|
||||
// correctly once games have been played — see projectionForSeeding.
|
||||
const simInputs = await getParticipantSimulatorInputs(sportsSeasonId);
|
||||
const projectedWinsMap = new Map(
|
||||
simInputs.map((input) => [
|
||||
input.participantId,
|
||||
projectionForSeeding(input.projectedWins, input.metadata),
|
||||
])
|
||||
);
|
||||
|
||||
const teams: TeamEntry[] = participantRows.map((r) => {
|
||||
const standing = standingsByParticipantId.get(r.id);
|
||||
const gamesPlayed = standing?.gamesPlayed ?? 0;
|
||||
|
|
@ -588,7 +474,6 @@ export class MLBSimulator implements Simulator {
|
|||
data: getTeamData(r.name),
|
||||
currentWins: standing?.wins ?? 0,
|
||||
remainingGames: Math.max(0, TOTAL_SEASON_GAMES - gamesPlayed),
|
||||
projectedWins: projectedWinsMap.get(r.id) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -661,28 +546,11 @@ export class MLBSimulator implements Simulator {
|
|||
|
||||
/**
|
||||
* Raw per-game win rate for regular-season seeding simulation.
|
||||
*
|
||||
* The base rate comes from sourceElo when available, else from the hardcoded
|
||||
* rdif via SEEDING_RDIF_SCALE (Pythagorean approximation). A user-entered
|
||||
* projected win total then re-expresses that as a rest-of-season target so the
|
||||
* projection is actually reached mid-season — see seedingWinRateFor.
|
||||
*
|
||||
* The result depends only on fixed per-team inputs, so it is resolved once here
|
||||
* rather than on every one of the ~1.5M calls the seeding loop makes.
|
||||
* Uses sourceElo-derived rate if available; falls back to hardcoded rdif
|
||||
* with SEEDING_RDIF_SCALE (Pythagorean approximation).
|
||||
*/
|
||||
const seedingWinRateMap = new Map(
|
||||
teams.map((team) => [
|
||||
team.id,
|
||||
seedingWinRateFor(
|
||||
rawWinRateMap.get(team.id) ?? rawWinRateFromRDif(getEntryRDif(team)),
|
||||
team.projectedWins,
|
||||
team.currentWins,
|
||||
team.remainingGames,
|
||||
projectedWinsWeight
|
||||
),
|
||||
])
|
||||
);
|
||||
const seedingWinRate = (entry: TeamEntry): number => seedingWinRateMap.get(entry.id) ?? 0.5;
|
||||
const seedingWinRate = (entry: TeamEntry): number =>
|
||||
rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry));
|
||||
|
||||
/**
|
||||
* Per-game win probability for team A over team B in a playoff series, from
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
/**
|
||||
* 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,7 +9,6 @@
|
|||
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";
|
||||
|
|
@ -63,6 +62,20 @@ 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;
|
||||
|
|
@ -155,7 +168,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). 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.",
|
||||
description: "Simulates the 20-team Little League World Series: pool play round-robin (5 teams/pool, top 2 advance) → 4-team double-elimination bracket per side (US & International) → consolation game (3rd/4th) → World Series. Uses championship futures odds for all win probabilities. Set externalId to 'US'/'Intl' (randomized pools) or 'US:A'/'US:B'/'Intl:A'/'Intl:B' (fixed pools).",
|
||||
},
|
||||
create: () => new LLWSSimulator(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -62,29 +62,6 @@ async function getPersistenceContext(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Side effects a caller can opt out of.
|
||||
*
|
||||
* A simulation run does three jobs — recompute probabilities, recalculate standings, and record
|
||||
* the day's EV snapshot. `updateProbabilitiesAfterResult` wants only the first: it runs inside
|
||||
* the result path, where the caller recalculates standings itself immediately afterwards.
|
||||
*
|
||||
* Letting the run recalculate there is not merely redundant, it is wrong.
|
||||
* recalculateAffectedLeagues detects change by snapshotting teamStandings, recalculating, then
|
||||
* diffing, and that diff gates the Discord standings post; a recalculation slipped in
|
||||
* beforehand makes the diff empty and silently suppresses the notification. recalculateStandings
|
||||
* also rolls previousRank forward on every call, so an extra one erases rank movement.
|
||||
*/
|
||||
export interface RunSportsSeasonSimulationOptions {
|
||||
/** Leave standings to the caller. */
|
||||
skipStandingsRecalc?: boolean;
|
||||
/**
|
||||
* Skip the daily EV snapshot. The snapshot is a per-day series keyed by snapshotDate, so
|
||||
* writing it on every match result just overwrites the day's row with intra-day values.
|
||||
*/
|
||||
skipSnapshots?: boolean;
|
||||
}
|
||||
|
||||
export interface RunSportsSeasonSimulationResult {
|
||||
sportsSeasonId: string;
|
||||
simulatorType: SimulatorType;
|
||||
|
|
@ -94,8 +71,7 @@ export interface RunSportsSeasonSimulationResult {
|
|||
}
|
||||
|
||||
export async function runSportsSeasonSimulation(
|
||||
sportsSeasonId: string,
|
||||
options: RunSportsSeasonSimulationOptions = {}
|
||||
sportsSeasonId: string
|
||||
): Promise<RunSportsSeasonSimulationResult> {
|
||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||
if (!sportsSeason) {
|
||||
|
|
@ -159,33 +135,29 @@ export async function runSportsSeasonSimulation(
|
|||
})),
|
||||
]);
|
||||
|
||||
if (!options.skipStandingsRecalc) {
|
||||
const seasonSports = await database().query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
||||
}
|
||||
const seasonSports = await database().query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
||||
|
||||
const snapshotDate = new Date().toISOString().slice(0, 10);
|
||||
if (!options.skipSnapshots) {
|
||||
await batchUpsertParticipantEvSnapshots(
|
||||
results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
snapshotDate,
|
||||
probFirst: r.probabilities.probFirst,
|
||||
probSecond: r.probabilities.probSecond,
|
||||
probThird: r.probabilities.probThird,
|
||||
probFourth: r.probabilities.probFourth,
|
||||
probFifth: r.probabilities.probFifth,
|
||||
probSixth: r.probabilities.probSixth,
|
||||
probSeventh: r.probabilities.probSeventh,
|
||||
probEighth: r.probabilities.probEighth,
|
||||
calculatedEV: calculateEV(r.probabilities, persistence.scoringRules),
|
||||
source: r.source,
|
||||
}))
|
||||
);
|
||||
}
|
||||
await batchUpsertParticipantEvSnapshots(
|
||||
results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
snapshotDate,
|
||||
probFirst: r.probabilities.probFirst,
|
||||
probSecond: r.probabilities.probSecond,
|
||||
probThird: r.probabilities.probThird,
|
||||
probFourth: r.probabilities.probFourth,
|
||||
probFifth: r.probabilities.probFifth,
|
||||
probSixth: r.probabilities.probSixth,
|
||||
probSeventh: r.probabilities.probSeventh,
|
||||
probEighth: r.probabilities.probEighth,
|
||||
calculatedEV: calculateEV(r.probabilities, persistence.scoringRules),
|
||||
source: r.source,
|
||||
}))
|
||||
);
|
||||
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
||||
|
||||
|
|
|
|||
122
app/test/fixtures/llws-bracket.ts
vendored
122
app/test/fixtures/llws-bracket.ts
vendored
|
|
@ -1,122 +0,0 @@
|
|||
/**
|
||||
* 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"],
|
||||
};
|
||||
|
|
@ -91,62 +91,13 @@ Keep specialized pages when they provide real workflow value, such as Golf Skill
|
|||
|
||||
## Input Policies
|
||||
|
||||
Direct ratings are preferred by default. If a simulator declares derived inputs, readiness may also pass with those alternatives:
|
||||
Direct ratings are always preferred. If a simulator declares derived inputs, readiness may also pass with those alternatives:
|
||||
|
||||
- `projectedWins` can become Elo using `seasonGames` and `parityFactor` from season config.
|
||||
- `projectedTablePoints` can become Elo using `seasonGames`, `maxTablePoints`, and `parityFactor`.
|
||||
- `sourceOdds` can become Elo through the shared futures-to-Elo conversion.
|
||||
- `sourceOdds` can become a generic `rating` when the simulator declares `derivableInputs: { rating: ["sourceOdds"] }`.
|
||||
|
||||
### Raw Elo vs. projections
|
||||
|
||||
Raw Elo and projections are *substitutes*, not a blend: `inputPolicy.baseEloPriority`
|
||||
lists them in order and the first source a participant has wins outright. The
|
||||
default is `["sourceElo", "projectedWins", "projectedTablePoints"]`, so a stored Elo
|
||||
beats a projection. Set the Base Elo Source control on the simulator page (or
|
||||
`baseEloPriority` directly) to `["projectedWins", "sourceElo"]` when projections are
|
||||
the season's source of truth. Futures odds are separate — they blend on top of
|
||||
whichever base won, weighted by `inputPolicy.oddsWeight`.
|
||||
|
||||
Whenever you write a projection without an explicit Elo, stamp
|
||||
`metadata.sourceEloMethod` (`"projectedWins"` / `"projectedTablePoints"`) on the
|
||||
row. `getParticipantSimulatorInputs` reads that flag and returns `sourceElo: null`
|
||||
so the Elo is re-derived from the projection on every run. Skip it and the
|
||||
non-destructive upsert leaves the previous Elo in place as a *direct* value, which
|
||||
then wins the priority race — the projection is stored and silently ignored. Both
|
||||
the Elo Ratings page's projections mode and the simulator page's CSV importer do
|
||||
this; any new importer must too.
|
||||
|
||||
Projections are stored and displayed exactly as entered. Never round-trip one
|
||||
through its derived Elo for display: the conversion rounds to an integer Elo, and a
|
||||
run re-resolves that Elo through the input policy (clamping, plus any futures
|
||||
blend), so the number the admin sees drifts away from the number they typed.
|
||||
|
||||
### Mid-season projections
|
||||
|
||||
A projected win total is a projected *final* total. A simulator that seeds from
|
||||
projections mid-season must spread the difference over the games still to play —
|
||||
`(projectedWins - currentWins) / remainingGames` — rather than reusing the
|
||||
season-long rate the derived Elo encodes, or it will never reach the projection.
|
||||
See `seedingWinRateFor` in `mlb-simulator.ts` (config knob `projectedWinsWeight`,
|
||||
1 = the projection is authoritative) and `simulateRegularSeasonSeeds` in
|
||||
`nll-simulator.ts` (which additionally decays a preseason prior as the season
|
||||
completes).
|
||||
|
||||
Two guards belong on any such rest-of-season rate:
|
||||
|
||||
- **A target outside `(0, 1)` means the projection is stale** — the team has
|
||||
already met it, or can no longer reach it. Fall back to the Elo rate. Clamping to
|
||||
a floor or ceiling instead simulates a team to stop winning entirely, or to win
|
||||
out, and collapses its seeding variance.
|
||||
- **Only apply a projection that actually produced the resolved Elo.** Check
|
||||
`metadata.sourceEloMethod === "projectedWins"` (see `projectionForSeeding` in
|
||||
`mlb-simulator.ts`). Any other method means the Elo represents something else: a
|
||||
hand-entered Elo that won the `baseEloPriority` race, or a futures blend. Seeding
|
||||
off the raw projection in those cases makes the projection simultaneously ignored
|
||||
as the Elo source and authoritative for the standings, and runs seeding and
|
||||
playoff matchups on two different strength scales.
|
||||
|
||||
Missing tail participants must remain blocked unless the season config explicitly chooses an `inputPolicy.missingEloStrategy`:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ Sentry.init({
|
|||
enabled: process.env.NODE_ENV === "production",
|
||||
sendDefaultPii: true,
|
||||
tracesSampleRate: 0,
|
||||
ignoreErrors: [
|
||||
/No route matches URL ".*\.css"/,
|
||||
/No route matches URL ".*\.js"/,
|
||||
/No route matches URL ".*\.(php|env|xml|aspx|asp|bak|sql|ini)"/i,
|
||||
/No route matches URL ".*\/(wp-admin|wp-login|phpmyadmin|xmlrpc)"/i,
|
||||
],
|
||||
beforeSend(event) {
|
||||
const msg = event.exception?.values?.[0]?.value ?? "";
|
||||
// Drop React Flight protocol probe errors (e.g. $1:aa:aa in multipart body)
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
/**
|
||||
* Repair: re-seed AFL Wildcard winners into the Elimination Finals they belong in.
|
||||
*
|
||||
* Brackets advanced before the re-seeding fix crossed each Wildcard winner into a fixed
|
||||
* Elimination Final — the 7v10 winner always met 6th and the 8v9 winner always met 5th —
|
||||
* instead of pairing them by ladder position (5th hosts the lower-ranked winner). The
|
||||
* fix only changes how new results advance, so an already-advanced bracket keeps its
|
||||
* wrong pairings until this runs. The admin UI cannot re-trigger it: a completed match
|
||||
* renders as "Complete", with no way to re-submit the winner.
|
||||
*
|
||||
* This runs the same reseedAflEliminationFinals the advancement path now uses, so it
|
||||
* makes exactly the correction a fresh bracket would have. It only ever moves Wildcard
|
||||
* teams between the two Elimination Final slots — no results, scores or placements are
|
||||
* touched, and nothing else in the bracket is written. A bracket that is already correct
|
||||
* is left alone.
|
||||
*
|
||||
* Admin → the event's bracket has a "Re-seed Wildcard Winners" button that does exactly
|
||||
* this for one event; use this script to sweep every afl_10 event, or where the UI is not
|
||||
* to hand.
|
||||
*
|
||||
* If an Elimination Final has already been played, its qualifier cannot be moved without
|
||||
* rewriting who contested a recorded result; the script reports that event and skips it.
|
||||
* Clear and regenerate that bracket in Admin instead, then Reprocess Bracket.
|
||||
*
|
||||
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
|
||||
*
|
||||
* npx tsx scripts/fix-afl-wildcard-reseed.ts # apply to every afl_10 event
|
||||
* npx tsx scripts/fix-afl-wildcard-reseed.ts --dry # report only
|
||||
* npx tsx scripts/fix-afl-wildcard-reseed.ts --event <id> # one event
|
||||
*/
|
||||
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "../database/schema.js";
|
||||
import { DatabaseContext, database } from "../database/context.js";
|
||||
import {
|
||||
findPlayoffMatchesByEventIdAndRound,
|
||||
reseedAflEliminationFinals,
|
||||
} from "../app/models/playoff-match.js";
|
||||
import { findParticipantsBySportsSeasonId } from "../app/models/season-participant.js";
|
||||
|
||||
const DRY = process.argv.includes("--dry");
|
||||
const eventFlag = process.argv.indexOf("--event");
|
||||
const ONLY_EVENT = eventFlag === -1 ? null : process.argv[eventFlag + 1];
|
||||
const log = (...a: unknown[]) => console.log(...a);
|
||||
|
||||
async function run() {
|
||||
const db = database();
|
||||
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: eq(schema.scoringEvents.bracketTemplateId, "afl_10"),
|
||||
});
|
||||
const targets = ONLY_EVENT ? events.filter((e) => e.id === ONLY_EVENT) : events;
|
||||
|
||||
if (ONLY_EVENT && targets.length === 0) {
|
||||
log(`No afl_10 event with id ${ONLY_EVENT}.`);
|
||||
return;
|
||||
}
|
||||
log(`afl_10 events to check: ${targets.length}`);
|
||||
|
||||
let fixed = 0;
|
||||
let alreadyRight = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const event of targets) {
|
||||
const name = event.name ?? event.id;
|
||||
|
||||
const participants = await findParticipantsBySportsSeasonId(event.sportsSeasonId);
|
||||
const nameOf = (id: string | null) =>
|
||||
id === null ? "TBD" : participants.find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
/** "M1: <host> vs <qualifier>" for both Elimination Finals. */
|
||||
const pairings = async () => {
|
||||
const efMatches = await findPlayoffMatchesByEventIdAndRound(event.id, "Elimination Finals");
|
||||
return efMatches
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
.map((m) => `M${m.matchNumber}: ${nameOf(m.participant1Id)} vs ${nameOf(m.participant2Id)}`)
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
try {
|
||||
const before = await pairings();
|
||||
|
||||
// The dry run still resolves the pairings — it just reports them instead of writing.
|
||||
if (DRY) {
|
||||
const efMatches = await findPlayoffMatchesByEventIdAndRound(event.id, "Elimination Finals");
|
||||
const wcMatches = await findPlayoffMatchesByEventIdAndRound(event.id, "Wildcard Round");
|
||||
const decided = wcMatches.filter((m) => m.isComplete && m.winnerId).length;
|
||||
log(` ${name}: ${before} (${decided}/${wcMatches.length} Wildcard results, ` +
|
||||
`${efMatches.filter((m) => m.isComplete).length} Elimination Final(s) played)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reseed = await reseedAflEliminationFinals(event.id);
|
||||
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
|
||||
alreadyRight += 1;
|
||||
log(` = ${name}: already correct — ${before}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
fixed += 1;
|
||||
log(` ~ ${name}:`);
|
||||
log(` was: ${before}`);
|
||||
log(` now: ${await pairings()}`);
|
||||
} catch (e) {
|
||||
skipped += 1;
|
||||
log(` ! ${name}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (DRY) {
|
||||
log("\nDry run — no writes. Re-run without --dry to apply.");
|
||||
return;
|
||||
}
|
||||
log(`\nDone. re-seeded=${fixed}, already correct=${alreadyRight}, skipped=${skipped}.`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (!dbUrl) {
|
||||
console.error("ERROR: DATABASE_URL is required");
|
||||
process.exit(1);
|
||||
}
|
||||
const client = postgres(dbUrl, { max: 1 });
|
||||
const db = drizzle(client, { schema });
|
||||
try {
|
||||
await DatabaseContext.run(db, run);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -11,11 +11,9 @@ export const app = express();
|
|||
|
||||
app.use((_, __, next) => DatabaseContext.run(db, next));
|
||||
|
||||
// Block common bot probe paths before React Router (and Sentry) see them.
|
||||
// `blog` is here only because scanners hammer /blog/wp/v2/* — drop it from this
|
||||
// list if a real blog route is ever added.
|
||||
// Block common bot probe paths before React Router (and Sentry) see them
|
||||
const BOT_PROBE_RE =
|
||||
/\.(php|env|htaccess|aspx|asp|jsp|config|bak|sql|ini|swp|DS_Store)$|^\/(wp-admin|wp-login|phpmyadmin|xmlrpc|server-status|cgi-bin|shell|cmd|console|actuator|blog)(\/|$)/i;
|
||||
/\.(php|env|htaccess|aspx|asp|jsp|config|bak|sql|ini|swp|DS_Store)$|^\/(wp-admin|wp-login|phpmyadmin|xmlrpc|server-status|cgi-bin|shell|cmd|console|actuator)(\/|$)/i;
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (BOT_PROBE_RE.test(req.path)) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
"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