Compare commits
1 commit
main
...
claude/app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddacf8b307 |
70 changed files with 1431 additions and 8347 deletions
|
|
@ -1,16 +1,13 @@
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { useRoundTransition } from "~/hooks/useRoundTransition";
|
import { useRoundTransition } from "~/hooks/useRoundTransition";
|
||||||
import type { FeederMap } from "~/lib/bracket-layout";
|
|
||||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
|
||||||
import {
|
import {
|
||||||
TreeColumns,
|
TreeColumns,
|
||||||
BracketMatchSlot,
|
BracketMatchSlot,
|
||||||
bracketGeometry,
|
|
||||||
windowGeometry,
|
|
||||||
SLOT_WIDTH,
|
SLOT_WIDTH,
|
||||||
LABEL_HEIGHT,
|
LABEL_HEIGHT,
|
||||||
DESIRED_CARD_HEIGHT,
|
DESIRED_CARD_HEIGHT,
|
||||||
|
CARD_GAP,
|
||||||
MAX_CARD_HEIGHT,
|
MAX_CARD_HEIGHT,
|
||||||
type BracketMatch,
|
type BracketMatch,
|
||||||
type BracketOwnership,
|
type BracketOwnership,
|
||||||
|
|
@ -24,8 +21,6 @@ interface BracketTreePaginatedProps {
|
||||||
/** Index of the first scoring round — default page starts here */
|
/** Index of the first scoring round — default page starts here */
|
||||||
firstScoringRoundIdx?: number;
|
firstScoringRoundIdx?: number;
|
||||||
thirdPlaceRound?: string;
|
thirdPlaceRound?: string;
|
||||||
feeders?: FeederMap;
|
|
||||||
template?: BracketTemplate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BracketTreePaginated({
|
export function BracketTreePaginated({
|
||||||
|
|
@ -35,68 +30,63 @@ export function BracketTreePaginated({
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
firstScoringRoundIdx,
|
firstScoringRoundIdx,
|
||||||
thirdPlaceRound,
|
thirdPlaceRound,
|
||||||
feeders,
|
|
||||||
template,
|
|
||||||
}: BracketTreePaginatedProps) {
|
}: BracketTreePaginatedProps) {
|
||||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
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(
|
const defaultPage = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : lastPage,
|
firstScoringRoundIdx !== undefined
|
||||||
lastPage,
|
? Math.max(0, firstScoringRoundIdx - 1)
|
||||||
|
: mainRounds.length - 2,
|
||||||
|
mainRounds.length - 2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
||||||
lastPage,
|
mainRounds.length - 2,
|
||||||
defaultPage,
|
defaultPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pageGeometry = (p: number) => windowGeometry(geometry, p, p + 1);
|
const targetPage = anim ? anim.toPage : page;
|
||||||
const labelFor = (p: number) => {
|
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
|
||||||
const [a, b] = [columns[p]?.label, columns[p + 1]?.label];
|
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
||||||
return b ? `${a} → ${b}` : (a ?? "");
|
|
||||||
|
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 visibleRounds = mainRounds.slice(page, page + 2);
|
||||||
const animFromG = anim ? pageGeometry(anim.fromPage) : pageG;
|
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
||||||
const animToG = anim ? pageGeometry(anim.toPage) : pageG;
|
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
||||||
|
|
||||||
let leftPage: number;
|
let leftRounds: string[];
|
||||||
let rightPage: number | null = null;
|
let rightRounds: string[] = [];
|
||||||
let leftG = pageG;
|
let leftHeight: number;
|
||||||
let rightG = pageG;
|
let rightHeight = 0;
|
||||||
let settlingTransition = false;
|
let settlingTransition = false;
|
||||||
if (anim?.phase === "sliding") {
|
if (anim?.phase === "sliding") {
|
||||||
leftPage = anim.dir === "right" ? anim.fromPage : anim.toPage;
|
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
||||||
rightPage = anim.dir === "right" ? anim.toPage : anim.fromPage;
|
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
||||||
leftG = anim.dir === "right" ? animFromG : animToG;
|
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
||||||
rightG = anim.dir === "right" ? animToG : animFromG;
|
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
||||||
} else if (anim?.phase === "settling") {
|
} else if (anim?.phase === "settling") {
|
||||||
leftPage = anim.toPage;
|
leftRounds = toRounds;
|
||||||
leftG = animToG;
|
leftHeight = animToHeight;
|
||||||
settlingTransition = true;
|
settlingTransition = true;
|
||||||
} else {
|
} else {
|
||||||
leftPage = page;
|
leftRounds = visibleRounds;
|
||||||
|
leftHeight = pageHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerMinHeight =
|
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
||||||
anim?.phase === "settling" ? animToG.bracketHeight : animFromG.bracketHeight;
|
|
||||||
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -119,7 +109,7 @@ export function BracketTreePaginated({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate(page + 1)}
|
onClick={() => navigate(page + 1)}
|
||||||
disabled={page >= lastPage || !!anim}
|
disabled={page + 2 >= mainRounds.length || !!anim}
|
||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
aria-label="Next rounds"
|
aria-label="Next rounds"
|
||||||
>
|
>
|
||||||
|
|
@ -139,24 +129,22 @@ export function BracketTreePaginated({
|
||||||
>
|
>
|
||||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={leftG}
|
visibleRounds={leftRounds}
|
||||||
columnRange={[leftPage, leftPage + 1]}
|
matchesByRound={matchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={leftHeight}
|
||||||
template={template}
|
|
||||||
transitionDuration={settlingTransition ? 500 : undefined}
|
transitionDuration={settlingTransition ? 500 : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{anim?.phase === "sliding" && rightPage !== null && (
|
{anim?.phase === "sliding" && (
|
||||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={rightG}
|
visibleRounds={rightRounds}
|
||||||
columnRange={[rightPage, rightPage + 1]}
|
matchesByRound={matchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={rightHeight}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -176,8 +164,6 @@ export function BracketTreePaginated({
|
||||||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
import { avatarColor } from "~/lib/avatar-colors";
|
import { avatarColor } from "~/lib/avatar-colors";
|
||||||
import { BRACKT_GRADIENT } from "~/lib/brand";
|
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 {
|
export interface BracketMatch {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -54,8 +46,6 @@ function formatScore(score: string | null): string | null {
|
||||||
|
|
||||||
interface ParticipantRowProps {
|
interface ParticipantRowProps {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
/** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */
|
|
||||||
feedLabel?: string | null;
|
|
||||||
isTbd: boolean;
|
isTbd: boolean;
|
||||||
isWinner: boolean;
|
isWinner: boolean;
|
||||||
isLoser: boolean;
|
isLoser: boolean;
|
||||||
|
|
@ -70,7 +60,6 @@ interface ParticipantRowProps {
|
||||||
|
|
||||||
function ParticipantRow({
|
function ParticipantRow({
|
||||||
name,
|
name,
|
||||||
feedLabel,
|
|
||||||
isTbd,
|
isTbd,
|
||||||
isWinner,
|
isWinner,
|
||||||
isLoser,
|
isLoser,
|
||||||
|
|
@ -125,7 +114,7 @@ function ParticipantRow({
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
>
|
>
|
||||||
{name ?? feedLabel ?? "TBD"}
|
{name ?? "TBD"}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Owner name below participant name */}
|
{/* Owner name below participant name */}
|
||||||
|
|
@ -161,8 +150,6 @@ interface BracketMatchSlotProps {
|
||||||
slotHeight: number;
|
slotHeight: number;
|
||||||
ownershipMap: Map<string, BracketOwnership>;
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
feeders?: FeederMap;
|
|
||||||
template?: BracketTemplate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BracketMatchSlot({
|
export function BracketMatchSlot({
|
||||||
|
|
@ -170,8 +157,6 @@ export function BracketMatchSlot({
|
||||||
slotHeight,
|
slotHeight,
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
feeders,
|
|
||||||
template,
|
|
||||||
}: BracketMatchSlotProps) {
|
}: BracketMatchSlotProps) {
|
||||||
const rowHeight = slotHeight / 2;
|
const rowHeight = slotHeight / 2;
|
||||||
const showText = rowHeight >= 10;
|
const showText = rowHeight >= 10;
|
||||||
|
|
@ -202,13 +187,6 @@ export function BracketMatchSlot({
|
||||||
|
|
||||||
const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20)));
|
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 (
|
return (
|
||||||
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
|
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
|
||||||
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
|
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
|
||||||
|
|
@ -230,7 +208,6 @@ export function BracketMatchSlot({
|
||||||
>
|
>
|
||||||
<ParticipantRow
|
<ParticipantRow
|
||||||
name={match.participant1?.name ?? null}
|
name={match.participant1?.name ?? null}
|
||||||
feedLabel={feed1}
|
|
||||||
isTbd={isTbd1}
|
isTbd={isTbd1}
|
||||||
isWinner={p1IsWinner}
|
isWinner={p1IsWinner}
|
||||||
isLoser={p1IsLoser}
|
isLoser={p1IsLoser}
|
||||||
|
|
@ -244,7 +221,6 @@ export function BracketMatchSlot({
|
||||||
/>
|
/>
|
||||||
<ParticipantRow
|
<ParticipantRow
|
||||||
name={match.participant2?.name ?? null}
|
name={match.participant2?.name ?? null}
|
||||||
feedLabel={feed2}
|
|
||||||
isTbd={isTbd2}
|
isTbd={isTbd2}
|
||||||
isWinner={p2IsWinner}
|
isWinner={p2IsWinner}
|
||||||
isLoser={p2IsLoser}
|
isLoser={p2IsLoser}
|
||||||
|
|
@ -261,45 +237,54 @@ export function BracketMatchSlot({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Connector column ─────────────────────────────────────────────────────────
|
// ─── Per-pair connector column ────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ConnectorColumnProps {
|
interface ConnectorColumnProps {
|
||||||
/** Edges crossing this gutter, in slot units. */
|
currentMatches: BracketMatch[];
|
||||||
edges: { fromCenter: number; toCenter: number }[];
|
nextMatches: BracketMatch[];
|
||||||
rowHeight: number;
|
|
||||||
offset: number;
|
|
||||||
bracketHeight: number;
|
bracketHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: ConnectorColumnProps) {
|
||||||
* Draws the feeder edges crossing one gutter. Because the layout assigns columns by
|
|
||||||
* depth from the final, every edge spans exactly one gutter — so a card that enters the
|
|
||||||
* bracket late is drawn in the column where it actually plays, and there is never an
|
|
||||||
* edge to route across a skipped column.
|
|
||||||
*/
|
|
||||||
function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorColumnProps) {
|
|
||||||
const mid = CONNECTOR_WIDTH / 2;
|
const mid = CONNECTOR_WIDTH / 2;
|
||||||
|
|
||||||
// Merge the two edges feeding one card into a single elbow, so a pair reads as one
|
|
||||||
// bracket join rather than two overlapping lines.
|
|
||||||
const byTarget = new Map<number, number[]>();
|
|
||||||
for (const { fromCenter, toCenter } of edges) {
|
|
||||||
const sources = byTarget.get(toCenter) ?? [];
|
|
||||||
sources.push(fromCenter);
|
|
||||||
byTarget.set(toCenter, sources);
|
|
||||||
}
|
|
||||||
|
|
||||||
const paths: string[] = [];
|
const paths: string[] = [];
|
||||||
for (const [toCenter, sources] of byTarget) {
|
|
||||||
const destY = toCenter * rowHeight - offset;
|
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
|
||||||
const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b);
|
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
|
||||||
if (ys.length === 1) {
|
|
||||||
paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals)
|
||||||
continue;
|
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`);
|
} else {
|
||||||
for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`);
|
// Non-standard (byes, play-ins, etc.): trace winners by participantId
|
||||||
paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`);
|
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 (
|
return (
|
||||||
|
|
@ -325,131 +310,52 @@ function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorC
|
||||||
|
|
||||||
// ─── Tree columns (shared by full + paginated) ───────────────────────────────
|
// ─── 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 {
|
interface TreeColumnsProps {
|
||||||
geometry: BracketGeometry;
|
visibleRounds: string[];
|
||||||
|
matchesByRound: Map<string, BracketMatch[]>;
|
||||||
ownershipMap: Map<string, BracketOwnership>;
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
|
bracketHeight: number;
|
||||||
transitionDuration?: 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({
|
export function TreeColumns({
|
||||||
geometry,
|
visibleRounds,
|
||||||
|
matchesByRound,
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
|
bracketHeight,
|
||||||
transitionDuration,
|
transitionDuration,
|
||||||
feeders,
|
|
||||||
template,
|
|
||||||
columnRange,
|
|
||||||
}: TreeColumnsProps) {
|
}: TreeColumnsProps) {
|
||||||
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
|
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 (
|
return (
|
||||||
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
||||||
{visible.map((column, vi) => {
|
{visibleRounds.map((round, ri) => {
|
||||||
const ci = firstColumn + vi;
|
const roundMatches = matchesByRound.get(round) ?? [];
|
||||||
const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci);
|
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 (
|
return (
|
||||||
<div key={column.label + ci} style={{ display: "contents" }}>
|
<div key={round} style={{ display: "contents" }}>
|
||||||
{/* Round column */}
|
{/* Round column */}
|
||||||
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
|
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
|
||||||
<div
|
<div
|
||||||
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
|
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
|
||||||
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||||
>
|
>
|
||||||
{column.label}
|
{round}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
||||||
{column.matches.map(({ match, center }) => (
|
{roundMatches.map((match, matchIdx) => (
|
||||||
<div
|
<div
|
||||||
key={match.id}
|
key={match.id}
|
||||||
data-match-id={match.id}
|
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: center * rowHeight - offset - cardHeight / 2,
|
top: matchIdx * slotHeight + cardTop,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: cardHeight,
|
height: cardHeight,
|
||||||
|
|
@ -461,8 +367,6 @@ export function TreeColumns({
|
||||||
slotHeight={cardHeight}
|
slotHeight={cardHeight}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -470,11 +374,10 @@ export function TreeColumns({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Connector between this column and the next */}
|
{/* Connector between this column and the next */}
|
||||||
{vi < visible.length - 1 && (
|
{nextRound && (
|
||||||
<ConnectorColumn
|
<ConnectorColumn
|
||||||
edges={gutterEdges}
|
currentMatches={roundMatches}
|
||||||
rowHeight={rowHeight}
|
nextMatches={nextMatches}
|
||||||
offset={offset}
|
|
||||||
bracketHeight={bracketHeight}
|
bracketHeight={bracketHeight}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -493,8 +396,6 @@ interface BracketTreeViewProps {
|
||||||
ownershipMap: Map<string, BracketOwnership>;
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
thirdPlaceRound?: string;
|
thirdPlaceRound?: string;
|
||||||
feeders?: FeederMap;
|
|
||||||
template?: BracketTemplate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BracketTreeView({
|
export function BracketTreeView({
|
||||||
|
|
@ -503,19 +404,13 @@ export function BracketTreeView({
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
thirdPlaceRound,
|
thirdPlaceRound,
|
||||||
feeders,
|
|
||||||
template,
|
|
||||||
}: BracketTreeViewProps) {
|
}: BracketTreeViewProps) {
|
||||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||||
|
|
||||||
const geometry = bracketGeometry(
|
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||||
mainRounds,
|
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||||
matchesByRound,
|
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH;
|
||||||
feeders,
|
|
||||||
template?.rounds.map((r) => r.name) ?? mainRounds
|
|
||||||
);
|
|
||||||
const { bracketHeight, minWidth } = geometry;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -524,11 +419,11 @@ export function BracketTreeView({
|
||||||
>
|
>
|
||||||
<div style={{ minWidth }}>
|
<div style={{ minWidth }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={geometry}
|
visibleRounds={mainRounds}
|
||||||
|
matchesByRound={matchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={bracketHeight}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
{thirdPlaceMatch && (
|
{thirdPlaceMatch && (
|
||||||
<div style={{ display: "flex", paddingTop: 20 }}>
|
<div style={{ display: "flex", paddingTop: 20 }}>
|
||||||
|
|
@ -546,8 +441,6 @@ export function BracketTreeView({
|
||||||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import type { BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
import type { ConferenceGroup } from "~/lib/bracket-templates";
|
||||||
import type { FeederMap } from "~/lib/bracket-layout";
|
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||||
import {
|
|
||||||
TreeColumns,
|
|
||||||
bracketGeometry,
|
|
||||||
type BracketMatch,
|
|
||||||
type BracketOwnership,
|
|
||||||
} from "./BracketTreeView";
|
|
||||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||||
|
|
||||||
interface NbaBracketLayoutProps {
|
interface NbaBracketLayoutProps {
|
||||||
|
|
@ -16,10 +10,11 @@ interface NbaBracketLayoutProps {
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
conferenceGroups: ConferenceGroup[];
|
conferenceGroups: ConferenceGroup[];
|
||||||
scoringRoundIdx: number;
|
scoringRoundIdx: number;
|
||||||
feeders?: FeederMap;
|
|
||||||
template?: BracketTemplate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DESIRED_CARD_HEIGHT = 112;
|
||||||
|
const CARD_GAP = 14;
|
||||||
|
|
||||||
function splitMatchesByConference(
|
function splitMatchesByConference(
|
||||||
matchesByRound: Map<string, BracketMatch[]>,
|
matchesByRound: Map<string, BracketMatch[]>,
|
||||||
group: ConferenceGroup
|
group: ConferenceGroup
|
||||||
|
|
@ -33,6 +28,11 @@ function splitMatchesByConference(
|
||||||
return result;
|
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({
|
export function NbaBracketLayout({
|
||||||
rounds,
|
rounds,
|
||||||
matchesByRound,
|
matchesByRound,
|
||||||
|
|
@ -40,10 +40,7 @@ export function NbaBracketLayout({
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
conferenceGroups,
|
conferenceGroups,
|
||||||
scoringRoundIdx,
|
scoringRoundIdx,
|
||||||
feeders,
|
|
||||||
template,
|
|
||||||
}: NbaBracketLayoutProps) {
|
}: NbaBracketLayoutProps) {
|
||||||
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
|
||||||
// Rounds that belong to any conference group
|
// Rounds that belong to any conference group
|
||||||
const conferenceRoundSet = new Set(
|
const conferenceRoundSet = new Set(
|
||||||
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
|
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
|
||||||
|
|
@ -60,7 +57,7 @@ export function NbaBracketLayout({
|
||||||
const sharedMatches = new Map(
|
const sharedMatches = new Map(
|
||||||
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
|
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
|
||||||
);
|
);
|
||||||
const sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder);
|
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -69,7 +66,7 @@ export function NbaBracketLayout({
|
||||||
{conferenceGroups.map((group, gi) => {
|
{conferenceGroups.map((group, gi) => {
|
||||||
const confRounds = conferenceRounds[gi];
|
const confRounds = conferenceRounds[gi];
|
||||||
const confMatches = splitMatchesByConference(matchesByRound, group);
|
const confMatches = splitMatchesByConference(matchesByRound, group);
|
||||||
const geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder);
|
const height = bracketHeight(confMatches, confRounds);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.name}>
|
<div key={group.name}>
|
||||||
|
|
@ -77,11 +74,11 @@ export function NbaBracketLayout({
|
||||||
{group.name}
|
{group.name}
|
||||||
</p>
|
</p>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={geometry}
|
visibleRounds={confRounds}
|
||||||
|
matchesByRound={confMatches}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={height}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -90,11 +87,11 @@ export function NbaBracketLayout({
|
||||||
{sharedRounds.length > 0 && (
|
{sharedRounds.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={sharedGeometry}
|
visibleRounds={sharedRounds}
|
||||||
|
matchesByRound={sharedMatches}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={sharedHeight}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -108,8 +105,6 @@ export function NbaBracketLayout({
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
firstScoringRoundIdx={scoringRoundIdx}
|
firstScoringRoundIdx={scoringRoundIdx}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import { RankingsRow } from "./RankingsRow";
|
||||||
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||||
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
||||||
import { buildFeederMap } from "~/lib/bracket-layout";
|
|
||||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||||
|
|
||||||
|
|
@ -77,6 +76,43 @@ export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
|
||||||
return byRound;
|
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 {
|
interface EliminatedEntry {
|
||||||
participant: Participant;
|
participant: Participant;
|
||||||
score: string | null;
|
score: string | null;
|
||||||
|
|
@ -355,8 +391,6 @@ export function PlayoffBracket({
|
||||||
const matchesByRound = groupMatchesByRound(matches);
|
const matchesByRound = groupMatchesByRound(matches);
|
||||||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
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 consolation = findConsolationRound(template);
|
||||||
const thirdPlaceRound = consolation?.round;
|
const thirdPlaceRound = consolation?.round;
|
||||||
|
|
@ -444,8 +478,6 @@ export function PlayoffBracket({
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
phases={template.phases}
|
phases={template.phases}
|
||||||
scoringRoundIdx={scoringRoundIdx}
|
scoringRoundIdx={scoringRoundIdx}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
) : template?.conferenceGroups ? (
|
) : template?.conferenceGroups ? (
|
||||||
<NbaBracketLayout
|
<NbaBracketLayout
|
||||||
|
|
@ -456,8 +488,6 @@ export function PlayoffBracket({
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
conferenceGroups={template.conferenceGroups}
|
conferenceGroups={template.conferenceGroups}
|
||||||
scoringRoundIdx={scoringRoundIdx}
|
scoringRoundIdx={scoringRoundIdx}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -469,8 +499,6 @@ export function PlayoffBracket({
|
||||||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
thirdPlaceRound={thirdPlaceRound}
|
thirdPlaceRound={thirdPlaceRound}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -483,8 +511,6 @@ export function PlayoffBracket({
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
firstScoringRoundIdx={scoringRoundIdx}
|
firstScoringRoundIdx={scoringRoundIdx}
|
||||||
thirdPlaceRound={thirdPlaceRound}
|
thirdPlaceRound={thirdPlaceRound}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,8 @@
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import type { BracketPhase, BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
||||||
import type { FeederMap } from "~/lib/bracket-layout";
|
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||||
import {
|
|
||||||
TreeColumns,
|
|
||||||
BracketMatchSlot,
|
|
||||||
bracketGeometry,
|
|
||||||
type BracketMatch,
|
|
||||||
type BracketOwnership,
|
|
||||||
} from "./BracketTreeView";
|
|
||||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
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 {
|
interface TabbedBracketLayoutProps {
|
||||||
rounds: string[];
|
rounds: string[];
|
||||||
matchesByRound: Map<string, BracketMatch[]>;
|
matchesByRound: Map<string, BracketMatch[]>;
|
||||||
|
|
@ -20,10 +10,11 @@ interface TabbedBracketLayoutProps {
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
phases: BracketPhase[];
|
phases: BracketPhase[];
|
||||||
scoringRoundIdx: number;
|
scoringRoundIdx: number;
|
||||||
feeders?: FeederMap;
|
|
||||||
template?: BracketTemplate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CARD_H = 112;
|
||||||
|
const CARD_GAP = 14;
|
||||||
|
|
||||||
function groupMatches(
|
function groupMatches(
|
||||||
matchesByRound: Map<string, BracketMatch[]>,
|
matchesByRound: Map<string, BracketMatch[]>,
|
||||||
group: ConferenceGroup
|
group: ConferenceGroup
|
||||||
|
|
@ -38,6 +29,11 @@ function groupMatches(
|
||||||
return out;
|
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 ───────────────────────────────────────────────────────────
|
// ─── Play-In Layout ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface PlayInColumnProps {
|
interface PlayInColumnProps {
|
||||||
|
|
@ -145,10 +141,7 @@ export function TabbedBracketLayout({
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
phases,
|
phases,
|
||||||
scoringRoundIdx,
|
scoringRoundIdx,
|
||||||
feeders,
|
|
||||||
template,
|
|
||||||
}: TabbedBracketLayoutProps) {
|
}: TabbedBracketLayoutProps) {
|
||||||
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{phases.map((phase) => {
|
{phases.map((phase) => {
|
||||||
|
|
@ -201,87 +194,50 @@ export function TabbedBracketLayout({
|
||||||
{phase.groups.map((group) => {
|
{phase.groups.map((group) => {
|
||||||
const gMatches = groupMatches(matchesByRound, group);
|
const gMatches = groupMatches(matchesByRound, group);
|
||||||
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
||||||
const geometry = bracketGeometry(gRounds, gMatches, feeders, roundOrder);
|
|
||||||
return (
|
return (
|
||||||
<div key={group.name}>
|
<div key={group.name}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||||
{group.name}
|
{group.name}
|
||||||
</p>
|
</p>
|
||||||
<div className="w-full overflow-x-auto">
|
<TreeColumns
|
||||||
<div style={{ minWidth: geometry.minWidth }}>
|
visibleRounds={gRounds}
|
||||||
<TreeColumns
|
matchesByRound={gMatches}
|
||||||
geometry={geometry}
|
ownershipMap={ownershipMap}
|
||||||
ownershipMap={ownershipMap}
|
userParticipantIds={userParticipantIds}
|
||||||
userParticipantIds={userParticipantIds}
|
bracketHeight={phaseHeight(gMatches, gRounds)}
|
||||||
feeders={feeders}
|
/>
|
||||||
template={template}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{sharedRounds.length > 0 && (
|
{sharedRounds.length > 0 && (
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
|
visibleRounds={sharedRounds}
|
||||||
|
matchesByRound={sharedMatchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
|
visibleRounds={phaseRounds}
|
||||||
|
matchesByRound={phaseMatchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile — paged one group at a time, matching the desktop split. Paging a
|
{/* Mobile */}
|
||||||
whole phase would merge the winners and elimination brackets into one
|
<div className="md:hidden">
|
||||||
tree, and a double-elimination phase is a DAG rather than a tree: the
|
|
||||||
same game feeds forward and sideways, so its column placement would be
|
|
||||||
arbitrary. */}
|
|
||||||
<div className="md:hidden space-y-6">
|
|
||||||
{phase.layout === "play-in" ? (
|
{phase.layout === "play-in" ? (
|
||||||
<PlayInLayout
|
<PlayInLayout
|
||||||
matchesByRound={phaseMatchesByRound}
|
matchesByRound={phaseMatchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
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
|
<BracketTreePaginated
|
||||||
rounds={phaseRounds}
|
rounds={phaseRounds}
|
||||||
|
|
@ -289,8 +245,6 @@ export function TabbedBracketLayout({
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import {
|
import {
|
||||||
PlayoffBracket,
|
PlayoffBracket,
|
||||||
|
buildFeederMap,
|
||||||
groupMatchesByRound,
|
groupMatchesByRound,
|
||||||
computeEliminatedByRound,
|
computeEliminatedByRound,
|
||||||
computeRankedEntries,
|
computeRankedEntries,
|
||||||
|
|
@ -63,72 +64,88 @@ describe("groupMatchesByRound", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Rendered LLWS bracket — geometry and empty-slot labels
|
// buildFeederMap
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("PlayoffBracket — rendered LLWS bracket", () => {
|
describe("buildFeederMap", () => {
|
||||||
const LLWS_ROUNDS = (getBracketTemplate("llws_20")?.rounds ?? []).map((r) => r.name);
|
it("returns an empty map when there is only one round", () => {
|
||||||
|
const matches = [makeMatch("Finals", 1)];
|
||||||
/** Every LLWS match, all unplayed, so each slot shows what will fill it. */
|
const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]);
|
||||||
function emptyLlwsMatches(): Match[] {
|
expect(map.size).toBe(0);
|
||||||
const template = getBracketTemplate("llws_20");
|
|
||||||
const matches: Match[] = [];
|
|
||||||
for (const round of template?.rounds ?? []) {
|
|
||||||
for (let n = 1; n <= round.matchCount; n++) {
|
|
||||||
matches.push({
|
|
||||||
...makeMatch(round.name, n, { participant1Id: null, participant2Id: null }),
|
|
||||||
participant1: null,
|
|
||||||
participant2: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("names empty slots after the game that feeds them", () => {
|
|
||||||
render(
|
|
||||||
<PlayoffBracket
|
|
||||||
matches={emptyLlwsMatches()}
|
|
||||||
rounds={LLWS_ROUNDS}
|
|
||||||
bracketTemplateId="llws_20"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// A winners-bracket loss drops into the elimination bracket — an edge that spans
|
|
||||||
// two separately rendered trees, so the label is the only way to show it.
|
|
||||||
expect(screen.getAllByText("Loser of Winners SF 1").length).toBeGreaterThan(0);
|
|
||||||
expect(screen.getAllByText("Winner of Opening 1").length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still shows TBD for a directly seeded slot", () => {
|
it("maps SF slots to the correct QF matches for an 8-team bracket", () => {
|
||||||
render(
|
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||||
<PlayoffBracket
|
const matches = [
|
||||||
matches={emptyLlwsMatches()}
|
makeMatch("Quarterfinals", 1),
|
||||||
rounds={LLWS_ROUNDS}
|
makeMatch("Quarterfinals", 2),
|
||||||
bracketTemplateId="llws_20"
|
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.
|
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||||
expect(screen.getAllByText("TBD").length).toBeGreaterThan(0);
|
|
||||||
|
// 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", () => {
|
it("maps Finals slots to the correct SF matches", () => {
|
||||||
const { container } = render(
|
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||||
<PlayoffBracket
|
const matches = [
|
||||||
matches={emptyLlwsMatches()}
|
makeMatch("Quarterfinals", 1),
|
||||||
rounds={LLWS_ROUNDS}
|
makeMatch("Quarterfinals", 2),
|
||||||
bracketTemplateId="llws_20"
|
makeMatch("Quarterfinals", 3),
|
||||||
/>
|
makeMatch("Quarterfinals", 4),
|
||||||
);
|
makeMatch("Semifinals", 1),
|
||||||
|
makeMatch("Semifinals", 2),
|
||||||
|
makeMatch("Finals", 1),
|
||||||
|
];
|
||||||
|
|
||||||
const heights = new Set(
|
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||||
[...container.querySelectorAll<HTMLElement>("[data-match-id]")].map(
|
|
||||||
(el) => el.style.height
|
expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 });
|
||||||
)
|
expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 });
|
||||||
);
|
});
|
||||||
// Previously a one-match column stretched its card to fill the bracket height.
|
|
||||||
expect(heights.size).toBe(1);
|
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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,18 @@
|
||||||
import * as Sentry from "@sentry/react-router";
|
import * as Sentry from "@sentry/react-router";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { logger } from "~/lib/logger";
|
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 { createReadableStreamFromReadable } from "@react-router/node";
|
||||||
import { ServerRouter } from "react-router";
|
import { ServerRouter } from "react-router";
|
||||||
import { isbot } from "isbot";
|
import { isbot } from "isbot";
|
||||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||||
import { renderToPipeableStream } from "react-dom/server";
|
import { renderToPipeableStream } from "react-dom/server";
|
||||||
|
|
||||||
const sentryHandleError = Sentry.createSentryHandleError({
|
export const handleError = Sentry.createSentryHandleError({
|
||||||
logErrors: true,
|
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;
|
export const streamTimeout = 5_000;
|
||||||
|
|
||||||
async function handleRequest(
|
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -31,20 +31,6 @@ export interface BracketRound {
|
||||||
* Has no effect on scoring rounds, which use RoundScoringConfig.winnerFloor instead.
|
* Has no effect on scoring rounds, which use RoundScoringConfig.winnerFloor instead.
|
||||||
*/
|
*/
|
||||||
nonScoringWinnerFloor?: number | null;
|
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 {
|
export interface GroupStageConfig {
|
||||||
|
|
@ -703,8 +689,7 @@ export const NFL_14: BracketTemplate = {
|
||||||
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
|
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
|
||||||
* - Week 1 Finals:
|
* - Week 1 Finals:
|
||||||
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
|
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
|
||||||
* - Elimination Finals: the two Wildcard winners are re-seeded by ladder position, so
|
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th)
|
||||||
* 5th hosts the lower-ranked winner and 6th the higher-ranked one (losers share 7th-8th)
|
|
||||||
* - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th)
|
* - 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 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
|
||||||
* - Week 4: Grand Final (1st vs 2nd)
|
* - Week 4: Grand Final (1st vs 2nd)
|
||||||
|
|
@ -722,32 +707,18 @@ export const AFL_10: BracketTemplate = {
|
||||||
matchCount: 2,
|
matchCount: 2,
|
||||||
feedsInto: "Elimination Finals",
|
feedsInto: "Elimination Finals",
|
||||||
isScoring: false, // Losers get 0 points (9th-10th)
|
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",
|
name: "Qualifying Finals",
|
||||||
matchCount: 2,
|
matchCount: 2,
|
||||||
feedsInto: "Preliminary Finals", // Winners get bye
|
feedsInto: "Preliminary Finals", // Winners get bye
|
||||||
isScoring: false, // Losers get second chance (go to Semi-Finals)
|
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",
|
name: "Elimination Finals",
|
||||||
matchCount: 2,
|
matchCount: 2,
|
||||||
feedsInto: "Semi-Finals",
|
feedsInto: "Semi-Finals",
|
||||||
isScoring: true, // Losers share 7th-8th
|
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",
|
name: "Semi-Finals",
|
||||||
|
|
|
||||||
|
|
@ -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 { describe, it, expect } from "vitest";
|
||||||
import { AFL_10, getScoringRoundType } from "~/lib/bracket-templates";
|
import { AFL_10, getScoringRoundType } from "~/lib/bracket-templates";
|
||||||
import { calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints, type ScoringRules } from "../scoring-rules";
|
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
|
||||||
import { getBracketEntryFloor } from "../scoring-calculator";
|
|
||||||
|
|
||||||
const DEFAULT_SCORING: ScoringRules = {
|
const DEFAULT_SCORING: ScoringRules = {
|
||||||
pointsFor1st: 100,
|
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -27,13 +27,6 @@ import {
|
||||||
calculateAveragedPoints,
|
calculateAveragedPoints,
|
||||||
type ScoringRules,
|
type ScoringRules,
|
||||||
} from "../scoring-rules";
|
} 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
|
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
|
||||||
// minimal stub is enough to capture the generated rows.
|
// minimal stub is enough to capture the generated rows.
|
||||||
|
|
@ -62,6 +55,121 @@ const DEFAULT_SCORING: ScoringRules = {
|
||||||
pointsFor8th: 10,
|
pointsFor8th: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── PDF game number ↔ (round, match number) ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match
|
||||||
|
// numbers in each round, International the high ones.
|
||||||
|
const GAME_TO_MATCH: Record<number, { round: string; matchNumber: number }> = {
|
||||||
|
// Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8)
|
||||||
|
2: { round: "Opening Round", matchNumber: 1 },
|
||||||
|
4: { round: "Opening Round", matchNumber: 2 },
|
||||||
|
6: { round: "Opening Round", matchNumber: 3 },
|
||||||
|
8: { round: "Opening Round", matchNumber: 4 },
|
||||||
|
1: { round: "Opening Round", matchNumber: 5 },
|
||||||
|
3: { round: "Opening Round", matchNumber: 6 },
|
||||||
|
5: { round: "Opening Round", matchNumber: 7 },
|
||||||
|
7: { round: "Opening Round", matchNumber: 8 },
|
||||||
|
// Winners Round 2 — U.S. G10,12; Intl G9,11
|
||||||
|
10: { round: "Winners Round 2", matchNumber: 1 },
|
||||||
|
12: { round: "Winners Round 2", matchNumber: 2 },
|
||||||
|
9: { round: "Winners Round 2", matchNumber: 3 },
|
||||||
|
11: { round: "Winners Round 2", matchNumber: 4 },
|
||||||
|
// Elimination Round 1 — U.S. G14,16; Intl G13,15
|
||||||
|
14: { round: "Elimination Round 1", matchNumber: 1 },
|
||||||
|
16: { round: "Elimination Round 1", matchNumber: 2 },
|
||||||
|
13: { round: "Elimination Round 1", matchNumber: 3 },
|
||||||
|
15: { round: "Elimination Round 1", matchNumber: 4 },
|
||||||
|
// Winners Semifinals — U.S. G17,19; Intl G18,20
|
||||||
|
17: { round: "Winners Semifinals", matchNumber: 1 },
|
||||||
|
19: { round: "Winners Semifinals", matchNumber: 2 },
|
||||||
|
18: { round: "Winners Semifinals", matchNumber: 3 },
|
||||||
|
20: { round: "Winners Semifinals", matchNumber: 4 },
|
||||||
|
// Elimination Round 2 — U.S. G22,24; Intl G21,23
|
||||||
|
22: { round: "Elimination Round 2", matchNumber: 1 },
|
||||||
|
24: { round: "Elimination Round 2", matchNumber: 2 },
|
||||||
|
21: { round: "Elimination Round 2", matchNumber: 3 },
|
||||||
|
23: { round: "Elimination Round 2", matchNumber: 4 },
|
||||||
|
// Elimination Round 3 — U.S. G26,28; Intl G25,27
|
||||||
|
26: { round: "Elimination Round 3", matchNumber: 1 },
|
||||||
|
28: { round: "Elimination Round 3", matchNumber: 2 },
|
||||||
|
25: { round: "Elimination Round 3", matchNumber: 3 },
|
||||||
|
27: { round: "Elimination Round 3", matchNumber: 4 },
|
||||||
|
// Winners Final — U.S. G30; Intl G29
|
||||||
|
30: { round: "Winners Final", matchNumber: 1 },
|
||||||
|
29: { round: "Winners Final", matchNumber: 2 },
|
||||||
|
// Elimination Round 4 — U.S. G32; Intl G31
|
||||||
|
32: { round: "Elimination Round 4", matchNumber: 1 },
|
||||||
|
31: { round: "Elimination Round 4", matchNumber: 2 },
|
||||||
|
// Elimination Final — U.S. G34; Intl G33
|
||||||
|
34: { round: "Elimination Final", matchNumber: 1 },
|
||||||
|
33: { round: "Elimination Final", matchNumber: 2 },
|
||||||
|
// Bracket Championship — U.S. G36; Intl G35
|
||||||
|
36: { round: "Bracket Championship", matchNumber: 1 },
|
||||||
|
35: { round: "Bracket Championship", matchNumber: 2 },
|
||||||
|
// Finals
|
||||||
|
37: { round: "Consolation Third Place", matchNumber: 1 },
|
||||||
|
38: { round: "World Championship", matchNumber: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const MATCH_TO_GAME = new Map<string, number>(
|
||||||
|
Object.entries(GAME_TO_MATCH).map(([game, m]) => [
|
||||||
|
`${m.round}#${m.matchNumber}`,
|
||||||
|
Number(game),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
function gameNumberFor(round: string, matchNumber: number): number {
|
||||||
|
const game = MATCH_TO_GAME.get(`${round}#${matchNumber}`);
|
||||||
|
if (game === undefined) throw new Error(`No PDF game for ${round} #${matchNumber}`);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrows a destination that the test expects to exist. */
|
||||||
|
function required<T>(destination: T | null): T {
|
||||||
|
if (destination === null) throw new Error("Expected a destination, got null");
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PDF game number a destination points at. */
|
||||||
|
function destinationGame(
|
||||||
|
destination: { round: string; matchNumber: number } | null
|
||||||
|
): number {
|
||||||
|
const d = required(destination);
|
||||||
|
return gameNumberFor(d.round, d.matchNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The official bracket printed as feed labels: for each game, which prior game's
|
||||||
|
* winner (W) or loser (L) fills each slot. `null` = a team seeded in directly.
|
||||||
|
*
|
||||||
|
* Transcribed from the PDF. This is the source of truth the routing must reproduce.
|
||||||
|
*/
|
||||||
|
const EXPECTED_SLOTS: Record<number, [string | null, string | null]> = {
|
||||||
|
// Opening Round — all directly seeded
|
||||||
|
1: [null, null], 2: [null, null], 3: [null, null], 4: [null, null],
|
||||||
|
5: [null, null], 6: [null, null], 7: [null, null], 8: [null, null],
|
||||||
|
// Winners Round 2 — bye team, then an Opening Round winner
|
||||||
|
9: [null, "W1"], 10: [null, "W2"], 11: [null, "W3"], 12: [null, "W4"],
|
||||||
|
// Elimination Round 1
|
||||||
|
13: ["L3", "L5"], 14: ["L4", "L6"], 15: ["L1", "L7"], 16: ["L2", "L8"],
|
||||||
|
// Winners Semifinals
|
||||||
|
17: ["W6", "W10"], 18: ["W5", "W9"], 19: ["W12", "W8"], 20: ["W11", "W7"],
|
||||||
|
// Elimination Round 2
|
||||||
|
21: ["L9", "W13"], 22: ["L10", "W14"], 23: ["L11", "W15"], 24: ["L12", "W16"],
|
||||||
|
// Elimination Round 3 — cross-over
|
||||||
|
25: ["L18", "W23"], 26: ["L17", "W24"], 27: ["L20", "W21"], 28: ["L19", "W22"],
|
||||||
|
// Winners Final
|
||||||
|
29: ["W18", "W20"], 30: ["W17", "W19"],
|
||||||
|
// Elimination Round 4
|
||||||
|
31: ["W27", "W25"], 32: ["W28", "W26"],
|
||||||
|
// Elimination Final
|
||||||
|
33: ["L29", "W31"], 34: ["L30", "W32"],
|
||||||
|
// Bracket Championship
|
||||||
|
35: ["W29", "W33"], 36: ["W30", "W34"],
|
||||||
|
// Finals
|
||||||
|
37: ["L36", "L35"], 38: ["W36", "W35"],
|
||||||
|
};
|
||||||
|
|
||||||
describe("LLWS 20 Bracket Template", () => {
|
describe("LLWS 20 Bracket Template", () => {
|
||||||
describe("Template structure", () => {
|
describe("Template structure", () => {
|
||||||
it("has correct identity and size", () => {
|
it("has correct identity and size", () => {
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it("AFL Wildcard Round: loser=0, winner gets T5 floor (feeds into Elimination Finals = scoring)", 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.
|
|
||||||
const { db, insertedRows } = makeDb();
|
const { db, insertedRows } = makeDb();
|
||||||
await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db);
|
await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db);
|
||||||
expect(insertedRows).toHaveLength(2);
|
expect(insertedRows).toHaveLength(2);
|
||||||
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
||||||
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 7, isPartialScore: true });
|
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, 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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("NBA Play-In loserAdvances=true (7v8 game)", () => {
|
describe("NBA Play-In loserAdvances=true (7v8 game)", () => {
|
||||||
|
|
@ -319,22 +305,6 @@ describe("processMatchResult", () => {
|
||||||
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
|
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 () => {
|
it("does not throw even if probability update fails", async () => {
|
||||||
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
new Error("network error")
|
new Error("network error")
|
||||||
|
|
|
||||||
|
|
@ -111,50 +111,4 @@ describe("simulator input model", () => {
|
||||||
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
|
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
|
||||||
expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull();
|
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: {
|
scoringEvents: {
|
||||||
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
|
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: {
|
seasonParticipantResults: {
|
||||||
findMany: vi.fn().mockResolvedValue(seasonResults),
|
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,
|
calculateBracketPoints,
|
||||||
calculateSharedPlacementPoints,
|
calculateSharedPlacementPoints,
|
||||||
} from "./scoring-rules";
|
} from "./scoring-rules";
|
||||||
import { getBracketTemplateIdsForSportsSeasons } from "./bracket-template";
|
|
||||||
|
|
||||||
export async function createDraftPick(data: {
|
export async function createDraftPick(data: {
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
|
|
@ -176,10 +175,18 @@ export async function getDraftedParticipantsWithPoints(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch-fetch bracket template IDs (one per sports season)
|
// Batch-fetch bracket template IDs (one per sports season)
|
||||||
const bracketTemplateMap =
|
const bracketTemplateMap = new Map<string, string | null>();
|
||||||
bracketSeasonIds.size > 0
|
if (bracketSeasonIds.size > 0) {
|
||||||
? await getBracketTemplateIdsForSportsSeasons([...bracketSeasonIds], db)
|
const events = await db.query.scoringEvents.findMany({
|
||||||
: new Map<string, string | null>();
|
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
|
// Batch-fetch QP totals for qualifying_points participants
|
||||||
const qpMap = new Map<string, number>(); // participantId → totalQP
|
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 { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
|
@ -104,33 +104,6 @@ export async function deleteParticipantResultsBySportsSeasonId(
|
||||||
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
|
.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
|
* Set result for a participant in a sports season
|
||||||
* Points are calculated on-demand based on each fantasy league's scoring rules
|
* Points are calculated on-demand based on each fantasy league's scoring rules
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,6 @@ import {
|
||||||
llwsSideAndLocal,
|
llwsSideAndLocal,
|
||||||
STANDARD_BRACKET_SEEDING,
|
STANDARD_BRACKET_SEEDING,
|
||||||
} from "~/lib/bracket-templates";
|
} 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 PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||||
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
||||||
|
|
@ -746,11 +737,9 @@ async function generateNFL14Bracket(
|
||||||
* Structure:
|
* Structure:
|
||||||
* - Wildcard Round: 7v10, 8v9
|
* - Wildcard Round: 7v10, 8v9
|
||||||
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
* - 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
|
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
|
||||||
* position — 5th draws the lower-ranked winner, 6th the higher-ranked one
|
* - Semi-Finals: QF losers vs EF winners
|
||||||
* - Semi-Finals: SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner
|
* - Preliminary Finals: QF winners vs SF winners
|
||||||
* - 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)
|
|
||||||
* - Grand Final: PF winners
|
* - Grand Final: PF winners
|
||||||
*/
|
*/
|
||||||
async function generateAFL10Bracket(
|
async function generateAFL10Bracket(
|
||||||
|
|
@ -802,16 +791,14 @@ async function generateAFL10Bracket(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Elimination Finals: 5th and 6th host the two Wildcard winners. Which winner lands
|
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
|
||||||
// where is decided by ladder position once both games are played (see
|
|
||||||
// resolveAflWildcardPlacements), not by a fixed crossover from a Wildcard match.
|
|
||||||
const eliminationSeeding = [
|
const eliminationSeeding = [
|
||||||
{ higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
|
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
|
||||||
{ higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
|
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = 0; i < eliminationSeeding.length; i++) {
|
for (let i = 0; i < eliminationSeeding.length; i++) {
|
||||||
const { higher, opponent } = eliminationSeeding[i];
|
const { higher, wildcard } = eliminationSeeding[i];
|
||||||
matches.push({
|
matches.push({
|
||||||
scoringEventId: eventId,
|
scoringEventId: eventId,
|
||||||
round: "Elimination Finals",
|
round: "Elimination Finals",
|
||||||
|
|
@ -821,11 +808,11 @@ async function generateAFL10Bracket(
|
||||||
isComplete: false,
|
isComplete: false,
|
||||||
isScoring: true, // Losers share 7th-8th
|
isScoring: true, // Losers share 7th-8th
|
||||||
templateRound: "Elimination Finals",
|
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++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
matches.push({
|
matches.push({
|
||||||
scoringEventId: eventId,
|
scoringEventId: eventId,
|
||||||
|
|
@ -871,257 +858,15 @@ async function generateAFL10Bracket(
|
||||||
return await createManyPlayoffMatches(matches);
|
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
|
* AFL-specific advancement logic for the complex double-chance system
|
||||||
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
||||||
*
|
*
|
||||||
* Advancement rules:
|
* Advancement rules:
|
||||||
* - Wildcard Round: Winner → Elimination Finals (re-seeded by ladder position)
|
* - Wildcard Round: Winner → Elimination Finals
|
||||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals (QF n → PF n, SF n)
|
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
||||||
* - Elimination Finals: Winner → Semi-Finals (EF n → SF n, a fixed pathway)
|
* - Elimination Finals: Winner → Semi-Finals
|
||||||
* - Semi-Finals: Winner → Preliminary Finals (SF n crosses over: SF1 → PF2, SF2 → PF1)
|
* - Semi-Finals: Winner → Preliminary Finals
|
||||||
* - Preliminary Finals: Winner → Grand Final
|
* - Preliminary Finals: Winner → Grand Final
|
||||||
*/
|
*/
|
||||||
async function advanceAFLWinner(
|
async function advanceAFLWinner(
|
||||||
|
|
@ -1131,10 +876,18 @@ async function advanceAFLWinner(
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const eventId = match.scoringEventId;
|
const eventId = match.scoringEventId;
|
||||||
|
|
||||||
// Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
|
// Wildcard Round: Winner advances to Elimination Finals
|
||||||
// position, so every result re-resolves both slots.
|
|
||||||
if (match.round === "Wildcard Round") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1162,11 +915,18 @@ async function advanceAFLWinner(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Elimination Finals: Winner → Semi-Finals. EF n feeds SF n — the crossover in this
|
// Elimination Finals: Winner → Semi-Finals
|
||||||
// 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.
|
|
||||||
if (match.round === "Elimination 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1803,10 +1563,190 @@ async function advanceNBAPlayInWinner(
|
||||||
|
|
||||||
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
|
// ── 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
|
* Where one participant goes after an LLWS match: a round, a side-local match number,
|
||||||
// existing server-side callers and tests keep their import path.
|
* and which slot to fill. `null` means eliminated (or, for winners, no further game).
|
||||||
export { LLWS_LOSER_ADVANCES_ROUNDS, resolveLLWSAdvancement, type LLWSResolvedDestination };
|
*/
|
||||||
|
interface LLWSDestination {
|
||||||
|
round: string;
|
||||||
|
localMatch: number;
|
||||||
|
slot: "participant1Id" | "participant2Id";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLWS advancement map, in SIDE-LOCAL match numbers.
|
||||||
|
*
|
||||||
|
* Keyed by round, then by the local match number of the completed game. Each entry
|
||||||
|
* says where the winner goes and where the loser goes (null = eliminated).
|
||||||
|
*
|
||||||
|
* Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate
|
||||||
|
* cross-overs — the elimination bracket does NOT feed straight across:
|
||||||
|
* Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4)
|
||||||
|
* Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1)
|
||||||
|
* Elim R4: W(Elim R3 m1) v W(Elim R3 m2)
|
||||||
|
*
|
||||||
|
* A loss in the winners bracket routes into the elimination bracket rather than
|
||||||
|
* eliminating the team; a loss in the elimination bracket is final.
|
||||||
|
*/
|
||||||
|
const LLWS_ADVANCEMENT: Record<
|
||||||
|
string,
|
||||||
|
Record<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
|
||||||
|
> = {
|
||||||
|
"Opening Round": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" },
|
||||||
|
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" },
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Winners Round 2": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" },
|
||||||
|
loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Winners Semifinals": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" },
|
||||||
|
loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Winners Final": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" },
|
||||||
|
// A winners-bracket final loss is not an elimination — it drops to the
|
||||||
|
// Elimination Final for a second chance at the side championship.
|
||||||
|
loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Round 1": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Round 2": {
|
||||||
|
// Cross-over: R2 m1's winner meets the OTHER semifinal loser.
|
||||||
|
1: {
|
||||||
|
winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Round 3": {
|
||||||
|
// The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25.
|
||||||
|
1: {
|
||||||
|
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Round 4": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Final": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rounds whose losers drop into the elimination bracket instead of going out. */
|
||||||
|
const LLWS_LOSER_ADVANCES_ROUNDS = new Set([
|
||||||
|
"Opening Round",
|
||||||
|
"Winners Round 2",
|
||||||
|
"Winners Semifinals",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** A resolved LLWS destination, in global (not side-local) match numbers. */
|
||||||
|
export interface LLWSResolvedDestination {
|
||||||
|
round: string;
|
||||||
|
matchNumber: number;
|
||||||
|
slot: "participant1Id" | "participant2Id";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve where the winner and loser of a completed LLWS match go, in global match
|
||||||
|
* numbers. `null` means that participant has no further game (eliminated, or the
|
||||||
|
* tournament is over for them).
|
||||||
|
*
|
||||||
|
* Pure — no DB access — so the whole 38-game routing can be verified against the
|
||||||
|
* official bracket in tests. advanceLLWSWinner is a thin writer on top of this.
|
||||||
|
*/
|
||||||
|
export function resolveLLWSAdvancement(
|
||||||
|
round: string,
|
||||||
|
matchNumber: number
|
||||||
|
): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } {
|
||||||
|
// Terminal rounds — nobody advances.
|
||||||
|
if (round === "Consolation Third Place" || round === "World Championship") {
|
||||||
|
return { winner: null, loser: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bracket Championship is the crossover: the winner goes to the World Championship
|
||||||
|
// and the loser to the Consolation game. The side fixes the slot in both (U.S. takes
|
||||||
|
// participant1, International participant2), so the two sides can't collide.
|
||||||
|
if (round === "Bracket Championship") {
|
||||||
|
const { side } = llwsSideAndLocal("Bracket Championship", matchNumber);
|
||||||
|
const slot: "participant1Id" | "participant2Id" =
|
||||||
|
side === 0 ? "participant1Id" : "participant2Id";
|
||||||
|
return {
|
||||||
|
winner: { round: "World Championship", matchNumber: 1, slot },
|
||||||
|
loser: { round: "Consolation Third Place", matchNumber: 1, slot },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundMap = LLWS_ADVANCEMENT[round];
|
||||||
|
if (!roundMap) {
|
||||||
|
throw new Error(`Round '${round}' is not part of the LLWS bracket`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
|
||||||
|
const routes = roundMap[localMatch];
|
||||||
|
if (!routes) {
|
||||||
|
throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Winner and loser stay on their own side, so the same side offset applies to both.
|
||||||
|
const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null =>
|
||||||
|
d === null
|
||||||
|
? null
|
||||||
|
: { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot };
|
||||||
|
|
||||||
|
return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the 20-team LLWS double-elimination bracket (38 matches).
|
* Generate the 20-team LLWS double-elimination bracket (38 matches).
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-
|
||||||
import { getUserDisplayName } from "~/models/user";
|
import { getUserDisplayName } from "~/models/user";
|
||||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
import { getBracketTemplateIdForSportsSeason } from "~/models/bracket-template";
|
|
||||||
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getEventResults } from "./event-result";
|
import { getEventResults } from "./event-result";
|
||||||
|
|
@ -175,110 +174,6 @@ function nonScoringWinnerFloorFor(
|
||||||
return nextRound?.isScoring === true ? 5 : null;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up the scoring config for a given round name, applying any template-specific
|
* Look up the scoring config for a given round name, applying any template-specific
|
||||||
* overrides before falling back to the standard ROUND_CONFIG.
|
* overrides before falling back to the standard ROUND_CONFIG.
|
||||||
|
|
@ -547,19 +442,6 @@ export async function processMatchResult(
|
||||||
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
||||||
matchId?: string;
|
matchId?: string;
|
||||||
skipSideEffects?: boolean;
|
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
|
* 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
|
* (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be
|
||||||
|
|
@ -570,7 +452,7 @@ export async function processMatchResult(
|
||||||
providedDb?: ReturnType<typeof database>
|
providedDb?: ReturnType<typeof database>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
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) {
|
if (!isScoring) {
|
||||||
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
||||||
|
|
@ -650,15 +532,13 @@ export async function processMatchResult(
|
||||||
: undefined;
|
: undefined;
|
||||||
// Update probabilities first so the standings recalc reads fresh EVs and
|
// Update probabilities first so the standings recalc reads fresh EVs and
|
||||||
// projected points reflect the new result.
|
// projected points reflect the new result.
|
||||||
if (!skipProbabilities) {
|
try {
|
||||||
try {
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
} catch (error) {
|
||||||
} catch (error) {
|
logger.error(
|
||||||
logger.error(
|
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
||||||
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
error
|
||||||
error
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||||
}
|
}
|
||||||
|
|
@ -1481,7 +1361,11 @@ export async function calculateTeamScore(
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
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);
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||||
return templateId;
|
return templateId;
|
||||||
}
|
}
|
||||||
|
|
@ -1590,7 +1474,11 @@ export async function calculateTeamProjectedScore(
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
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);
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||||
return templateId;
|
return templateId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -359,28 +359,18 @@ export async function batchUpsertParticipantSimulatorInputs(
|
||||||
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
|
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
|
||||||
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
|
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
|
||||||
// tell readers whether the stored Elo/rating is generated vs. a trusted
|
// tell readers whether the stored Elo/rating is generated vs. a trusted
|
||||||
// direct value. Two rules apply, and both always apply — they are not
|
// direct value. When a caller supplies explicit metadata, use it as-is
|
||||||
// alternatives:
|
// (prepareSimulatorInputsForRun and the projection importer set the
|
||||||
//
|
// correct flags). Otherwise preserve existing metadata, but drop the
|
||||||
// 1. Drop the method flag for any column receiving a fresh direct
|
// method flag for any column receiving a fresh direct value — otherwise a
|
||||||
// value, otherwise a stale "generated" flag would cause that
|
// stale "generated" flag would cause that newly-entered Elo/rating to be
|
||||||
// newly-entered Elo/rating to be filtered out as derived (see
|
// filtered out as derived (see getParticipantSimulatorInputs).
|
||||||
// getParticipantSimulatorInputs).
|
metadata: sql`CASE
|
||||||
// 2. Merge any metadata the caller supplied over the result
|
WHEN excluded.metadata IS NOT NULL THEN excluded.metadata
|
||||||
// (prepareSimulatorInputsForRun and the projection importers set the
|
ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
|
||||||
// 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)
|
|
||||||
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
|
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
|
||||||
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
|
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
|
||||||
) || COALESCE(excluded.metadata, '{}'::jsonb)`,
|
END`,
|
||||||
updatedAt: sql`excluded.updated_at`,
|
updatedAt: sql`excluded.updated_at`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from
|
||||||
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
|
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getParticipantEV } from "./participant-expected-value";
|
import { getParticipantEV } from "./participant-expected-value";
|
||||||
import { getBracketTemplateIdForSportsSeason } from "./bracket-template";
|
|
||||||
import { calculateEV } from "~/services/ev-calculator";
|
import { calculateEV } from "~/services/ev-calculator";
|
||||||
|
|
||||||
// Re-export types from shared types file
|
// Re-export types from shared types file
|
||||||
|
|
@ -164,7 +163,11 @@ export async function getTeamScoreBreakdown(
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
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);
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||||
return 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,
|
projectedWinsToElo,
|
||||||
} from '~/services/probability-engine';
|
} from '~/services/probability-engine';
|
||||||
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
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
|
// Simulator types that use worldRanking in addition to sourceElo
|
||||||
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
|
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 participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||||
const simulatorInputs = await getParticipantSimulatorInputs(sportsSeasonId);
|
|
||||||
|
|
||||||
// The projection a participant was actually saved with. Read it back verbatim:
|
const existingData: Record<string, { elo: number | null; ranking: number | null }> = {};
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
for (const ev of existingEVs) {
|
for (const ev of existingEVs) {
|
||||||
const existing = existingData[ev.participantId];
|
existingData[ev.participantId] = {
|
||||||
if (!existing) continue;
|
elo: ev.sourceElo ?? null,
|
||||||
existing.elo = ev.sourceElo ?? null;
|
ranking: ev.worldRanking ?? null,
|
||||||
existing.ranking = ev.worldRanking ?? null;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
||||||
|
|
@ -277,16 +252,7 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
if (simulatorConfig) {
|
if (simulatorConfig) {
|
||||||
participants.forEach(p => {
|
participants.forEach(p => {
|
||||||
const d = existingData[p.id];
|
const d = existingData[p.id];
|
||||||
// A stored projection is shown exactly as it was entered. Only fall back to
|
if (d?.elo !== null && d?.elo !== undefined) {
|
||||||
// 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) {
|
|
||||||
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
|
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||||
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||||
: eloToProjectedWins(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 [bulkText, setBulkText] = useState('');
|
||||||
const [parseResults, setParseResults] = useState<{
|
const [parseResults, setParseResults] = useState<{
|
||||||
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }>;
|
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>;
|
||||||
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }>;
|
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
function findParticipantMatch(inputName: string) {
|
function findParticipantMatch(inputName: string) {
|
||||||
|
|
@ -325,8 +291,8 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
|
|
||||||
function parseBulkText() {
|
function parseBulkText() {
|
||||||
const lines = bulkText.split('\n');
|
const lines = bulkText.split('\n');
|
||||||
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }> = [];
|
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; projection: number | null }> = [];
|
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }> = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|
@ -349,9 +315,9 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
const participant = findParticipantMatch(inputName);
|
const participant = findParticipantMatch(inputName);
|
||||||
if (participant && !seen.has(participant.id)) {
|
if (participant && !seen.has(participant.id)) {
|
||||||
seen.add(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) {
|
} else if (!participant) {
|
||||||
unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
|
unmatched.push({ inputName, elo, ranking: null });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const match = usesRanking
|
const match = usesRanking
|
||||||
|
|
@ -376,9 +342,9 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
const participant = findParticipantMatch(inputName);
|
const participant = findParticipantMatch(inputName);
|
||||||
if (participant && !seen.has(participant.id)) {
|
if (participant && !seen.has(participant.id)) {
|
||||||
seen.add(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) {
|
} 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) {
|
for (const m of parseResults.matched) {
|
||||||
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
|
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
|
||||||
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
||||||
// The pasted number goes in as typed. Round-tripping it through the derived
|
if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) {
|
||||||
// Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
|
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||||
// came back as 95.1 before anything was even saved.
|
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||||
if (inputMode === 'projectedWins' && m.projection !== null) {
|
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||||
newWins[m.participantId] = m.projection.toString();
|
).toFixed(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setEloValues(newElos);
|
setEloValues(newElos);
|
||||||
|
|
@ -523,10 +489,7 @@ Mark Selby, 2432`
|
||||||
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
||||||
<span className="text-muted-foreground">{m.inputName}</span>
|
<span className="text-muted-foreground">{m.inputName}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{m.name} →{' '}
|
{m.name} → {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
||||||
{m.projection !== null
|
|
||||||
? `${m.projection} ${projectionUnit} (Elo ${m.elo})`
|
|
||||||
: m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
|
||||||
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
|
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -546,9 +509,7 @@ Mark Selby, 2432`
|
||||||
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
||||||
<span>{u.inputName}</span>
|
<span>{u.inputName}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{u.projection !== null
|
{u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
||||||
? `${u.projection} ${projectionUnit} (Elo ${u.elo})`
|
|
||||||
: u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
|
||||||
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
|
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -579,7 +540,7 @@ Mark Selby, 2432`
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{inputMode === 'projectedWins'
|
{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
|
: 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 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.`}
|
: `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 { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
|
||||||
import {
|
import {
|
||||||
findPlayoffMatchesByEventId,
|
findPlayoffMatchesByEventId,
|
||||||
deletePlayoffMatchesByEventId,
|
|
||||||
generateBracketFromTemplate,
|
generateBracketFromTemplate,
|
||||||
setMatchWinner,
|
setMatchWinner,
|
||||||
advanceWinnerTemplate,
|
advanceWinnerTemplate,
|
||||||
findPlayoffMatchById,
|
findPlayoffMatchById,
|
||||||
assignParticipantsToKnockout,
|
assignParticipantsToKnockout,
|
||||||
doesLoserAdvance,
|
doesLoserAdvance,
|
||||||
reseedAflEliminationFinals,
|
|
||||||
reseedAflSemiFinals,
|
|
||||||
} from "~/models/playoff-match";
|
} from "~/models/playoff-match";
|
||||||
import {
|
import {
|
||||||
createGame,
|
createGame,
|
||||||
|
|
@ -38,7 +35,6 @@ import {
|
||||||
recalculateAffectedLeagues,
|
recalculateAffectedLeagues,
|
||||||
recalculateStandings,
|
recalculateStandings,
|
||||||
autoCompleteRoundIfDone,
|
autoCompleteRoundIfDone,
|
||||||
applyBracketEntryFloors,
|
|
||||||
} from "~/models/scoring-calculator";
|
} from "~/models/scoring-calculator";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||||
|
|
@ -46,7 +42,6 @@ import {
|
||||||
setParticipantResult,
|
setParticipantResult,
|
||||||
findParticipantResultsBySportsSeasonId,
|
findParticipantResultsBySportsSeasonId,
|
||||||
deleteParticipantResultsBySportsSeasonId,
|
deleteParticipantResultsBySportsSeasonId,
|
||||||
deleteParticipantResultsForParticipants,
|
|
||||||
} from "~/models/participant-result";
|
} from "~/models/participant-result";
|
||||||
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
|
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
|
|
@ -174,7 +169,7 @@ async function scoreQualifyingBracket(
|
||||||
/**
|
/**
|
||||||
* Mark the given participants as eliminated (finalPosition = 0) and, for fantasy
|
* Mark the given participants as eliminated (finalPosition = 0) and, for fantasy
|
||||||
* (non-qualifying) events, announce the teams newly eliminated by this run to the
|
* (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
|
* "Newly eliminated" = participants with no prior result row, so re-running a
|
||||||
* generation step never re-announces the same teams. The announcement is 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
|
* the eliminations themselves are already committed. eventId is deliberately
|
||||||
* omitted from the recalc call so the announcement doesn't pull in unrelated
|
* omitted from the recalc call so the announcement doesn't pull in unrelated
|
||||||
* completed matches as "Scored Matches".
|
* 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(
|
async function markEliminatedAndAnnounce(
|
||||||
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
|
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
|
||||||
participantIds: string[]
|
participantIds: string[]
|
||||||
): Promise<{ markedCount: number; recalculated: boolean }> {
|
): Promise<number> {
|
||||||
const existingResults = await findParticipantResultsBySportsSeasonId(event.sportsSeasonId);
|
const existingResults = await findParticipantResultsBySportsSeasonId(event.sportsSeasonId);
|
||||||
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
|
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
|
||||||
const newlyEliminatedIds = participantIds.filter((id) => !alreadyHadResult.has(id));
|
const newlyEliminatedIds = participantIds.filter((id) => !alreadyHadResult.has(id));
|
||||||
|
|
@ -202,8 +190,6 @@ async function markEliminatedAndAnnounce(
|
||||||
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let recalculated = false;
|
|
||||||
|
|
||||||
// QPs (e.g. tennis/CS2 majors) don't get elimination announcements.
|
// QPs (e.g. tennis/CS2 majors) don't get elimination announcements.
|
||||||
if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) {
|
if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -211,13 +197,12 @@ async function markEliminatedAndAnnounce(
|
||||||
eventName: event.name ?? undefined,
|
eventName: event.name ?? undefined,
|
||||||
eliminatedParticipantIds: newlyEliminatedIds,
|
eliminatedParticipantIds: newlyEliminatedIds,
|
||||||
});
|
});
|
||||||
recalculated = true;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("[Eliminations] Discord announcement failed:", err);
|
logger.error("[Eliminations] Discord announcement failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { markedCount: participantIds.length, recalculated };
|
return participantIds.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request, params }: Route.ActionArgs) {
|
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") {
|
if (intent === "generate-bracket") {
|
||||||
const templateId = formData.get("templateId");
|
const templateId = formData.get("templateId");
|
||||||
|
|
||||||
|
|
@ -410,24 +351,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
try {
|
try {
|
||||||
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
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).
|
// PHASE 5.3: Mark participants NOT in the bracket as eliminated (and announce).
|
||||||
const event = await getScoringEventById(params.eventId);
|
const event = await getScoringEventById(params.eventId);
|
||||||
if (event) {
|
if (event) {
|
||||||
|
|
@ -436,24 +359,17 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const toEliminate = allParticipants
|
const toEliminate = allParticipants
|
||||||
.filter((p) => !participantsInBracket.has(p.id))
|
.filter((p) => !participantsInBracket.has(p.id))
|
||||||
.map((p) => p.id);
|
.map((p) => p.id);
|
||||||
const { markedCount, recalculated } = await markEliminatedAndAnnounce(event, toEliminate);
|
const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate);
|
||||||
logger.log(`[BracketGeneration] Marked ${markedCount} participants as eliminated`);
|
logger.log(`[BracketGeneration] Marked ${eliminatedCount} 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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" };
|
return { success: "Bracket generated successfully" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error generating bracket:", 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") {
|
if (intent === "reprocess-bracket") {
|
||||||
try {
|
try {
|
||||||
const event = await getScoringEventById(params.eventId);
|
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.` };
|
return { success: `${baseMessage} No mirror windows to sync.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches.length === 0) {
|
if (completed.length === 0) {
|
||||||
return { error: "No bracket to reprocess" };
|
return { error: "No completed matches to reprocess" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wipe this bracket's participants' results and rebuild from scratch. Deleting
|
// Delete ALL results for this sports season and rebuild from scratch.
|
||||||
// only the partial rows would leave stale finalized ones, which the "never
|
// Only deleting partial rows leaves stale finalized rows that block
|
||||||
// un-finalize" guard in upsertParticipantResult then refuses to correct.
|
// the "never un-finalize" guard in upsertParticipantResult.
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
const db = database();
|
const db = database();
|
||||||
// Reused further down to decide who is *not* in the bracket and so eliminated.
|
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
|
||||||
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);
|
|
||||||
|
|
||||||
// Replay each completed match in bracket order (earlier rounds first).
|
// Replay each completed match in bracket order (earlier rounds first).
|
||||||
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
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).
|
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
|
||||||
// This covers teams that didn't make the playoffs/play-in tournament.
|
// This covers teams that didn't make the playoffs/play-in tournament.
|
||||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
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;
|
let eliminatedCount = 0;
|
||||||
for (const participant of allParticipants) {
|
for (const participant of allParticipants) {
|
||||||
if (!bracketParticipantIds.has(participant.id)) {
|
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.
|
// skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement.
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
|
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
|
||||||
|
|
||||||
return {
|
return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` };
|
||||||
success:
|
|
||||||
`Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ` +
|
|
||||||
`${entryFloorCount} seeded participant(s) given their guaranteed entry floor, ` +
|
|
||||||
`${eliminatedCount} non-bracket participant(s) eliminated`,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error reprocessing bracket:", error);
|
logger.error("Error reprocessing bracket:", error);
|
||||||
return {
|
return {
|
||||||
|
|
@ -1296,10 +1092,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const toEliminate = allParticipants
|
const toEliminate = allParticipants
|
||||||
.filter((p) => !uniqueParticipants.has(p.id))
|
.filter((p) => !uniqueParticipants.has(p.id))
|
||||||
.map((p) => p.id);
|
.map((p) => p.id);
|
||||||
const { markedCount: eliminatedCount } = await markEliminatedAndAnnounce(
|
const eliminatedCount = await markEliminatedAndAnnounce(groupsEvent, toEliminate);
|
||||||
groupsEvent,
|
|
||||||
toEliminate
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
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>
|
</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 ====== */}
|
{/* ====== SETUP PHASE ====== */}
|
||||||
{showSetup && (
|
{showSetup && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
batchUpsertParticipantEVs,
|
batchUpsertParticipantEVs,
|
||||||
getAllParticipantEVsForSeason
|
getAllParticipantEVsForSeason
|
||||||
} from "~/models/participant-expected-value";
|
} from "~/models/participant-expected-value";
|
||||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const sportsSeason = await findSportsSeasonById(params.id);
|
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) {
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
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,
|
probSeventh: parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100,
|
||||||
probEighth: parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100,
|
probEighth: parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100,
|
||||||
},
|
},
|
||||||
scoringRules: DEFAULT_SCORING_RULES,
|
scoringRules,
|
||||||
source: "manual" as const,
|
source: "manual" as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { ArrowLeft, Calculator } from "lucide-react";
|
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 {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Expected Values — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
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 };
|
export { loader };
|
||||||
|
|
||||||
// EV is shown on the same reference scale the runner persists it with: a sports season
|
// DEFAULT scoring values — must match DEFAULT_SCORING_RULES in the simulate route.
|
||||||
// is shared across leagues with different scoring, so DEFAULT_SCORING_RULES is the
|
// Scoring: 1st=100, 2nd=70, 3rd/4th (FF losers)=45 each, 5th–8th (E8 losers)=20 each.
|
||||||
// common scale and each league re-derives its own EV from the stored probabilities
|
// Sum = 100+70+45+45+20+20+20+20 = 340.
|
||||||
// (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.
|
|
||||||
//
|
//
|
||||||
// Total EV invariant: Σ EV across all participants = Σ scoring values = 340,
|
// Total EV invariant: Σ EV across all participants = Σ scoring values = 340,
|
||||||
// because each probability column sums to 1.0 across all participants.
|
// 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
|
// 1. Stale EV records from a prior simulation run (fix: re-run simulation, which now
|
||||||
// zeros non-bracket participants automatically)
|
// zeros non-bracket participants automatically)
|
||||||
// 2. DB precision truncation (numeric(6,4) = 4dp; max drift ≈ ±1 for 68 teams)
|
// 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;
|
probFirst: string; probSecond: string; probThird: string; probFourth: string;
|
||||||
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
|
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
|
||||||
}): number {
|
}): number {
|
||||||
return calculateEV(
|
return parseFloat(ev.probFirst) * SCORING[0]
|
||||||
{
|
+ parseFloat(ev.probSecond) * SCORING[1]
|
||||||
probFirst: parseFloat(ev.probFirst),
|
+ parseFloat(ev.probThird) * SCORING[2]
|
||||||
probSecond: parseFloat(ev.probSecond),
|
+ parseFloat(ev.probFourth) * SCORING[3]
|
||||||
probThird: parseFloat(ev.probThird),
|
+ parseFloat(ev.probFifth) * SCORING[4]
|
||||||
probFourth: parseFloat(ev.probFourth),
|
+ parseFloat(ev.probSixth) * SCORING[5]
|
||||||
probFifth: parseFloat(ev.probFifth),
|
+ parseFloat(ev.probSeventh) * SCORING[6]
|
||||||
probSixth: parseFloat(ev.probSixth),
|
+ parseFloat(ev.probEighth) * SCORING[7];
|
||||||
probSeventh: parseFloat(ev.probSeventh),
|
|
||||||
probEighth: parseFloat(ev.probEighth),
|
|
||||||
},
|
|
||||||
DEFAULT_SCORING_RULES
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(val: string | number) {
|
function fmt(val: string | number) {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@ import { batchUpsertParticipantEVs } from '~/models/participant-expected-value';
|
||||||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||||
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';
|
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';
|
||||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||||
import { calculateEV } from '~/services/ev-calculator';
|
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
|
||||||
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
|
|
||||||
import { recalculateStandings } from '~/models/scoring-calculator';
|
import { recalculateStandings } from '~/models/scoring-calculator';
|
||||||
import { database } from '~/database/context';
|
import { database } from '~/database/context';
|
||||||
import * as schema from '~/database/schema';
|
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 { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
|
||||||
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
|
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 {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Golf Skills — ${data?.sportsSeason?.name ?? 'Sports Season'} - Brackt Admin` }];
|
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";
|
} from "~/services/simulations/manifest";
|
||||||
import {
|
import {
|
||||||
getSimulatorInputPolicy,
|
getSimulatorInputPolicy,
|
||||||
resolveRatings,
|
|
||||||
resolveSourceElos,
|
|
||||||
type MissingEloStrategy,
|
type MissingEloStrategy,
|
||||||
type MissingRatingStrategy,
|
type MissingRatingStrategy,
|
||||||
} from "~/services/simulations/input-policy";
|
} from "~/services/simulations/input-policy";
|
||||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
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 {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Simulator Setup - ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
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.requiredInputs,
|
||||||
...config.profile.optionalInputs,
|
...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) => ({
|
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
|
||||||
key,
|
key,
|
||||||
label: simulatorInputLabel(key),
|
label: simulatorInputLabel(key),
|
||||||
required: config.profile.requiredInputs.includes(key),
|
required: config.profile.requiredInputs.includes(key),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// The projection this simulator can derive Elo from, labelled here for the same
|
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy, inputColumns };
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActionData {
|
interface ActionData {
|
||||||
|
|
@ -182,7 +124,6 @@ const HONORED_ENGINE_KNOBS = new Set([
|
||||||
"baseDrawRate",
|
"baseDrawRate",
|
||||||
"drawDecay",
|
"drawDecay",
|
||||||
"ratingScaleFactor",
|
"ratingScaleFactor",
|
||||||
"projectedWinsWeight",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function parseOptionalNumber(value: string | undefined): number | null {
|
function parseOptionalNumber(value: string | undefined): number | null {
|
||||||
|
|
@ -291,28 +232,17 @@ function parseInputCsv(
|
||||||
continue;
|
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({
|
inputs.push({
|
||||||
participantId,
|
participantId,
|
||||||
sportsSeasonId,
|
sportsSeasonId,
|
||||||
sourceElo,
|
sourceElo: parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined,
|
||||||
sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined,
|
sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined,
|
||||||
worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined,
|
worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined,
|
||||||
rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined,
|
rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined,
|
||||||
projectedWins,
|
projectedWins: parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined,
|
||||||
projectedTablePoints,
|
projectedTablePoints: parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined,
|
||||||
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined,
|
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined,
|
||||||
region: cols[indexes.get("region") ?? -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,
|
...currentPolicy,
|
||||||
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
||||||
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
||||||
baseEloPriority: parseBaseEloPriorityChoice(formData.get("baseEloPriority"), currentPolicy.baseEloPriority),
|
|
||||||
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
|
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
|
||||||
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
|
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
|
||||||
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
||||||
|
|
@ -455,7 +384,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
const isSubmitting = navigation.state === "submitting";
|
const isSubmitting = navigation.state === "submitting";
|
||||||
const setupSections = config.profile.setupSections;
|
const setupSections = config.profile.setupSections;
|
||||||
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
|
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
|
||||||
const projectionsOutrankElo = inputPolicy.baseEloPriority[0] !== "sourceElo";
|
|
||||||
const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
|
const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
|
||||||
const showsInputPolicy =
|
const showsInputPolicy =
|
||||||
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
|
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
|
// 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.
|
// 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 requiredInputs = config.profile.requiredInputs;
|
||||||
const gridTemplate = `2fr repeat(${Math.max(inputColumns.length, 1)}, 1fr)`;
|
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.
|
// 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)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// A required Elo/rating counts as present when the input policy resolves one,
|
const isRowIncomplete = (input: (typeof inputRows)[number]["input"]) =>
|
||||||
// not only when it is stored directly: getParticipantSimulatorInputs deliberately
|
requiredInputs.some((key) => input?.[key] === null || input?.[key] === undefined);
|
||||||
// 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 [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [onlyMissing, setOnlyMissing] = useState(false);
|
const [onlyMissing, setOnlyMissing] = useState(false);
|
||||||
|
|
@ -501,11 +420,11 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
const normalizedSearch = normalizeName(search);
|
const normalizedSearch = normalizeName(search);
|
||||||
return inputRows.filter(({ participant, input }) => {
|
return inputRows.filter(({ participant, input }) => {
|
||||||
if (normalizedSearch && !normalizeName(participant.name).includes(normalizedSearch)) return false;
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 totalPages = Math.max(1, Math.ceil(filteredRows.length / PARTICIPANT_PAGE_SIZE));
|
||||||
const safePage = Math.min(page, totalPages - 1);
|
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.
|
this Elo — they are not blended again per game.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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") && (
|
{config.profile.requiredInputs.includes("sourceElo") && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
|
@ -872,7 +771,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
pageRows.map(({ participant, input }) => {
|
pageRows.map(({ participant, input }) => {
|
||||||
const incomplete = isRowIncomplete(participant.id, input);
|
const incomplete = isRowIncomplete(input);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={participant.id}
|
key={participant.id}
|
||||||
|
|
@ -885,34 +784,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
</div>
|
</div>
|
||||||
{inputColumns.length > 0 ? (
|
{inputColumns.length > 0 ? (
|
||||||
inputColumns.map((column) => {
|
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];
|
const value = input?.[column.key];
|
||||||
return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>;
|
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 { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||||
import { getSurfaceElosForSeason, batchUpsertSurfaceElos } from '~/models/surface-elo';
|
import { getSurfaceElosForSeason, batchUpsertSurfaceElos } from '~/models/surface-elo';
|
||||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||||
import { calculateEV } from '~/services/ev-calculator';
|
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
|
||||||
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
|
|
||||||
import { recalculateStandings } from '~/models/scoring-calculator';
|
import { recalculateStandings } from '~/models/scoring-calculator';
|
||||||
import { database } from '~/database/context';
|
import { database } from '~/database/context';
|
||||||
import * as schema from '~/database/schema';
|
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 { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
|
||||||
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
|
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 {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Surface Elo — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
return [{ title: `Surface Elo — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,6 @@ import * as participantEVModel from "~/models/participant-expected-value";
|
||||||
// Mock the dependencies
|
// Mock the dependencies
|
||||||
vi.mock("~/models/participant-result");
|
vi.mock("~/models/participant-result");
|
||||||
vi.mock("~/models/participant-expected-value");
|
vi.mock("~/models/participant-expected-value");
|
||||||
vi.mock("~/models/simulator");
|
|
||||||
vi.mock("~/models/sports-season");
|
|
||||||
vi.mock("~/services/simulations/runner");
|
|
||||||
vi.mock("~/database/context", () => ({
|
vi.mock("~/database/context", () => ({
|
||||||
database: () => ({
|
database: () => ({
|
||||||
query: {
|
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", () => {
|
describe("probability-updater", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -272,262 +266,5 @@ describe("probability-updater", () => {
|
||||||
expect(callArgs.probabilities.probSeventh).toBe(0);
|
expect(callArgs.probabilities.probSeventh).toBe(0);
|
||||||
expect(callArgs.probabilities.probEighth).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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import {
|
||||||
processQualifyingBracketEvent,
|
processQualifyingBracketEvent,
|
||||||
recalculateAffectedLeagues,
|
recalculateAffectedLeagues,
|
||||||
} from "~/models/scoring-calculator";
|
} from "~/models/scoring-calculator";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
|
||||||
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
||||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||||
import {
|
import {
|
||||||
|
|
@ -284,11 +283,6 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventName: event.name ?? undefined,
|
eventName: event.name ?? undefined,
|
||||||
matchId: playoffMatch.id,
|
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
|
loserAdvances: event.bracketTemplateId
|
||||||
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
|
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
|
||||||
: false,
|
: 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 };
|
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,6 @@ import type { ProbabilityDistribution } from "./ev-calculator";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
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
|
* Result of probability update operation
|
||||||
|
|
@ -101,40 +96,6 @@ function createFinishedProbabilities(finalPosition: number): number[] {
|
||||||
return probs;
|
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
|
* 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)
|
* 1. Get all participant results (finished participants)
|
||||||
* 2. Get all existing participant EVs
|
* 2. Get all existing participant EVs
|
||||||
* 3. For finished participants: set 100% at their placement
|
* 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,
|
* 4. For unfinished participants: recalculate using ICM with remaining participants
|
||||||
* otherwise recalculate using ICM with remaining participants
|
|
||||||
*
|
*
|
||||||
* @param sportsSeasonId Sports season to update
|
* @param sportsSeasonId Sports season to update
|
||||||
* @param recalculateUnfinished Whether to recalculate unfinished participants (default true)
|
* @param recalculateUnfinished Whether to recalculate unfinished participants (default true)
|
||||||
|
|
@ -163,63 +123,55 @@ export async function updateProbabilitiesAfterResult(
|
||||||
// Get all existing EVs
|
// Get all existing EVs
|
||||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||||
|
|
||||||
// Create map of participantId -> finalPosition.
|
// 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.
|
|
||||||
const finishedMap = new Map(
|
const finishedMap = new Map(
|
||||||
results
|
results
|
||||||
.filter(r => r.finalPosition !== null && !r.isPartialScore)
|
.filter(r => r.finalPosition !== null)
|
||||||
.map(r => [r.participantId, r.finalPosition ?? 0])
|
.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
|
// Recalculate unfinished participants if requested
|
||||||
if (recalculateUnfinished) {
|
if (recalculateUnfinished) {
|
||||||
const unfinishedEVs = existingEVs.filter(
|
const unfinishedEVs = existingEVs.filter(
|
||||||
ev => !finishedMap.has(ev.participantId)
|
ev => !finishedMap.has(ev.participantId)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (unfinishedEVs.length > 0 && (await shouldRerunSimulator(sportsSeasonId))) {
|
if (unfinishedEVs.length > 0) {
|
||||||
// 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) {
|
|
||||||
// Get their current championship probabilities (use existing P(1st) as proxy)
|
// Get their current championship probabilities (use existing P(1st) as proxy)
|
||||||
const unfinishedOdds = unfinishedEVs.map(ev => {
|
const unfinishedOdds = unfinishedEVs.map(ev => {
|
||||||
const pFirst = parseFloat(ev.probFirst);
|
const pFirst = parseFloat(ev.probFirst);
|
||||||
|
|
@ -257,7 +209,7 @@ export async function updateProbabilitiesAfterResult(
|
||||||
participantId,
|
participantId,
|
||||||
sportsSeasonId,
|
sportsSeasonId,
|
||||||
probabilities,
|
probabilities,
|
||||||
scoringRules: DEFAULT_SCORING_RULES,
|
scoringRules: defaultScoringRules,
|
||||||
source: 'futures_odds', // Recalculated from remaining odds
|
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 {
|
return {
|
||||||
finishedParticipants: finishedMap.size,
|
finishedParticipants: finishedMap.size,
|
||||||
unfishedParticipants: existingEVs.length - finishedMap.size,
|
unfishedParticipants: existingEVs.length - finishedMap.size,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,6 @@
|
||||||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
import {
|
import { getTeamData, eloWinProbability, AFLSimulator } from "../afl-simulator";
|
||||||
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";
|
|
||||||
|
|
||||||
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -134,82 +125,8 @@ const PARTICIPANT_ROWS = AFL_TEAMS.map((name, i) => ({
|
||||||
|
|
||||||
const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id);
|
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()", () => {
|
describe("AFLSimulator.simulate()", () => {
|
||||||
let mockDb: {
|
let mockDb: { select: MockInstance };
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { database } = await import("~/database/context");
|
const { database } = await import("~/database/context");
|
||||||
|
|
@ -219,11 +136,6 @@ describe("AFLSimulator.simulate()", () => {
|
||||||
|
|
||||||
let selectCallCount = 0;
|
let selectCallCount = 0;
|
||||||
mockDb = {
|
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(() => {
|
select: vi.fn().mockImplementation(() => {
|
||||||
selectCallCount++;
|
selectCallCount++;
|
||||||
if (selectCallCount === 1) {
|
if (selectCallCount === 1) {
|
||||||
|
|
@ -443,259 +355,4 @@ describe("AFLSimulator.simulate()", () => {
|
||||||
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
|
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
|
||||||
expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst);
|
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"]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,33 +26,6 @@ describe("simulator input policy", () => {
|
||||||
expect(resolved.get("team-1")).toMatchObject({ sourceElo: 1600, method: "direct" });
|
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", () => {
|
it("derives Elo from projected wins when Elo is missing", () => {
|
||||||
const resolved = resolveSourceElos(
|
const resolved = resolveSourceElos(
|
||||||
[{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],
|
[{ 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 { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||||
import {
|
import { LLWSSimulator } from "../llws-simulator";
|
||||||
LLWSSimulator,
|
|
||||||
makePlayGame,
|
|
||||||
playCrossoverGame,
|
|
||||||
readBracketSlots,
|
|
||||||
} from "../llws-simulator";
|
|
||||||
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
|
|
||||||
import type { SimulationResult } from "../types";
|
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({
|
vi.mock("~/database/context", () => ({
|
||||||
database: vi.fn(),
|
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 ────────────────────────────────────────────────────────────────────
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("LLWSSimulator", () => {
|
describe("LLWSSimulator", () => {
|
||||||
let mockDb: {
|
let mockDb: { select: MockInstance };
|
||||||
select: MockInstance;
|
|
||||||
query: {
|
|
||||||
scoringEvents: { findMany: MockInstance };
|
|
||||||
playoffMatches: { findMany: MockInstance };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
let selectCallCount: number;
|
let selectCallCount: number;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
selectCallCount = 0;
|
selectCallCount = 0;
|
||||||
const { database } = await import("~/database/context");
|
const { database } = await import("~/database/context");
|
||||||
mockDb = {
|
mockDb = { select: vi.fn() };
|
||||||
select: vi.fn(),
|
|
||||||
query: {
|
|
||||||
scoringEvents: { findMany: vi.fn().mockResolvedValue([]) },
|
|
||||||
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupMockDb(
|
function setupMockDb(
|
||||||
participants: { id: string; name?: string; externalId: string | null }[],
|
participants: { id: string; name?: string; externalId: string | null }[],
|
||||||
evRows: { participantId: string; sourceOdds: number | null }[],
|
evRows: { participantId: string; sourceOdds: number | null }[]
|
||||||
bracketMatches?: Partial<PlayoffMatchRow>[]
|
|
||||||
) {
|
) {
|
||||||
selectCallCount = 0;
|
|
||||||
mockDb.select.mockImplementation(() => {
|
mockDb.select.mockImplementation(() => {
|
||||||
const callIndex = selectCallCount++;
|
const callIndex = selectCallCount++;
|
||||||
const data = callIndex === 0 ? participants : evRows;
|
const data = callIndex === 0 ? participants : evRows;
|
||||||
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
|
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") {
|
function defaultParticipants(mode: "randomized" | "fixed" = "randomized") {
|
||||||
|
|
@ -458,493 +311,4 @@ describe("LLWSSimulator", () => {
|
||||||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Futures calibration ───────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// A championship future already contains the ~6 wins needed to lift the trophy.
|
|
||||||
// Feeding it straight into a single game (p1 / (p1 + p2)) makes every game as
|
|
||||||
// lopsided as the whole tournament and compounds the favorite's edge round after
|
|
||||||
// round, which inflated favorites badly. The simulator decompresses futures to Elo
|
|
||||||
// first, so re-simulating a random draw should hand back roughly the prices it was
|
|
||||||
// given rather than a much more extreme distribution.
|
|
||||||
|
|
||||||
describe("futures calibration", () => {
|
|
||||||
// A representative LLWS board: a clear favorite, a long tail.
|
|
||||||
const BOARD = [
|
|
||||||
200, 750, 900, 1200, 1600, 2000, 2500, 3000, 4000, 6000,
|
|
||||||
350, 800, 1000, 1400, 1800, 2200, 2800, 3500, 5000, 8000,
|
|
||||||
];
|
|
||||||
|
|
||||||
function boardEvRows() {
|
|
||||||
return ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: BOARD[i] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
it("reproduces the favorite's championship price instead of inflating it", async () => {
|
|
||||||
setupMockDb(defaultParticipants(), boardEvRows());
|
|
||||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
|
||||||
|
|
||||||
const market = marketProbabilities(BOARD);
|
|
||||||
const simulated = probsFor(results, "us-1").probFirst;
|
|
||||||
|
|
||||||
// The favorite prices around 22%. The old raw-futures model simulated ~45%.
|
|
||||||
expect(simulated).toBeCloseTo(market[0], 1);
|
|
||||||
expect(simulated).toBeLessThan(market[0] + 0.06);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the whole field close to its priced championship probability", async () => {
|
|
||||||
setupMockDb(defaultParticipants(), boardEvRows());
|
|
||||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
|
||||||
|
|
||||||
const market = marketProbabilities(BOARD);
|
|
||||||
const errors = ALL_IDS.map((id, i) => probsFor(results, id).probFirst - market[i]);
|
|
||||||
const rmse = Math.sqrt(errors.reduce((s, e) => s + e * e, 0) / errors.length);
|
|
||||||
|
|
||||||
// Calibrated RMSE is ~0.003; the old model sat around 0.06.
|
|
||||||
expect(rmse).toBeLessThan(0.02);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Regression: the previous mapping rescaled every field onto a fixed 1250–1750
|
|
||||||
// Elo span, which discarded how spread out the board actually was and pulled a
|
|
||||||
// nearly flat field apart into contenders and no-hopers the market never implied.
|
|
||||||
const TIGHT_BOARD = Array.from({ length: 20 }, (_, i) => 1500 + i * 35);
|
|
||||||
|
|
||||||
it("does not inflate the favorite on a tightly priced board", async () => {
|
|
||||||
setupMockDb(
|
|
||||||
defaultParticipants(),
|
|
||||||
ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: TIGHT_BOARD[i] }))
|
|
||||||
);
|
|
||||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
|
||||||
|
|
||||||
const market = marketProbabilities(TIGHT_BOARD);
|
|
||||||
const simulated = probsFor(results, "us-1").probFirst;
|
|
||||||
|
|
||||||
// The favorite prices near 6%. A fixed-span mapping simulated it around 13%,
|
|
||||||
// so the band is wide enough for Monte Carlo noise but nowhere near that.
|
|
||||||
expect(Math.abs(simulated - market[0])).toBeLessThan(0.015);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a tightly priced field tight", async () => {
|
|
||||||
setupMockDb(
|
|
||||||
defaultParticipants(),
|
|
||||||
ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: TIGHT_BOARD[i] }))
|
|
||||||
);
|
|
||||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
|
||||||
const probs = ALL_IDS.map((id) => probsFor(results, id).probFirst);
|
|
||||||
|
|
||||||
// Every team prices between roughly 4% and 6%, so nobody should run away with
|
|
||||||
// it and nobody should be written off.
|
|
||||||
expect(Math.max(...probs)).toBeLessThan(0.09);
|
|
||||||
expect(Math.min(...probs)).toBeGreaterThan(0.02);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rates a team with no odds entered around the middle of the field", async () => {
|
|
||||||
// us-5 is priced mid-board; blanking its odds should not move it far. The old
|
|
||||||
// 1500 fallback was the centre of the Elo scale rather than of the field, which
|
|
||||||
// promoted an unpriced team to roughly 6th of 20.
|
|
||||||
const priced = ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: BOARD[i] }));
|
|
||||||
setupMockDb(defaultParticipants(), priced);
|
|
||||||
const withOdds = probsFor(
|
|
||||||
await new LLWSSimulator(20_000).simulate("season-1"), "us-5"
|
|
||||||
).probFirst;
|
|
||||||
|
|
||||||
const blanked = priced.map((row) =>
|
|
||||||
row.participantId === "us-5" ? { ...row, sourceOdds: null } : row
|
|
||||||
);
|
|
||||||
setupMockDb(defaultParticipants(), blanked);
|
|
||||||
const withoutOdds = probsFor(
|
|
||||||
await new LLWSSimulator(20_000).simulate("season-1"), "us-5"
|
|
||||||
).probFirst;
|
|
||||||
|
|
||||||
// Priced 5th of 20, so the median rating should land it in the same territory.
|
|
||||||
expect(withoutOdds).toBeGreaterThan(withOdds / 2);
|
|
||||||
expect(withoutOdds).toBeLessThan(withOdds * 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not starve longshots of championship probability", async () => {
|
|
||||||
setupMockDb(defaultParticipants(), boardEvRows());
|
|
||||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
|
||||||
|
|
||||||
// The longest shot on the board prices near 0.8%. Compounding raw futures drove
|
|
||||||
// teams like this to essentially zero.
|
|
||||||
const longshot = probsFor(results, "intl-10").probFirst;
|
|
||||||
expect(longshot).toBeGreaterThan(0.002);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Bracket-aware mode ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("bracket-aware mode", () => {
|
|
||||||
it("uses the real draw rather than shuffling when a bracket is seeded", async () => {
|
|
||||||
// With no odds every team is equally strong, so the only edge is structural:
|
|
||||||
// the two bye teams skip the Opening Round. Under a randomized draw every team
|
|
||||||
// gets a bye equally often and this difference disappears.
|
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), seededBracket());
|
|
||||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
|
||||||
|
|
||||||
const byeTeam = probsFor(results, "us-9").probFirst;
|
|
||||||
const openingTeam = probsFor(results, "us-1").probFirst;
|
|
||||||
expect(byeTeam).toBeGreaterThan(openingTeam);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still returns a full, normalized distribution in bracket mode", async () => {
|
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), seededBracket());
|
|
||||||
const results = await new LLWSSimulator(5_000).simulate("season-1");
|
|
||||||
|
|
||||||
expect(results).toHaveLength(20);
|
|
||||||
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
|
|
||||||
expect(results.reduce((s, r) => s + r.probabilities.probThird, 0)).toBeCloseTo(1.0, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to a randomized draw when the bracket has no participants seeded", async () => {
|
|
||||||
const unseeded = seededBracket().map((m) => ({
|
|
||||||
...m,
|
|
||||||
participant1Id: null,
|
|
||||||
participant2Id: null,
|
|
||||||
}));
|
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), unseeded);
|
|
||||||
const results = await new LLWSSimulator(5_000).simulate("season-1");
|
|
||||||
|
|
||||||
expect(results).toHaveLength(20);
|
|
||||||
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the most recent bracket event when several exist", async () => {
|
|
||||||
// A stale event's matches would carry no draw, silently reverting to a
|
|
||||||
// randomized one and discarding every recorded result.
|
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), seededBracket());
|
|
||||||
mockDb.query.scoringEvents.findMany.mockResolvedValue([
|
|
||||||
{ id: "stale-event", createdAt: new Date("2026-07-01") },
|
|
||||||
{ id: "event-1", createdAt: new Date("2026-08-01") },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
|
||||||
|
|
||||||
// Bracket mode is in force, so the fixed bye slots still show their advantage.
|
|
||||||
expect(probsFor(results, "us-9").probFirst).toBeGreaterThan(
|
|
||||||
probsFor(results, "us-1").probFirst
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when the bracket is only partially seeded", async () => {
|
|
||||||
// participant1Id/participant2Id are ON DELETE SET NULL, so removing and
|
|
||||||
// re-adding one participant mid-tournament empties a single slot. Falling back
|
|
||||||
// to a randomized draw there would put eliminated teams back in contention.
|
|
||||||
const holed = seededBracket().map((m) =>
|
|
||||||
m.round === "Opening Round" && m.matchNumber === 3
|
|
||||||
? { ...m, participant2Id: null }
|
|
||||||
: m
|
|
||||||
);
|
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), holed);
|
|
||||||
await expect(new LLWSSimulator(100).simulate("season-1")).rejects.toThrow(
|
|
||||||
/partially seeded \(19 of 20/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when the bracket seeds the same team into two slots", async () => {
|
|
||||||
const duplicated = seededBracket().map((m) =>
|
|
||||||
m.round === "Opening Round" && m.matchNumber === 2
|
|
||||||
? { ...m, participant1Id: "us-1" } // us-1 already opens match 1
|
|
||||||
: m
|
|
||||||
);
|
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), duplicated);
|
|
||||||
await expect(new LLWSSimulator(100).simulate("season-1")).rejects.toThrow(
|
|
||||||
/more than one slot/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("takes sides from the bracket, not externalId, once a bracket is seeded", async () => {
|
|
||||||
// The bracket is authoritative about the draw, so an externalId the pre-bracket
|
|
||||||
// path would reject must not block a season that already has a real bracket.
|
|
||||||
const participants = [
|
|
||||||
...US_IDS.map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
|
|
||||||
...INTL_IDS.slice(0, 9).map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
|
||||||
{ id: "intl-10", name: "Team intl-10", externalId: "CANADA" },
|
|
||||||
];
|
|
||||||
setupMockDb(participants, makeEvRows(ALL_IDS), seededBracket());
|
|
||||||
const results = await new LLWSSimulator(5_000).simulate("season-1");
|
|
||||||
|
|
||||||
expect(results).toHaveLength(20);
|
|
||||||
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when the bracket is seeded with a participant outside the season", async () => {
|
|
||||||
const foreign = seededBracket().map((m) =>
|
|
||||||
m.round === "Opening Round" && m.matchNumber === 1
|
|
||||||
? { ...m, participant1Id: "stranger-1" }
|
|
||||||
: m
|
|
||||||
);
|
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), foreign);
|
|
||||||
await expect(new LLWSSimulator(100).simulate("season-1")).rejects.toThrow(
|
|
||||||
/not in this sports season/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Completed results ─────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// The core of the fix: games already played must stick across every iteration
|
|
||||||
// instead of being re-simulated from scratch.
|
|
||||||
|
|
||||||
describe("completed results", () => {
|
|
||||||
// us-1 is a strong favorite, so a recorded loss should visibly move its number.
|
|
||||||
const favouredEvRows = ALL_IDS.map((participantId, i) => ({
|
|
||||||
participantId,
|
|
||||||
sourceOdds: participantId === "us-1" ? 200 : 1000 + i * 200,
|
|
||||||
}));
|
|
||||||
|
|
||||||
async function probFirstFor(id: string, matches: PlayoffMatchRow[]): Promise<number> {
|
|
||||||
setupMockDb(defaultParticipants(), favouredEvRows, matches);
|
|
||||||
const results = await new LLWSSimulator(20_000).simulate("season-1");
|
|
||||||
return probsFor(results, id).probFirst;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("drops a favorite's championship probability after a recorded loss", async () => {
|
|
||||||
const before = await probFirstFor("us-1", seededBracket());
|
|
||||||
|
|
||||||
// us-1 loses its Opening Round game. In double elimination that is not an
|
|
||||||
// elimination — it drops to the elimination bracket — but it now needs a much
|
|
||||||
// longer path, so its title probability must fall.
|
|
||||||
const afterLoss = completeMatch(seededBracket(), "Opening Round", 1, "us-2", "us-1");
|
|
||||||
const after = await probFirstFor("us-1", afterLoss);
|
|
||||||
|
|
||||||
expect(after).toBeLessThan(before);
|
|
||||||
// Not merely noise: a first-round loss is a real blow to a favorite.
|
|
||||||
expect(after).toBeLessThan(before * 0.8);
|
|
||||||
// But not elimination either — the elimination bracket still reaches the final.
|
|
||||||
expect(after).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("raises the opponent's championship probability after that same win", async () => {
|
|
||||||
const before = await probFirstFor("us-2", seededBracket());
|
|
||||||
const afterWin = completeMatch(seededBracket(), "Opening Round", 1, "us-2", "us-1");
|
|
||||||
const after = await probFirstFor("us-2", afterWin);
|
|
||||||
|
|
||||||
expect(after).toBeGreaterThan(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("zeroes out a team that has been eliminated (two recorded losses)", async () => {
|
|
||||||
// Fill the elimination-bracket game the way advancement would: the Opening
|
|
||||||
// Round 1 and Opening Round 4 losers meet in Elimination Round 1 match 2.
|
|
||||||
let matches = seededBracket();
|
|
||||||
matches = completeMatch(matches, "Opening Round", 1, "us-2", "us-1");
|
|
||||||
matches = completeMatch(matches, "Opening Round", 4, "us-7", "us-8");
|
|
||||||
matches = completeMatch(matches, "Elimination Round 1", 2, "us-8", "us-1");
|
|
||||||
|
|
||||||
setupMockDb(defaultParticipants(), favouredEvRows, matches);
|
|
||||||
const results = await new LLWSSimulator(5_000).simulate("season-1");
|
|
||||||
const eliminated = probsFor(results, "us-1");
|
|
||||||
|
|
||||||
// A second loss is final — every placement tier must be exactly zero.
|
|
||||||
for (const value of Object.values(eliminated)) {
|
|
||||||
expect(value).toBe(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the distribution normalized once results have been recorded", async () => {
|
|
||||||
let matches = seededBracket();
|
|
||||||
matches = completeMatch(matches, "Opening Round", 1, "us-2", "us-1");
|
|
||||||
matches = completeMatch(matches, "Opening Round", 5, "intl-2", "intl-1");
|
|
||||||
|
|
||||||
setupMockDb(defaultParticipants(), favouredEvRows, matches);
|
|
||||||
const results = await new LLWSSimulator(5_000).simulate("season-1");
|
|
||||||
|
|
||||||
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
|
|
||||||
expect(results.reduce((s, r) => s + r.probabilities.probSecond, 0)).toBeCloseTo(1.0, 1);
|
|
||||||
expect(results.reduce((s, r) => s + r.probabilities.probFifth, 0)).toBeCloseTo(1.0, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores a completed result whose participants never reach that game", async () => {
|
|
||||||
// A corrupt row: Elimination Round 1 match 2 takes the Opening Round 1 and 4
|
|
||||||
// losers, so a team from Opening Round 3 can never appear there. The game must
|
|
||||||
// be simulated instead of desynchronising the rest of the bracket.
|
|
||||||
const matches = completeMatch(
|
|
||||||
seededBracket(), "Elimination Round 1", 2, "us-5", "us-6"
|
|
||||||
);
|
|
||||||
setupMockDb(defaultParticipants(), favouredEvRows, matches);
|
|
||||||
const results = await new LLWSSimulator(5_000).simulate("season-1");
|
|
||||||
|
|
||||||
expect(results).toHaveLength(20);
|
|
||||||
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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"]));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
it("only derives inputs from declared optional inputs", () => {
|
||||||
for (const simulatorType of SIMULATOR_TYPES) {
|
for (const simulatorType of SIMULATOR_TYPES) {
|
||||||
const profile = SIMULATOR_MANIFEST[simulatorType];
|
const profile = SIMULATOR_MANIFEST[simulatorType];
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ import {
|
||||||
rawWinRateFromElo,
|
rawWinRateFromElo,
|
||||||
rdifWinProbability,
|
rdifWinProbability,
|
||||||
eloToRDif,
|
eloToRDif,
|
||||||
projectionForSeeding,
|
|
||||||
seedingWinRateFor,
|
|
||||||
sampleBinomial,
|
sampleBinomial,
|
||||||
simBo3,
|
simBo3,
|
||||||
simBo5,
|
simBo5,
|
||||||
|
|
@ -283,8 +281,8 @@ describe("sampleBinomial", () => {
|
||||||
|
|
||||||
// ─── Series simulators ────────────────────────────────────────────────────────
|
// ─── Series simulators ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const teamA = { id: "a", name: "Team A", 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, projectedWins: null };
|
const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0 };
|
||||||
const alwaysA = () => 1.0; // team A always wins each game
|
const alwaysA = () => 1.0; // team A always wins each game
|
||||||
const alwaysB = () => 0.0; // team B always wins each game
|
const alwaysB = () => 0.0; // team B always wins each game
|
||||||
const coinFlip = () => 0.5;
|
const coinFlip = () => 0.5;
|
||||||
|
|
@ -348,159 +346,9 @@ describe("eloToRDif", () => {
|
||||||
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
|
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lands on the same run-differential scale as the hardcoded TEAMS_DATA rdif", () => {
|
it("round-trips through winRateFromRDif: winRate(eloToRDif(elo)) ≈ eloWinProb(elo, 1500)", () => {
|
||||||
// 95 projected wins out of 162 → Elo ≈ 1561. On the TEAMS_DATA scale that is a
|
const elo = 1620;
|
||||||
// ~+140 run differential, right alongside the Dodgers' hardcoded +137 — not the
|
const expectedWinRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
|
||||||
// ~+686 the old RDIF_DIVISOR scaling produced.
|
expect(winRateFromRDif(eloToRDif(elo))).toBeCloseTo(expectedWinRate, 4);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,6 @@ import {
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||||
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
||||||
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
||||||
import { recalculateStandings } from "~/models/scoring-calculator";
|
|
||||||
import { database } from "~/database/context";
|
|
||||||
import { getSimulator } from "~/services/simulations/registry";
|
import { getSimulator } from "~/services/simulations/registry";
|
||||||
import { normalizeSimulationResultColumns } from "~/services/simulations/simulation-probabilities";
|
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" }]);
|
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 () => {
|
it("throws when the sports season is not found", async () => {
|
||||||
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);
|
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,48 +3,29 @@
|
||||||
*
|
*
|
||||||
* Monte Carlo simulation of the AFL regular season and finals for 2026.
|
* 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:
|
* Algorithm:
|
||||||
* 1. Load all participants for the sports season from DB
|
* 1. Load all participants for the sports season from DB
|
||||||
* 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
|
* 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
|
||||||
* Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
|
* Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
|
||||||
* 3. Load current regular season standings (wins, gamesPlayed) — if available
|
* 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
|
* 4. For each simulation:
|
||||||
* 5. For each simulation:
|
* a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed)
|
||||||
* a. Pre-bracket mode only: for each team, simulate remaining regular season games
|
* using Elo win probability vs. an average opponent (Elo 1500)
|
||||||
* (TOTAL_GAMES - gamesPlayed) using Elo win probability vs. an average opponent
|
* → projectedPoints = currentWins*4 + simulatedRemainingWins*4
|
||||||
* (Elo 1500) → projectedPoints = currentWins*4 + simulatedRemainingWins*4
|
* b. Sort all 18 teams by projected points desc + random tiebreaker → final ladder
|
||||||
* b. Pre-bracket mode only: sort all 18 teams by projected points desc + random
|
* → Top 10 advance to the AFL Finals Series
|
||||||
* tiebreaker → final ladder → top 10 advance to the AFL Finals Series.
|
* c. Simulate AFL Finals Series (AFL_10 bracket):
|
||||||
* 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:
|
|
||||||
*
|
*
|
||||||
* Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts)
|
* Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts)
|
||||||
* Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye)
|
* Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye)
|
||||||
* losers → Semi-Finals (2nd chance)
|
* losers → Semi-Finals (2nd chance)
|
||||||
* Elimination Finals: #5 vs lower WC winner, → losers exit (7th/8th)
|
* Elimination Finals: #5 vs WC2w, #6 vs WC1w → losers exit (7th/8th)
|
||||||
* #6 vs higher WC winner
|
* Semi-Finals: QF1L vs EF2w, QF2L vs EF1w → losers exit (5th/6th)
|
||||||
* Semi-Finals: QF1L vs EF1w, QF2L vs EF2w → losers exit (5th/6th)
|
|
||||||
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
||||||
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
||||||
*
|
*
|
||||||
* 6. Track placement counts per scoring tier
|
* 5. Track placement counts per scoring tier
|
||||||
* 7. Convert counts to probability distributions
|
* 6. Convert counts to probability distributions
|
||||||
*
|
*
|
||||||
* Win probability (Elo, PARITY_FACTOR = 450):
|
* Win probability (Elo, PARITY_FACTOR = 450):
|
||||||
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 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)
|
* probFifth/Sixth = Semi-Finals losers (2 per sim — split evenly)
|
||||||
* probSeventh/Eighth = Elimination 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)
|
* 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
|
* 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
|
* 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 { database } from "~/database/context";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
|
|
@ -94,9 +75,6 @@ import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
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.
|
* Elo parity factor for AFL single-game win probability.
|
||||||
* 450 reflects moderate variance — lower than NHL (1000) to account for
|
* 450 reflects moderate variance — lower than NHL (1000) to account for
|
||||||
|
|
@ -214,232 +192,6 @@ function simulateProjectedWins(entry: TeamEntry): number {
|
||||||
return entry.currentWins + extra;
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class AFLSimulator implements 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) ─────────────────────────
|
// ─── 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.
|
* 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);
|
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
|
// 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
|
// 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 sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
const efLoserCounts = 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++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
// With a real bracket the draw is fixed and its played games are replayed from their
|
const finalists = buildFinalsList();
|
||||||
// recorded result; without one the ladder is re-projected every iteration.
|
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists);
|
||||||
const finalists = bracket ? bracket.seeds : buildFinalsList();
|
|
||||||
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists, play);
|
|
||||||
|
|
||||||
championCounts.set(gfWinner.id, (championCounts.get(gfWinner.id) ?? 0) + 1);
|
championCounts.set(gfWinner.id, (championCounts.get(gfWinner.id) ?? 0) + 1);
|
||||||
finalistCounts.set(gfLoser.id, (finalistCounts.get(gfLoser.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).
|
// 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:
|
// Exact denominators guarantee column sums of 1.0 by construction:
|
||||||
// probFirst/Second → / NUM_SIMULATIONS (1 per sim)
|
// 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
|
// 6. Per-position normalization — belt-and-suspenders guard against floating-point
|
||||||
// division residuals. Columns are already near-exactly 1.0 after step 7.
|
// division residuals. Columns are already near-exactly 1.0 after step 5.
|
||||||
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
|
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
|
||||||
"probFirst", "probSecond", "probThird", "probFourth",
|
"probFirst", "probSecond", "probThird", "probFourth",
|
||||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||||
|
|
|
||||||
|
|
@ -9,48 +9,28 @@
|
||||||
* pool play. This mirrors the llws_20 bracket template so simulated placements line
|
* pool play. This mirrors the llws_20 bracket template so simulated placements line
|
||||||
* up with the bracket admins actually score.
|
* up with the bracket admins actually score.
|
||||||
*
|
*
|
||||||
* Two modes:
|
|
||||||
* 1. Pre-bracket mode: no llws_20 bracket exists yet (or it has no participants
|
|
||||||
* seeded). Each side is shuffled into the 10 bracket slots every iteration, so
|
|
||||||
* the draw is modelled as random.
|
|
||||||
* 2. Bracket-aware mode: a seeded llws_20 bracket exists. Teams sit in their real
|
|
||||||
* slots and completed match results are honored rather than re-simulated, so a
|
|
||||||
* team that has already lost carries that loss into every iteration.
|
|
||||||
*
|
|
||||||
* Algorithm:
|
* Algorithm:
|
||||||
* 1. Load all 20 participants for the sports season from DB
|
* 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
|
* (must be exactly 10 US + 10 International, identified by externalId)
|
||||||
* whatever results have been recorded so far
|
* 2. Load championship futures odds from participantExpectedValues.sourceOdds
|
||||||
* 3. Load championship futures odds from participantExpectedValues.sourceOdds
|
|
||||||
* (entered via Admin → Futures Odds; American format)
|
* (entered via Admin → Futures Odds; American format)
|
||||||
* 4. Convert those futures to Elo via the shared probability engine, then drive
|
* 3. Convert odds to normalized championship probabilities (vig removed).
|
||||||
* each game with the Elo win probability (see "Why Elo" below)
|
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50.
|
||||||
* 5. Per simulation:
|
* 4. Per simulation:
|
||||||
* a. Place each side's 10 teams into the bracket slots (real draw when known,
|
* a. Shuffle each side's 10 teams into the 10 bracket slots (8 opening-round
|
||||||
* otherwise shuffled)
|
* teams + 2 byes). The draw is modelled as random — a specific known draw
|
||||||
* b. Simulate the 10-team double-elimination bracket for each side, replaying
|
* is not yet expressible in participant config.
|
||||||
* completed games from their recorded result (see simulateSideBracket)
|
* b. Simulate the 10-team double-elimination bracket for each side
|
||||||
|
* (see simulateSideBracket for the exact game-by-game structure)
|
||||||
* c. Consolation game: US side loser vs Intl side loser → 3rd / 4th
|
* c. Consolation game: US side loser vs Intl side loser → 3rd / 4th
|
||||||
* d. World Championship: US champion vs Intl champion → 1st / 2nd
|
* d. World Championship: US champion vs Intl champion → 1st / 2nd
|
||||||
* 6. Track placement counts across all simulations
|
* 5. Track placement counts across all simulations.
|
||||||
* 7. Convert counts to probability distributions
|
* 6. Convert counts to probability distributions.
|
||||||
*
|
|
||||||
* Why Elo rather than raw futures:
|
|
||||||
* A championship future already bakes in the ~6 wins needed to lift the trophy, so
|
|
||||||
* using it directly as a single-game strength (p1 / (p1 + p2)) makes every
|
|
||||||
* individual game as lopsided as the whole tournament and compounds the favorite's
|
|
||||||
* edge over and over. buildLLWSElos undoes that compression first (the empirically
|
|
||||||
* calibrated cube-root step in decompressProbability) before mapping to an Elo
|
|
||||||
* scale, and LLWS_PARITY_FACTOR then widens the Elo curve to reflect how much
|
|
||||||
* single-game variance there is in six-inning Little League baseball. Unlike the
|
|
||||||
* shared convertFuturesToElo helper, the mapping preserves how spread out the board
|
|
||||||
* actually is — see buildLLWSElos for why that matters.
|
|
||||||
*
|
*
|
||||||
* Side assignment (externalId): "US" or "Intl". The legacy pool suffixes
|
* 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
|
* ("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
|
* 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
|
* no longer exist, so the suffix has no effect.
|
||||||
* bracket's own slots decide the sides and externalId is not consulted.
|
|
||||||
*
|
*
|
||||||
* Placement tiers → SimulationProbabilities mapping (matches llws_20's scoring):
|
* Placement tiers → SimulationProbabilities mapping (matches llws_20's scoring):
|
||||||
* probFirst = World Championship winner (1 per sim)
|
* probFirst = World Championship winner (1 per sim)
|
||||||
|
|
@ -65,21 +45,15 @@
|
||||||
* 1. Create a Sport with simulatorType = "llws_bracket"
|
* 1. Create a Sport with simulatorType = "llws_bracket"
|
||||||
* 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International)
|
* 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
|
* 3. Set externalId on each participant via Admin → Manage Participants to "US" or
|
||||||
* "Intl" (optional — names starting with "US " infer US, all others infer Intl).
|
* "Intl" (optional — names starting with "US " infer US, all others infer Intl)
|
||||||
* Once the bracket is generated and seeded this is no longer used.
|
|
||||||
* 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds)
|
* 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds)
|
||||||
* 5. Run simulation via Admin → Simulate
|
* 5. Run simulation via Admin → Simulate
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import {
|
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
|
||||||
convertAmericanOddsToProbability,
|
|
||||||
decompressProbability,
|
|
||||||
eloWinProbabilityWithParity,
|
|
||||||
} from "~/services/probability-engine";
|
|
||||||
import { llwsMatchNumber } from "~/lib/bracket-templates";
|
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { positiveConfigNumber } from "./config-access";
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
|
|
@ -88,61 +62,16 @@ import { positiveConfigNumber } from "./config-access";
|
||||||
const NUM_SIMULATIONS = 50_000;
|
const NUM_SIMULATIONS = 50_000;
|
||||||
const US_TEAM_COUNT = 10;
|
const US_TEAM_COUNT = 10;
|
||||||
const INTL_TEAM_COUNT = 10;
|
const INTL_TEAM_COUNT = 10;
|
||||||
const DEFAULT_ELO = 1500;
|
|
||||||
const LLWS_TEMPLATE_ID = "llws_20";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Elo scaling for a single LLWS game.
|
|
||||||
*
|
|
||||||
* Higher than the 400-point standard because a six-inning Little League game between
|
|
||||||
* 12-year-olds is far closer to a coin flip than a pro game: one pitcher, one big
|
|
||||||
* inning, and the mercy rule all compress the gap.
|
|
||||||
*
|
|
||||||
* Calibrated by sweeping this value until a randomized-draw simulation reproduces the
|
|
||||||
* championship futures it was fed, across boards of different shape (see
|
|
||||||
* LLWS_ELO_SPREAD for why the shape matters). Total RMSE over a wide board, a
|
|
||||||
* top-heavy board, and a nearly flat one:
|
|
||||||
* parity 450 → 0.028
|
|
||||||
* parity 550 → 0.016 ← chosen
|
|
||||||
* parity 750 → 0.040
|
|
||||||
* parity 1000 → 0.061
|
|
||||||
* Overridable per season via the `parityFactor` simulator config.
|
|
||||||
*/
|
|
||||||
const LLWS_PARITY_FACTOR = 550;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Elo points per natural-log unit of relative team strength.
|
|
||||||
*
|
|
||||||
* Only the ratio LLWS_ELO_SPREAD / parityFactor affects the simulation, so this fixes
|
|
||||||
* the readable scale of the ratings and LLWS_PARITY_FACTOR does the calibrating. 300
|
|
||||||
* puts a typical 20-team board in the familiar ~1350–1700 range.
|
|
||||||
*/
|
|
||||||
const LLWS_ELO_SPREAD = 300;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Power transform undoing the compounding baked into a championship future.
|
|
||||||
* Matches DEFAULT_CALIBRATION.exponent in the probability engine.
|
|
||||||
*/
|
|
||||||
const LLWS_DECOMPRESSION_EXPONENT = 0.33;
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Side = "US" | "Intl";
|
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 {
|
interface Team {
|
||||||
participantId: string;
|
participantId: string;
|
||||||
side: Side;
|
side: Side;
|
||||||
/** Single-game strength on an Elo scale, decompressed from championship futures. */
|
/** Normalized championship win probability (0–1, vig removed). */
|
||||||
elo: number;
|
oddsProb: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlacementCounts {
|
interface PlacementCounts {
|
||||||
|
|
@ -156,25 +85,6 @@ interface PlacementCounts {
|
||||||
elimRound4Loser: number;
|
elimRound4Loser: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Plays one bracket game. `round`/`localMatch` identify the game within its side so a
|
|
||||||
* completed result can be looked up; `t1`/`t2` are the teams the simulation has
|
|
||||||
* routed into it.
|
|
||||||
*/
|
|
||||||
type PlayGame = (
|
|
||||||
round: string,
|
|
||||||
localMatch: number,
|
|
||||||
t1: Team,
|
|
||||||
t2: Team
|
|
||||||
) => { winner: Team; loser: Team };
|
|
||||||
|
|
||||||
interface LoadedBracket {
|
|
||||||
/** Each side's 10 teams in bracket slot order (8 opening-round, then 2 byes). */
|
|
||||||
slots: Record<Side, Team[]>;
|
|
||||||
/** All bracket matches, keyed by `${round}#${globalMatchNumber}`. */
|
|
||||||
matches: Map<string, BracketMatch>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function zeroCounts(): PlacementCounts {
|
function zeroCounts(): PlacementCounts {
|
||||||
|
|
@ -184,12 +94,16 @@ function zeroCounts(): PlacementCounts {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchKey(round: string, matchNumber: number): string {
|
function simGame(t1: Team, t2: Team): { winner: Team; loser: Team } {
|
||||||
return `${round}#${matchNumber}`;
|
// 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.
|
||||||
function simGame(t1: Team, t2: Team, parityFactor: number): { winner: Team; loser: Team } {
|
let p1Win: number;
|
||||||
const p1Win = eloWinProbabilityWithParity(t1.elo, t2.elo, parityFactor);
|
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 };
|
return Math.random() < p1Win ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,76 +118,6 @@ function shuffle<T>(arr: T[]): T[] {
|
||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The recorded loser of a completed match. loserId is written by the scoring flow,
|
|
||||||
* but fall back to "whichever slot isn't the winner" for older rows.
|
|
||||||
*/
|
|
||||||
function completedLoser(match: BracketMatch): string | null {
|
|
||||||
if (match.loserId) return match.loserId;
|
|
||||||
if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id;
|
|
||||||
if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the game-playing function for one side.
|
|
||||||
*
|
|
||||||
* When the bracket has a completed result for a game AND that result is between the
|
|
||||||
* two teams the simulation routed into it, the recorded winner is used verbatim —
|
|
||||||
* that is what makes an already-played loss stick across all iterations. Anything
|
|
||||||
* else is simulated. The pair check keeps a corrupt or out-of-order row from
|
|
||||||
* desynchronising the rest of the bracket.
|
|
||||||
*/
|
|
||||||
export function makePlayGame(
|
|
||||||
sideIndex: 0 | 1,
|
|
||||||
bracket: LoadedBracket | null,
|
|
||||||
parityFactor: number
|
|
||||||
): PlayGame {
|
|
||||||
if (!bracket) {
|
|
||||||
return (_round, _localMatch, t1, t2) => simGame(t1, t2, parityFactor);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (round, localMatch, t1, t2) => {
|
|
||||||
const match = bracket.matches.get(
|
|
||||||
matchKey(round, llwsMatchNumber(round, sideIndex, localMatch))
|
|
||||||
);
|
|
||||||
if (match?.isComplete && match.winnerId) {
|
|
||||||
const loserId = completedLoser(match);
|
|
||||||
const arrived = [t1.participantId, t2.participantId];
|
|
||||||
if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) {
|
|
||||||
return match.winnerId === t1.participantId
|
|
||||||
? { winner: t1, loser: t2 }
|
|
||||||
: { winner: t2, loser: t1 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return simGame(t1, t2, parityFactor);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play one of the two cross-side games (Consolation, World Championship). Both are a
|
|
||||||
* single shared match numbered 1, so they don't go through the side-local mapping.
|
|
||||||
*/
|
|
||||||
export function playCrossoverGame(
|
|
||||||
round: string,
|
|
||||||
bracket: LoadedBracket | null,
|
|
||||||
parityFactor: number,
|
|
||||||
t1: Team,
|
|
||||||
t2: Team
|
|
||||||
): { winner: Team; loser: Team } {
|
|
||||||
const match = bracket?.matches.get(matchKey(round, 1));
|
|
||||||
if (match?.isComplete && match.winnerId) {
|
|
||||||
const loserId = completedLoser(match);
|
|
||||||
const arrived = [t1.participantId, t2.participantId];
|
|
||||||
if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) {
|
|
||||||
return match.winnerId === t1.participantId
|
|
||||||
? { winner: t1, loser: t2 }
|
|
||||||
: { winner: t2, loser: t1 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return simGame(t1, t2, parityFactor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulate one side's 10-team double-elimination bracket.
|
* Simulate one side's 10-team double-elimination bracket.
|
||||||
*
|
*
|
||||||
|
|
@ -281,7 +125,7 @@ export function playCrossoverGame(
|
||||||
* layout: slots[0..7] are the four opening-round games (two teams each) and
|
* 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.
|
* slots[8], slots[9] are the two bye teams entering Winners Round 2.
|
||||||
*
|
*
|
||||||
* Structure (side-local, mirroring LLWS_ADVANCEMENT in lib/llws-bracket):
|
* Structure (side-local, mirroring LLWS_ADVANCEMENT in models/playoff-match):
|
||||||
* Winners bracket
|
* Winners bracket
|
||||||
* OP1 s0 v s1 OP2 s2 v s3 OP3 s4 v s5 OP4 s6 v s7
|
* 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
|
* WR2-1 s8 v OP1w WR2-2 s9 v OP2w
|
||||||
|
|
@ -299,51 +143,47 @@ export function playCrossoverGame(
|
||||||
* Elimination Final. There is no "if necessary" game, so the side championship is
|
* Elimination Final. There is no "if necessary" game, so the side championship is
|
||||||
* decided in one game.
|
* 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
|
* Returns { sideChampion, sideLoser }; the two scoring elimination losers are
|
||||||
* bumped into the counts directly.
|
* bumped into the counts directly.
|
||||||
*/
|
*/
|
||||||
function simulateSideBracket(
|
function simulateSideBracket(
|
||||||
slots: Team[],
|
slots: Team[],
|
||||||
bump: (id: string, key: keyof PlacementCounts) => void,
|
bump: (id: string, key: keyof PlacementCounts) => void
|
||||||
play: PlayGame
|
|
||||||
): { sideChampion: Team; sideLoser: Team } {
|
): { sideChampion: Team; sideLoser: Team } {
|
||||||
// ── Winners bracket ────────────────────────────────────────────────────────
|
// ── Winners bracket ────────────────────────────────────────────────────────
|
||||||
const op1 = play("Opening Round", 1, slots[0], slots[1]);
|
const op1 = simGame(slots[0], slots[1]);
|
||||||
const op2 = play("Opening Round", 2, slots[2], slots[3]);
|
const op2 = simGame(slots[2], slots[3]);
|
||||||
const op3 = play("Opening Round", 3, slots[4], slots[5]);
|
const op3 = simGame(slots[4], slots[5]);
|
||||||
const op4 = play("Opening Round", 4, slots[6], slots[7]);
|
const op4 = simGame(slots[6], slots[7]);
|
||||||
|
|
||||||
const wr21 = play("Winners Round 2", 1, slots[8], op1.winner);
|
const wr21 = simGame(slots[8], op1.winner);
|
||||||
const wr22 = play("Winners Round 2", 2, slots[9], op2.winner);
|
const wr22 = simGame(slots[9], op2.winner);
|
||||||
|
|
||||||
const wsf1 = play("Winners Semifinals", 1, op3.winner, wr21.winner);
|
const wsf1 = simGame(op3.winner, wr21.winner);
|
||||||
const wsf2 = play("Winners Semifinals", 2, wr22.winner, op4.winner);
|
const wsf2 = simGame(wr22.winner, op4.winner);
|
||||||
|
|
||||||
const wf = play("Winners Final", 1, wsf1.winner, wsf2.winner);
|
const wf = simGame(wsf1.winner, wsf2.winner);
|
||||||
|
|
||||||
// ── Elimination bracket ────────────────────────────────────────────────────
|
// ── Elimination bracket ────────────────────────────────────────────────────
|
||||||
const er11 = play("Elimination Round 1", 1, op2.loser, op3.loser);
|
const er11 = simGame(op2.loser, op3.loser);
|
||||||
const er12 = play("Elimination Round 1", 2, op1.loser, op4.loser);
|
const er12 = simGame(op1.loser, op4.loser);
|
||||||
|
|
||||||
const er21 = play("Elimination Round 2", 1, wr21.loser, er11.winner);
|
const er21 = simGame(wr21.loser, er11.winner);
|
||||||
const er22 = play("Elimination Round 2", 2, wr22.loser, er12.winner);
|
const er22 = simGame(wr22.loser, er12.winner);
|
||||||
|
|
||||||
// Cross-over: each semifinal loser meets the winner from the opposite half.
|
// Cross-over: each semifinal loser meets the winner from the opposite half.
|
||||||
const er31 = play("Elimination Round 3", 1, wsf1.loser, er22.winner);
|
const er31 = simGame(wsf1.loser, er22.winner);
|
||||||
const er32 = play("Elimination Round 3", 2, wsf2.loser, er21.winner);
|
const er32 = simGame(wsf2.loser, er21.winner);
|
||||||
|
|
||||||
const er4 = play("Elimination Round 4", 1, er32.winner, er31.winner);
|
const er4 = simGame(er32.winner, er31.winner);
|
||||||
bump(er4.loser.participantId, "elimRound4Loser"); // 7th–8th tier
|
bump(er4.loser.participantId, "elimRound4Loser"); // 7th–8th tier
|
||||||
|
|
||||||
// The Winners Final loser gets its second chance here.
|
// The Winners Final loser gets its second chance here.
|
||||||
const ef = play("Elimination Final", 1, wf.loser, er4.winner);
|
const ef = simGame(wf.loser, er4.winner);
|
||||||
bump(ef.loser.participantId, "elimFinalLoser"); // 5th–6th tier
|
bump(ef.loser.participantId, "elimFinalLoser"); // 5th–6th tier
|
||||||
|
|
||||||
// ── Side championship ──────────────────────────────────────────────────────
|
// ── Side championship ──────────────────────────────────────────────────────
|
||||||
const sideChampionship = play("Bracket Championship", 1, wf.winner, ef.winner);
|
const sideChampionship = simGame(wf.winner, ef.winner);
|
||||||
|
|
||||||
return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser };
|
return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser };
|
||||||
}
|
}
|
||||||
|
|
@ -369,157 +209,13 @@ function parseExternalId(raw: string | null): { side: Side } | null {
|
||||||
* Infer an externalId from a participant name when none is stored.
|
* Infer an externalId from a participant name when none is stored.
|
||||||
* Teams whose name is exactly "US" or starts with "US " (case-insensitive)
|
* Teams whose name is exactly "US" or starts with "US " (case-insensitive)
|
||||||
* are assigned to the US side; all others are assigned to Intl.
|
* 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 {
|
function inferExternalIdFromName(name: string): string {
|
||||||
const upper = name.trim().toUpperCase();
|
const upper = name.trim().toUpperCase();
|
||||||
return upper === "US" || upper.startsWith("US ") ? "US" : "Intl";
|
return upper === "US" || upper.startsWith("US ") ? "US" : "Intl";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Elo construction ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map participants to single-game Elo ratings from their championship futures.
|
|
||||||
*
|
|
||||||
* Deliberately NOT convertFuturesToElo. That helper finishes by rescaling the field
|
|
||||||
* onto a fixed 1250–1750 span (mapToElo), which throws away how spread out the board
|
|
||||||
* actually is: a board whose favorite is priced at 22% and one whose favorite is
|
|
||||||
* priced at 6% both come out 500 Elo wide, so the tight board's field gets pulled
|
|
||||||
* apart into contenders and no-hopers that the market never implied. On such a board
|
|
||||||
* that inflated the favorite from 6% to 13%.
|
|
||||||
*
|
|
||||||
* Instead the decompressed strengths are mapped by their log-ratio to the field's
|
|
||||||
* geometric mean, which preserves dispersion: a tight board yields a narrow Elo span
|
|
||||||
* and a top-heavy one a wide span, both centred on DEFAULT_ELO.
|
|
||||||
*
|
|
||||||
* Returns the ratings alongside the rating to use for a team with no odds entered —
|
|
||||||
* the median of the priced field, so leaving odds blank neither promotes nor buries a
|
|
||||||
* team. (DEFAULT_ELO is the centre of the scale, but futures fields are skewed, so on
|
|
||||||
* a typical board it would rank a team around 6th of 20.)
|
|
||||||
*/
|
|
||||||
export function buildLLWSElos(
|
|
||||||
evRows: Array<{ participantId: string; sourceOdds: number | null }>
|
|
||||||
): { elos: Map<string, number>; unpricedElo: number } {
|
|
||||||
const priced = evRows.filter((row) => row.sourceOdds !== null);
|
|
||||||
|
|
||||||
// A single priced team carries no information about the rest of the field, so
|
|
||||||
// there is nothing to normalise against — treat the season as unpriced.
|
|
||||||
if (priced.length < 2) return { elos: new Map(), unpricedElo: DEFAULT_ELO };
|
|
||||||
|
|
||||||
const rawProbs = priced.map((row) => convertAmericanOddsToProbability(row.sourceOdds ?? 0));
|
|
||||||
const rawSum = rawProbs.reduce((a, b) => a + b, 0);
|
|
||||||
if (rawSum <= 0) return { elos: new Map(), unpricedElo: DEFAULT_ELO };
|
|
||||||
|
|
||||||
// Vig-removed championship probability → single-game strength.
|
|
||||||
const logStrengths = rawProbs.map((prob) =>
|
|
||||||
Math.log(
|
|
||||||
Math.max(decompressProbability(prob / rawSum, LLWS_DECOMPRESSION_EXPONENT), Number.MIN_VALUE)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const meanLog = logStrengths.reduce((a, b) => a + b, 0) / logStrengths.length;
|
|
||||||
|
|
||||||
const elos = new Map<string, number>(
|
|
||||||
priced.map((row, i) => [
|
|
||||||
row.participantId,
|
|
||||||
DEFAULT_ELO + LLWS_ELO_SPREAD * (logStrengths[i] - meanLog),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
return { elos, unpricedElo: median([...elos.values()]) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function median(values: number[]): number {
|
|
||||||
if (values.length === 0) return DEFAULT_ELO;
|
|
||||||
const sorted = values.toSorted((a, b) => a - b);
|
|
||||||
const mid = Math.floor(sorted.length / 2);
|
|
||||||
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Bracket loading ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the seeded llws_20 bracket for this season, if there is one.
|
|
||||||
*
|
|
||||||
* Returns null only when the bracket carries no draw at all — no matches, or a
|
|
||||||
* freshly generated bracket with every slot still empty — in which case the caller
|
|
||||||
* falls back to a randomized draw.
|
|
||||||
*
|
|
||||||
* A *partially* seeded bracket is an error rather than a fallback. Silently falling
|
|
||||||
* back there would throw away the real draw and every recorded result along with it,
|
|
||||||
* putting eliminated teams back in contention; and it is reachable in practice,
|
|
||||||
* because playoff_matches.participant1Id/participant2Id are ON DELETE SET NULL, so
|
|
||||||
* removing and re-adding a single participant mid-tournament empties a slot.
|
|
||||||
* Likewise, a bracket seeded with unknown or duplicated participants fails loudly.
|
|
||||||
*/
|
|
||||||
export function readBracketSlots(
|
|
||||||
matches: BracketMatch[],
|
|
||||||
teamsById: Map<string, Team>
|
|
||||||
): LoadedBracket | null {
|
|
||||||
if (matches.length === 0) return null;
|
|
||||||
|
|
||||||
const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m]));
|
|
||||||
|
|
||||||
// Collect both sides' draws before deciding, so "nothing seeded" is judged over the
|
|
||||||
// whole bracket rather than one side at a time.
|
|
||||||
const draw: Record<Side, (string | null)[]> = { US: [], Intl: [] };
|
|
||||||
|
|
||||||
for (const side of ["US", "Intl"] as const) {
|
|
||||||
const sideIndex = SIDE_INDEX[side];
|
|
||||||
|
|
||||||
for (let local = 1; local <= 4; local++) {
|
|
||||||
const match = byKey.get(
|
|
||||||
matchKey("Opening Round", llwsMatchNumber("Opening Round", sideIndex, local))
|
|
||||||
);
|
|
||||||
draw[side].push(match?.participant1Id ?? null, match?.participant2Id ?? null);
|
|
||||||
}
|
|
||||||
for (let local = 1; local <= 2; local++) {
|
|
||||||
const match = byKey.get(
|
|
||||||
matchKey("Winners Round 2", llwsMatchNumber("Winners Round 2", sideIndex, local))
|
|
||||||
);
|
|
||||||
draw[side].push(match?.participant1Id ?? null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allSlots = [...draw.US, ...draw.Intl];
|
|
||||||
const seededCount = allSlots.filter((id) => id !== null).length;
|
|
||||||
|
|
||||||
// Generated but not yet filled in — no draw to honor.
|
|
||||||
if (seededCount === 0) return null;
|
|
||||||
|
|
||||||
if (seededCount < allSlots.length) {
|
|
||||||
throw new Error(
|
|
||||||
`LLWS bracket is only partially seeded (${seededCount} of ${allSlots.length} slots ` +
|
|
||||||
`filled). Re-seed the bracket in Admin → Bracket before simulating; simulating ` +
|
|
||||||
`around the gap would discard the draw and every recorded result.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const slots: Record<Side, Team[]> = { US: [], Intl: [] };
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
for (const side of ["US", "Intl"] as const) {
|
|
||||||
for (const id of draw[side]) {
|
|
||||||
const participantId = id as string;
|
|
||||||
if (seen.has(participantId)) {
|
|
||||||
throw new Error(
|
|
||||||
`LLWS bracket seeds participant ${participantId} into more than one slot.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
seen.add(participantId);
|
|
||||||
|
|
||||||
const team = teamsById.get(participantId);
|
|
||||||
if (!team) {
|
|
||||||
throw new Error(
|
|
||||||
`LLWS bracket references participant ${participantId}, which is not in this sports season.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// The bracket is authoritative about which side a team is on.
|
|
||||||
slots[side].push({ ...team, side });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { slots, matches: byKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class LLWSSimulator implements Simulator {
|
export class LLWSSimulator implements Simulator {
|
||||||
|
|
@ -527,7 +223,6 @@ export class LLWSSimulator implements Simulator {
|
||||||
|
|
||||||
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
|
||||||
const parityFactor = positiveConfigNumber(config, "parityFactor", LLWS_PARITY_FACTOR);
|
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants.
|
// 1. Load all participants.
|
||||||
|
|
@ -552,80 +247,52 @@ export class LLWSSimulator implements Simulator {
|
||||||
.from(schema.seasonParticipantExpectedValues)
|
.from(schema.seasonParticipantExpectedValues)
|
||||||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||||||
|
|
||||||
// 3. Decompress the futures into single-game Elo ratings.
|
const rawOddsMap = new Map<string, number>();
|
||||||
const { elos, unpricedElo } = buildLLWSElos(evRows);
|
for (const row of evRows) {
|
||||||
|
if (row.sourceOdds !== null) {
|
||||||
|
rawOddsMap.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Normalize odds (remove vig) to get championship probability per team.
|
||||||
|
const normalizedOddsMap = new Map<string, number>();
|
||||||
|
if (rawOddsMap.size > 0) {
|
||||||
|
const rawSum = [...rawOddsMap.values()].reduce((a, b) => a + b, 0);
|
||||||
|
for (const [id, prob] of rawOddsMap) {
|
||||||
|
normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Parse externalId for each participant to determine which side they're on.
|
// 4. Parse externalId for each participant to determine which side they're on.
|
||||||
// A seeded bracket overrides this below, but the field still has to be a legal
|
|
||||||
// 10/10 split before we know whether a bracket exists.
|
|
||||||
const teams: Team[] = [];
|
const teams: Team[] = [];
|
||||||
const unparseableSides: Array<{ id: string; externalId: string | null }> = [];
|
|
||||||
for (const p of participants) {
|
for (const p of participants) {
|
||||||
const raw = p.externalId ?? inferExternalIdFromName(p.name);
|
const raw = p.externalId ?? inferExternalIdFromName(p.name);
|
||||||
const parsed = parseExternalId(raw);
|
const parsed = parseExternalId(raw);
|
||||||
if (!parsed) unparseableSides.push({ id: p.id, externalId: p.externalId });
|
if (!parsed) {
|
||||||
teams.push({
|
|
||||||
// Provisional: a seeded bracket overwrites this below.
|
|
||||||
participantId: p.id,
|
|
||||||
side: parsed?.side ?? "Intl",
|
|
||||||
elo: elos.get(p.id) ?? unpricedElo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamsById = new Map(teams.map((t) => [t.participantId, t]));
|
|
||||||
|
|
||||||
// 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(
|
throw new Error(
|
||||||
`Participant ${firstBad.id} has invalid externalId "${firstBad.externalId}". ` +
|
`Participant ${p.id} has invalid externalId "${p.externalId}". ` +
|
||||||
`Expected: "US" or "Intl".`
|
`Expected: "US" or "Intl".`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
teams.push({
|
||||||
const usTeams = teams.filter((t) => t.side === "US");
|
participantId: p.id,
|
||||||
const intlTeams = teams.filter((t) => t.side === "Intl");
|
side: parsed.side,
|
||||||
|
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
|
||||||
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");
|
// Validate team counts per side.
|
||||||
const intlPool = bracket ? bracket.slots.Intl : teams.filter((t) => t.side === "Intl");
|
const usTeams = teams.filter((t) => t.side === "US");
|
||||||
|
const intlTeams = teams.filter((t) => t.side === "Intl");
|
||||||
|
|
||||||
const playUS = makePlayGame(SIDE_INDEX.US, bracket, parityFactor);
|
if (usTeams.length !== US_TEAM_COUNT) {
|
||||||
const playIntl = makePlayGame(SIDE_INDEX.Intl, bracket, parityFactor);
|
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}.`);
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Initialise placement count accumulators for all participants.
|
// 5. Initialise placement count accumulators for all participants.
|
||||||
const allIds = participants.map((p) => p.id);
|
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, zeroCounts()]));
|
||||||
const bump = (id: string, key: keyof PlacementCounts) => {
|
const bump = (id: string, key: keyof PlacementCounts) => {
|
||||||
|
|
@ -633,33 +300,27 @@ export class LLWSSimulator implements Simulator {
|
||||||
if (entry) entry[key]++;
|
if (entry) entry[key]++;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 7. Run Monte Carlo simulations.
|
// 6. Run Monte Carlo simulations.
|
||||||
for (let s = 0; s < numSimulations; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
// With a real bracket the draw is fixed; without one it is modelled as random.
|
// The draw is modelled as random: shuffle each side into the 10 bracket slots
|
||||||
const usSlots = bracket ? usPool : shuffle([...usPool]);
|
// (8 opening-round teams, then the 2 bye teams).
|
||||||
const intlSlots = bracket ? intlPool : shuffle([...intlPool]);
|
|
||||||
|
|
||||||
const { sideChampion: usChamp, sideLoser: usLose } =
|
const { sideChampion: usChamp, sideLoser: usLose } =
|
||||||
simulateSideBracket(usSlots, bump, playUS);
|
simulateSideBracket(shuffle([...usTeams]), bump);
|
||||||
const { sideChampion: intlChamp, sideLoser: intlLose } =
|
const { sideChampion: intlChamp, sideLoser: intlLose } =
|
||||||
simulateSideBracket(intlSlots, bump, playIntl);
|
simulateSideBracket(shuffle([...intlTeams]), bump);
|
||||||
|
|
||||||
// Consolation game: 3rd / 4th place.
|
// Consolation game: 3rd / 4th place.
|
||||||
const consolation = playCrossoverGame(
|
const consolation = simGame(usLose, intlLose);
|
||||||
"Consolation Third Place", bracket, parityFactor, usLose, intlLose
|
|
||||||
);
|
|
||||||
bump(consolation.winner.participantId, "thirdPlace");
|
bump(consolation.winner.participantId, "thirdPlace");
|
||||||
bump(consolation.loser.participantId, "fourthPlace");
|
bump(consolation.loser.participantId, "fourthPlace");
|
||||||
|
|
||||||
// World Championship: 1st / 2nd place.
|
// World Championship: 1st / 2nd place.
|
||||||
const ws = playCrossoverGame(
|
const ws = simGame(usChamp, intlChamp);
|
||||||
"World Championship", bracket, parityFactor, usChamp, intlChamp
|
|
||||||
);
|
|
||||||
bump(ws.winner.participantId, "champion");
|
bump(ws.winner.participantId, "champion");
|
||||||
bump(ws.loser.participantId, "finalist");
|
bump(ws.loser.participantId, "finalist");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Convert counts to probability distributions.
|
// 7. Convert counts to probability distributions.
|
||||||
// Each of the two 5–8 tiers takes exactly 2 teams per sim (one per side), and
|
// 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
|
// the teams within a tier are tied, so the tier probability is split across
|
||||||
// its two positions.
|
// its two positions.
|
||||||
|
|
|
||||||
|
|
@ -34,25 +34,6 @@ export interface SimulatorManifestProfile {
|
||||||
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
|
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
|
||||||
setupSections: SimulatorSetupSection[];
|
setupSections: SimulatorSetupSection[];
|
||||||
minParticipantInputs?: number;
|
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 = {
|
const BASE_CONFIG = {
|
||||||
|
|
@ -90,7 +71,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["sourceOdds"],
|
optionalInputs: ["sourceOdds"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
ncaam_bracket: {
|
ncaam_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
|
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"],
|
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
||||||
derivableInputs: { rating: ["sourceOdds"] },
|
derivableInputs: { rating: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
ncaaw_bracket: {
|
ncaaw_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
|
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"],
|
optionalInputs: ["sourceOdds", "seed", "region"],
|
||||||
derivableInputs: { rating: ["sourceOdds"] },
|
derivableInputs: { rating: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
nba_bracket: {
|
nba_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
|
||||||
|
|
@ -114,7 +92,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
nhl_bracket: {
|
nhl_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
|
||||||
|
|
@ -122,7 +99,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
nfl_bracket: {
|
nfl_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
|
||||||
|
|
@ -136,10 +112,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["projectedWins"],
|
optionalInputs: ["projectedWins"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins"] },
|
derivableInputs: { sourceElo: ["projectedWins"] },
|
||||||
// The bracket is optional — before one exists the ladder is projected from Elo — but once
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||||
// it is drawn the simulator seeds from it and honors completed results.
|
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
epl_standings: {
|
epl_standings: {
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
|
|
@ -162,7 +135,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["worldRanking", "seed"],
|
optionalInputs: ["worldRanking", "seed"],
|
||||||
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
|
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
tennis_qualifying_points: {
|
tennis_qualifying_points: {
|
||||||
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
|
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
|
||||||
|
|
@ -171,7 +143,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "surfaceElo", "events"],
|
setupSections: ["participants", "surfaceElo", "events"],
|
||||||
},
|
},
|
||||||
mlb_bracket: {
|
mlb_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, projectedWinsWeight: 1, inputPolicy: { oddsWeight: 0.3 } },
|
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, inputPolicy: { oddsWeight: 0.3 } },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||||
|
|
@ -190,21 +162,18 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
|
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
darts_bracket: {
|
darts_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
|
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
|
||||||
requiredInputs: ["sourceElo", "worldRanking"],
|
requiredInputs: ["sourceElo", "worldRanking"],
|
||||||
optionalInputs: ["seed"],
|
optionalInputs: ["seed"],
|
||||||
setupSections: ["participants", "eloRatings", "rankings"],
|
setupSections: ["participants", "eloRatings", "rankings"],
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
cs2_major_qualifying_points: {
|
cs2_major_qualifying_points: {
|
||||||
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
|
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["worldRanking", "metadata"],
|
optionalInputs: ["worldRanking", "metadata"],
|
||||||
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
ncaa_football_bracket: {
|
ncaa_football_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
|
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"],
|
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
|
||||||
},
|
},
|
||||||
llws_bracket: {
|
llws_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 550, usTeamCount: 10, internationalTeamCount: 10 },
|
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10 },
|
||||||
requiredInputs: ["sourceOdds"],
|
requiredInputs: ["sourceOdds"],
|
||||||
optionalInputs: ["metadata"],
|
optionalInputs: ["metadata"],
|
||||||
// The bracket is optional — without one the draw is randomized — but once it
|
setupSections: ["participants", "futuresOdds"],
|
||||||
// exists the simulator reads the real draw and honors completed results from it.
|
|
||||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
college_hockey_bracket: {
|
college_hockey_bracket: {
|
||||||
// College hockey blends odds into Elo internally (and also uses NPI rank,
|
// 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"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
|
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
brackt: {
|
brackt: {
|
||||||
defaultConfig: { iterations: 20_000 },
|
defaultConfig: { iterations: 20_000 },
|
||||||
|
|
@ -257,7 +222,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
|
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||||
bracketAware: true,
|
|
||||||
},
|
},
|
||||||
mls_bracket: {
|
mls_bracket: {
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,8 @@
|
||||||
* 1. Load all participants for the sports season from DB
|
* 1. Load all participants for the sports season from DB
|
||||||
* 2. Load current standings (wins, gamesPlayed) from regularSeasonStandings
|
* 2. Load current standings (wins, gamesPlayed) from regularSeasonStandings
|
||||||
* 3. Load sourceElo ratings from seasonParticipantExpectedValues
|
* 3. Load sourceElo ratings from seasonParticipantExpectedValues
|
||||||
* 4. Load raw projected win totals from seasonParticipantSimulatorInputs
|
* 4. Match participant names to hardcoded team data (RDif + league/division)
|
||||||
* 5. Match participant names to hardcoded team data (RDif + league/division)
|
* 5. For each simulation:
|
||||||
* 6. For each simulation:
|
|
||||||
* a. For each league (AL/NL), simulate remaining regular season games for
|
* a. For each league (AL/NL), simulate remaining regular season games for
|
||||||
* every team using Binomial sampling, giving final projected wins.
|
* every team using Binomial sampling, giving final projected wins.
|
||||||
* b. Division winner = best record in each division (3 per league).
|
* 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
|
* - Division Series (best-of-5): 1 vs lowest WC survivor, 2 vs other
|
||||||
* - League Championship Series (best-of-7)
|
* - League Championship Series (best-of-7)
|
||||||
* e. World Series (best-of-7): AL champ vs NL champ
|
* e. World Series (best-of-7): AL champ vs NL champ
|
||||||
* 7. Track placement counts per scoring tier
|
* 6. Track placement counts per scoring tier
|
||||||
* 8. Convert counts to probability distributions
|
* 7. Convert counts to probability distributions
|
||||||
*
|
*
|
||||||
* Win probability (log5 formula):
|
* Win probability (log5 formula):
|
||||||
* Step 1 — convert projected RDif to win rate for playoff matchups:
|
* 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)
|
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
|
||||||
*
|
*
|
||||||
* Regular season simulation (seeding):
|
* 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.
|
* 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
|
* Remaining games = TOTAL_SEASON_GAMES − gamesPlayed are drawn from a
|
||||||
* Binomial distribution. This makes playoff seeding respond to both current
|
* Binomial distribution. This makes playoff seeding respond to both current
|
||||||
* standings and user-entered projected wins.
|
* standings and user-entered projected wins.
|
||||||
*
|
*
|
||||||
* Input resolution:
|
* Futures blending:
|
||||||
* sourceElo is the single Elo produced by the shared input policy — already a
|
* If sourceOdds are stored in participantExpectedValues for this season,
|
||||||
* blend of any raw Elo / projections / futures odds, written by
|
* the per-game win probability for playoff series is blended:
|
||||||
* prepareSimulatorInputsForRun before the run. This simulator does not blend
|
* P(game) = RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb
|
||||||
* futures odds itself.
|
* RDIF_WEIGHT = 0.7, ODDS_WEIGHT = 0.3.
|
||||||
*
|
*
|
||||||
* Placement tiers → SimulationProbabilities mapping:
|
* Placement tiers → SimulationProbabilities mapping:
|
||||||
* probFirst = World Series champion (1 per sim)
|
* probFirst = World Series champion (1 per sim)
|
||||||
|
|
@ -82,10 +74,9 @@ import { database } from "~/database/context";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { configNumber, positiveConfigNumber } from "./config-access";
|
import { positiveConfigNumber } from "./config-access";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
import { getParticipantSimulatorInputs } from "~/models/simulator";
|
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -109,13 +100,6 @@ const RDIF_DIVISOR = 8000;
|
||||||
*/
|
*/
|
||||||
const SEEDING_RDIF_SCALE = 1620;
|
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) ────────────────────
|
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
|
||||||
//
|
//
|
||||||
// rdif: Projected run differential from 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
|
* Convert an Elo rating to an equivalent projected run differential.
|
||||||
* scale as the hardcoded TEAMS_DATA.rdif values.
|
* Uses the standard Elo win probability formula (parity factor 400, average Elo 1500),
|
||||||
*
|
* then inverts the winRateFromRDif formula: rdif = (winRate − 0.5) × RDIF_DIVISOR.
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* Exported for unit testing.
|
* Exported for unit testing.
|
||||||
*/
|
*/
|
||||||
export function eloToRDif(elo: number): number {
|
export function eloToRDif(elo: number): number {
|
||||||
return (rawWinRateFromElo(elo) - 0.5) * SEEDING_RDIF_SCALE;
|
return (rawWinRateFromElo(elo) - 0.5) * RDIF_DIVISOR;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -366,8 +270,6 @@ interface TeamEntry {
|
||||||
originalSeed?: number;
|
originalSeed?: number;
|
||||||
currentWins: number; // from regularSeasonStandings (0 pre-season)
|
currentWins: number; // from regularSeasonStandings (0 pre-season)
|
||||||
remainingGames: number; // TOTAL_SEASON_GAMES - gamesPlayed
|
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. */
|
/** 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 {
|
export class MLBSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
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();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants for this sports season.
|
// 1. Load all participants for this sports season.
|
||||||
|
|
@ -567,18 +465,6 @@ export class MLBSimulator implements Simulator {
|
||||||
const standings = await getRegularSeasonStandings(sportsSeasonId);
|
const standings = await getRegularSeasonStandings(sportsSeasonId);
|
||||||
const standingsByParticipantId = new Map(standings.map((s) => [s.participantId, s]));
|
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 teams: TeamEntry[] = participantRows.map((r) => {
|
||||||
const standing = standingsByParticipantId.get(r.id);
|
const standing = standingsByParticipantId.get(r.id);
|
||||||
const gamesPlayed = standing?.gamesPlayed ?? 0;
|
const gamesPlayed = standing?.gamesPlayed ?? 0;
|
||||||
|
|
@ -588,7 +474,6 @@ export class MLBSimulator implements Simulator {
|
||||||
data: getTeamData(r.name),
|
data: getTeamData(r.name),
|
||||||
currentWins: standing?.wins ?? 0,
|
currentWins: standing?.wins ?? 0,
|
||||||
remainingGames: Math.max(0, TOTAL_SEASON_GAMES - gamesPlayed),
|
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.
|
* Raw per-game win rate for regular-season seeding simulation.
|
||||||
*
|
* Uses sourceElo-derived rate if available; falls back to hardcoded rdif
|
||||||
* The base rate comes from sourceElo when available, else from the hardcoded
|
* with SEEDING_RDIF_SCALE (Pythagorean approximation).
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
const seedingWinRateMap = new Map(
|
const seedingWinRate = (entry: TeamEntry): number =>
|
||||||
teams.map((team) => [
|
rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry));
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-game win probability for team A over team B in a playoff series, from
|
* Per-game win probability for team A over team B in a playoff series, from
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
|
||||||
llws_bracket: {
|
llws_bracket: {
|
||||||
info: {
|
info: {
|
||||||
name: "LLWS Bracket Monte Carlo",
|
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: a 10-team double-elimination bracket per side (US & International), each producing a side champion, then the consolation game (3rd/4th) and the World Championship (1st/2nd). Uses championship futures odds for all win probabilities. Set externalId to 'US' or 'Intl'.",
|
||||||
},
|
},
|
||||||
create: () => new LLWSSimulator(),
|
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 {
|
export interface RunSportsSeasonSimulationResult {
|
||||||
sportsSeasonId: string;
|
sportsSeasonId: string;
|
||||||
simulatorType: SimulatorType;
|
simulatorType: SimulatorType;
|
||||||
|
|
@ -94,8 +71,7 @@ export interface RunSportsSeasonSimulationResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runSportsSeasonSimulation(
|
export async function runSportsSeasonSimulation(
|
||||||
sportsSeasonId: string,
|
sportsSeasonId: string
|
||||||
options: RunSportsSeasonSimulationOptions = {}
|
|
||||||
): Promise<RunSportsSeasonSimulationResult> {
|
): Promise<RunSportsSeasonSimulationResult> {
|
||||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||||
if (!sportsSeason) {
|
if (!sportsSeason) {
|
||||||
|
|
@ -159,33 +135,29 @@ export async function runSportsSeasonSimulation(
|
||||||
})),
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!options.skipStandingsRecalc) {
|
const seasonSports = await database().query.seasonSports.findMany({
|
||||||
const seasonSports = await database().query.seasonSports.findMany({
|
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
||||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
});
|
||||||
});
|
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
||||||
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshotDate = new Date().toISOString().slice(0, 10);
|
const snapshotDate = new Date().toISOString().slice(0, 10);
|
||||||
if (!options.skipSnapshots) {
|
await batchUpsertParticipantEvSnapshots(
|
||||||
await batchUpsertParticipantEvSnapshots(
|
results.map((r) => ({
|
||||||
results.map((r) => ({
|
participantId: r.participantId,
|
||||||
participantId: r.participantId,
|
sportsSeasonId,
|
||||||
sportsSeasonId,
|
snapshotDate,
|
||||||
snapshotDate,
|
probFirst: r.probabilities.probFirst,
|
||||||
probFirst: r.probabilities.probFirst,
|
probSecond: r.probabilities.probSecond,
|
||||||
probSecond: r.probabilities.probSecond,
|
probThird: r.probabilities.probThird,
|
||||||
probThird: r.probabilities.probThird,
|
probFourth: r.probabilities.probFourth,
|
||||||
probFourth: r.probabilities.probFourth,
|
probFifth: r.probabilities.probFifth,
|
||||||
probFifth: r.probabilities.probFifth,
|
probSixth: r.probabilities.probSixth,
|
||||||
probSixth: r.probabilities.probSixth,
|
probSeventh: r.probabilities.probSeventh,
|
||||||
probSeventh: r.probabilities.probSeventh,
|
probEighth: r.probabilities.probEighth,
|
||||||
probEighth: r.probabilities.probEighth,
|
calculatedEV: calculateEV(r.probabilities, persistence.scoringRules),
|
||||||
calculatedEV: calculateEV(r.probabilities, persistence.scoringRules),
|
source: r.source,
|
||||||
source: r.source,
|
}))
|
||||||
}))
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
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
|
## 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.
|
- `projectedWins` can become Elo using `seasonGames` and `parityFactor` from season config.
|
||||||
- `projectedTablePoints` can become Elo using `seasonGames`, `maxTablePoints`, and `parityFactor`.
|
- `projectedTablePoints` can become Elo using `seasonGames`, `maxTablePoints`, and `parityFactor`.
|
||||||
- `sourceOdds` can become Elo through the shared futures-to-Elo conversion.
|
- `sourceOdds` can become Elo through the shared futures-to-Elo conversion.
|
||||||
- `sourceOdds` can become a generic `rating` when the simulator declares `derivableInputs: { rating: ["sourceOdds"] }`.
|
- `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`:
|
Missing tail participants must remain blocked unless the season config explicitly chooses an `inputPolicy.missingEloStrategy`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ Sentry.init({
|
||||||
enabled: process.env.NODE_ENV === "production",
|
enabled: process.env.NODE_ENV === "production",
|
||||||
sendDefaultPii: true,
|
sendDefaultPii: true,
|
||||||
tracesSampleRate: 0,
|
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) {
|
beforeSend(event) {
|
||||||
const msg = event.exception?.values?.[0]?.value ?? "";
|
const msg = event.exception?.values?.[0]?.value ?? "";
|
||||||
// Drop React Flight protocol probe errors (e.g. $1:aa:aa in multipart body)
|
// Drop React Flight protocol probe errors (e.g. $1:aa:aa in multipart body)
|
||||||
|
|
|
||||||
391
plans/mobile-app-architecture.md
Normal file
391
plans/mobile-app-architecture.md
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
# Brackt Mobile — Framework Decision & Architecture Design
|
||||||
|
|
||||||
|
**Status:** Design document. Not an implementation plan yet — this is the input to one.
|
||||||
|
**Date:** 2026-08-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
Brackt.com is a multi-sport fantasy drafting platform. Its centerpiece is a **live, real-time snake draft** with a running clock, a per-team draft queue, autodraft, and commissioner controls. That experience is the strongest possible argument for a native app: drafts are time-boxed, turn-based, and happen while people are away from a desk. The website already tries to compensate — `app/hooks/useDraftNotifications.ts` fires Web Notifications only when `document.hidden`, and `server/socket.ts` emits a full `draft-state-sync` snapshot on reconnect with an inline comment explaining it exists *because mobile backgrounding breaks the HTTP revalidate path*. The codebase has been reaching for an app for a while.
|
||||||
|
|
||||||
|
**The goal:** ship iOS and Android from one codebase, with as much code shared with the website as possible, so the two never drift apart on the rules that matter.
|
||||||
|
|
||||||
|
**The constraint that shapes everything:** divergence risk is not evenly distributed. Two `<Button>` components drifting apart is cosmetic. Two implementations of `calculateDraftEligibility` drifting apart silently changes who is allowed to draft whom. This design deliberately spends its sharing budget on the logic and contract layers, and accepts a rewritten presentation layer.
|
||||||
|
|
||||||
|
**Decisions already made** (see §4 for rationale): Expo/React Native; hybrid native + Expo DOM components; contract-first with zod; npm workspaces monorepo; admin surfaced via DOM components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Where the codebase actually stands
|
||||||
|
|
||||||
|
An audit of the repo produced these numbers. They drive every decision below.
|
||||||
|
|
||||||
|
| Layer | LOC (non-test) | Files | Portable to mobile? |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `app/routes/` | ~30,500 | 136 | Split — loaders/actions stay server, JSX rewritten |
|
||||||
|
| `app/components/` | ~19,600 | 197 | **No** — Tailwind v4 + 12 Radix packages, DOM-only |
|
||||||
|
| `app/services/` | ~19,400 | 117 | Server-only (32 simulators, EV engine, sync) — stays put |
|
||||||
|
| `app/models/` | ~15,200 | 104 | Server-only (Drizzle + AsyncLocalStorage) — types reusable |
|
||||||
|
| `app/lib/` | ~4,300 | 56 | **Mostly yes** — this is the extraction target |
|
||||||
|
| `app/hooks/` | ~1,240 | 14 | Logic yes, DOM listeners no |
|
||||||
|
| `server/` | ~1,070 | 11 | Server-only, reused as-is |
|
||||||
|
|
||||||
|
**Good news, and it is genuinely good:**
|
||||||
|
|
||||||
|
- **The draft room is already mobile-shaped.** 21 endpoints under `app/routes/api/` are a real JSON API (`Response.json`, proper status codes, session-checked). The draft room already calls them with `fetch`, not `<Form>`. `GET /api/seasons/:seasonId/draft` already returns a complete JSON draft board.
|
||||||
|
- **`draft-state-sync` is a ready-made mobile hydration payload** — picks, timers with absolute `expiresAt`, queue, watchlist, pause state, all in one event on `join-draft`.
|
||||||
|
- **The timer is deadline-based, not tick-based.** `server/timer.ts` emits an absolute `expiresAt` epoch and clients count down locally (`app/lib/draft-timer.ts`). No per-second socket traffic, and state is fully reconstructible from a timestamp — exactly right for a client that backgrounds.
|
||||||
|
- **`useDraftSocket.ts` already handles offline/online/visibilitychange** with explicitly mobile-motivated comments.
|
||||||
|
- **The mobile information architecture already exists.** `$leagueId.draft.$seasonId.tsx` has a `md:hidden` bottom tab bar and `useDraftRoomState.ts` drives a `mobileTab` state machine (`available | queue | board | teams | controls`). The native draft room is reimplementing a *known, designed* layout — not inventing one.
|
||||||
|
- **`socket.io-client` runs unchanged in React Native.**
|
||||||
|
|
||||||
|
**Three blockers, all of which are also latent problems for the website:**
|
||||||
|
|
||||||
|
1. **Socket.IO has no authentication whatsoever.** There is no `io.use()` middleware, no `auth:` option on the client, and no cookie check in the connection handler. `join-draft(seasonId, teamId?)` validates only that the `teamId` belongs to the season — never that the caller *owns* that team. Any unauthenticated client can join `draft-${seasonId}`, receive the full `draft-state-sync`, and by passing an arbitrary valid `teamId` join `team-${teamId}` to read another manager's private queue and watchlist. Mutations are safe (every `/api/*` action re-checks the session), so this is a read-side leak, not a write path — but it is a real leak that exists today.
|
||||||
|
2. **Most of the app is not consumable by a non-React-Router client.** ~70 page routes return turbo-stream-serialized loader data (not JSON), and ~57 route actions take `FormData` + cookies with an `intent` discriminator. The `plans/completed/public-api-v1.md` plan was written for Clerk and **never implemented** — there is no `server/routes/apiV1.ts`, no `docs/openapi.yaml`, no `/api/v1` reference anywhere in the tree.
|
||||||
|
3. **There is no validation or contract layer.** `zod@4.3.6` is a dependency used in exactly **one file** (`app/utils/sports-data-sync.server.ts`). Everything else is `formData.get("x") as string` plus hand-rolled null checks. Socket payload types are declared **three separate times** (`server/socket.ts`, `server/socket.d.ts`, inline in `useDraftSocketEvents.ts`) and are already drifting — `pick-replaced`, `queue-eligibility-pruned`, `draft-rolled-back`, and `draft-started` are emitted and handled but missing from the `ServerToClientEvents` interface.
|
||||||
|
|
||||||
|
**Two pieces of documentation are stale and should be corrected as part of this work:** `CLAUDE.md` and `AGENTS.md` both say "Clerk auth" — the app migrated to **better-auth** (`BETTERAUTH_MIGRATION.md`, and `docs/agents/auth.md` is accurate). `docs/agents/architecture.md` documents a `timer-update` socket event that no longer exists and omits ~8 events that do.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Framework decision
|
||||||
|
|
||||||
|
### Chosen: Expo (React Native) — SDK 56 / React Native 0.85
|
||||||
|
|
||||||
|
React Native with Expo is the only option that satisfies "iOS + Android from one codebase" *and* "share code with the website," because the website is React + TypeScript. Flutter, native Swift/Kotlin, and .NET MAUI all score zero on sharing and were not seriously considered.
|
||||||
|
|
||||||
|
Within the React/TypeScript family:
|
||||||
|
|
||||||
|
| Option | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| **Expo + React Native** | **Chosen.** Same language, same React model, same `socket.io-client`, mature push story, managed cloud builds (EAS) so no Xcode/Android Studio required day-to-day. New Architecture is mandatory and stable as of SDK 55+. |
|
||||||
|
| Capacitor / WebView wrapper | Rejected as the primary approach. Near-zero divergence, but the draft room — the single screen most worth having native — is the one most damaged by webview latency and scroll/gesture behavior. |
|
||||||
|
| PWA only | Rejected. `public/site.webmanifest` already declares `display: standalone`, but there is **no service worker**, so there is no Web Push today and adding it is not free. On iOS, web push requires the user to add to home screen and reliability is poor — unacceptable for a time-boxed draft clock. No store presence either. |
|
||||||
|
| React Native Web universal UI | Rejected — see below. |
|
||||||
|
| One / Tamagui-based universal frameworks | Rejected. Too young to bet a solo-maintained production app on. |
|
||||||
|
|
||||||
|
### Rejected: universal UI (react-native-web + NativeWind)
|
||||||
|
|
||||||
|
This was the closest call and deserves an explicit record, because it is the option that *sounds* most aligned with "share as much as possible."
|
||||||
|
|
||||||
|
Against it:
|
||||||
|
|
||||||
|
- **Scope.** ~19,600 LOC across 197 components, ~4,350 `className` usages, and **12 `@radix-ui/*` packages** that are DOM-only. Universalizing means rewriting all of it.
|
||||||
|
- **It regresses the website.** You would lose Radix's accessibility primitives, semantic HTML and SSR/SEO for the marketing pages and generated sitemap, `recharts` (`PointProgressionChart`, `RecentScoresCard`), `@dnd-kit` (draft-order sorting), `@tanstack/react-virtual` (participant list), `react-image-crop` (avatar editor), and Turnstile on auth forms. Each has an RN substitute; each substitute is a downgrade.
|
||||||
|
- **Much of the UI is desktop-dense by nature** — 36 admin routes, standings tables, bracket trees (`BracketTreeView`, `Cs2TournamentBracket`, `NbaBracketLayout`). A primitive set optimized for phone *and* dense desktop tables is good at neither.
|
||||||
|
- **The payoff is the wrong layer.** It buys pixel-level sharing while the actual divergence risk sits in draft eligibility, snake-draft math, timer semantics, and API shapes — all of which this design shares anyway, at a fraction of the cost.
|
||||||
|
|
||||||
|
For a solo developer working in TypeScript/React only, it is a months-long migration that makes the website worse in order to share the layer that matters least.
|
||||||
|
|
||||||
|
### Chosen UI strategy: hybrid — native screens + Expo DOM components
|
||||||
|
|
||||||
|
Native React Native screens for the surfaces that justify native quality:
|
||||||
|
|
||||||
|
- Draft room, draft queue, draft board
|
||||||
|
- League home, standings, team detail
|
||||||
|
- Auth, onboarding, invite-accept
|
||||||
|
- User settings, team settings, notification preferences
|
||||||
|
|
||||||
|
**Expo DOM components (`'use dom'`)** for the long tail — these render *the existing web React components*, unchanged, inside a native-managed WebView with a serializable-props bridge:
|
||||||
|
|
||||||
|
- All 36 admin routes
|
||||||
|
- Marketing/static: rules, how-to-play, privacy, support
|
||||||
|
- Tournament brackets and bracket tree views
|
||||||
|
- Avatar editor / image crop (canvas-based, genuinely web tech)
|
||||||
|
|
||||||
|
This is what makes "full parity" tractable for one person. Those screens are literally the same files the website renders, so they cannot diverge, and they are exactly the screens where a WebView's cost is irrelevant — nobody is drag-scrolling a Swiss-stage admin table with 60fps expectations. Any DOM screen can be promoted to a native screen later, one at a time, forever, without a rewrite of the shell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Target architecture
|
||||||
|
|
||||||
|
### 4.1 Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
brackt/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/ # the existing React Router 7 app, moved wholesale
|
||||||
|
│ │ ├── app/ # routes, components, models, services, lib
|
||||||
|
│ │ ├── server/ # Express + Socket.IO + timer
|
||||||
|
│ │ ├── database/ # Drizzle schema + context
|
||||||
|
│ │ └── server.ts
|
||||||
|
│ └── mobile/ # new Expo app
|
||||||
|
│ ├── app/ # Expo Router file-based routes
|
||||||
|
│ ├── components/ # native components (NativeWind-styled)
|
||||||
|
│ ├── dom/ # 'use dom' wrappers around web components
|
||||||
|
│ └── lib/ # native socket client, auth, push registration
|
||||||
|
├── packages/
|
||||||
|
│ ├── contracts/ # zod schemas — THE shared contract (new)
|
||||||
|
│ ├── core/ # pure domain logic, extracted from app/lib (moved)
|
||||||
|
│ └── api-client/ # typed fetch client generated from contracts (new)
|
||||||
|
├── drizzle/ # migrations stay at root (drizzle.config.ts points into apps/web)
|
||||||
|
└── package.json # npm workspaces root
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Layering, and what each layer means for divergence
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────┐
|
||||||
|
│ PRESENTATION web JSX │ RN screens │ DOM cmp │ ← diverges by design
|
||||||
|
├───────────────────────────────────────────────────────────┤
|
||||||
|
│ packages/api-client typed fetch + socket client │ ← shared
|
||||||
|
│ packages/contracts zod schemas, z.infer'd types │ ← shared, the contract
|
||||||
|
│ packages/core draft rules, snake math, timers │ ← shared, the rules
|
||||||
|
├───────────────────────────────────────────────────────────┤
|
||||||
|
│ SERVER app/routes/api app/models app/services server/ │ ← single implementation
|
||||||
|
└───────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The rule this enforces: **anything that could produce a different answer on two clients lives in `packages/core` or `packages/contracts`, and there is exactly one copy of it.**
|
||||||
|
|
||||||
|
### 4.3 `packages/core` — the extraction
|
||||||
|
|
||||||
|
These files were verified as pure (no DB, no React, no Node built-ins) and move out of `app/lib/` with their co-located tests:
|
||||||
|
|
||||||
|
| File | LOC | Why it must be shared |
|
||||||
|
|---|---:|---|
|
||||||
|
| `draft-eligibility.ts` | 233 | **The draft rules engine.** Decides which sports a team may draft from. |
|
||||||
|
| `draft-order.ts` | 82 | Snake-draft math: `buildDraftOrderTeams`, `getTeamForPick`, `getProjectedPicks` |
|
||||||
|
| `draft-timer.ts` | 145 | `formatClockTime`, `calculateTimeAfterPick`, `clientExpiresAt`, chess-clock presets |
|
||||||
|
| `overnight-pause.ts` | 96 | `isInOvernightWindow`, `getOvernightResumeUTC` |
|
||||||
|
| `bracket-templates.ts` | 1,301 | Bracket shape definitions |
|
||||||
|
| `fifa-2026-bracket.ts` + third-place | 668 | World Cup bracket logic |
|
||||||
|
| `flag-generator.ts`, `flag-types.ts`, `avatar-data.ts`, `avatar-colors.ts`, `color-hash.ts` | ~166 | Deterministic avatar config from a seed — renderer-agnostic |
|
||||||
|
| `date-utils.ts`, `normalize-team-name.ts`, `fuzzy-match.ts`, `standings-display.ts`, `tournament-identity.ts`, `sport-icon-url.ts`, `cloudinary-url.ts`, `scoring-types.ts` | ~450 | Misc pure helpers |
|
||||||
|
| `calculatePickInfo` (from `app/models/draft-utils.ts`) | — | Snake round/pick math, already used on both server and client paths |
|
||||||
|
|
||||||
|
**One carve-out:** `getTimerColorClass` in `draft-timer.ts` returns Tailwind class strings. It stays web-side; `packages/core` exports a `getTimerSeverity(): 'normal' | 'warning' | 'critical'` and each platform maps severity to its own styling.
|
||||||
|
|
||||||
|
Every one of these files already has tests in `__tests__/`, which move with them and become `packages/core`'s test suite.
|
||||||
|
|
||||||
|
### 4.4 `packages/contracts` — hand-written zod, not derived from Drizzle
|
||||||
|
|
||||||
|
Domain types today come from `typeof schema.X.$inferSelect`, and composite types use `Awaited<ReturnType<typeof someModelFn>>`. That pattern is a hard blocker for mobile: it drags `drizzle-orm/pg-core` and the `postgres` driver into the type graph, and the inferred types carry `Date` objects and `numeric`-as-string values that do not survive a JSON boundary.
|
||||||
|
|
||||||
|
**Decision: `packages/contracts` hand-writes zod schemas for the wire format and derives types with `z.infer`. It does not import from `database/schema.ts` at all.**
|
||||||
|
|
||||||
|
This costs some duplication and buys three things: mobile never touches Drizzle; the wire format is explicit rather than accidental (dates are ISO strings, numerics are numbers, both stated in the schema); and there is finally runtime validation where today there is none. Server handlers validate their inputs against the same schemas, so a drift between the DB shape and the wire shape becomes a test failure rather than a silent `undefined` on one platform.
|
||||||
|
|
||||||
|
Contents:
|
||||||
|
|
||||||
|
- **Socket events** — one schema per event, replacing the three drifting declarations. Includes the four currently-missing events.
|
||||||
|
- **API request/response bodies** — for each of the 21 existing draft/queue endpoints, then league, team, settings, and admin domains as they convert.
|
||||||
|
- **Shared enums** — season status, autodraft mode, timer mode, audit action, derived from the same source of truth as the pgEnums.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 Screen inventory — every route, assigned
|
||||||
|
|
||||||
|
`app/routes.ts` has **95 route entries**. This is the full parity map.
|
||||||
|
|
||||||
|
**Native RN screens (24)** — the surfaces where native quality is worth the rewrite:
|
||||||
|
|
||||||
|
| Group | Routes |
|
||||||
|
|---|---|
|
||||||
|
| Draft (3) | `/leagues/:id/draft/:seasonId`, `/draft-queue/:seasonId`, `/draft-board/:seasonId` |
|
||||||
|
| League (8) | `/leagues/new` (the 1,314-line wizard), `/leagues/creating`, `/leagues/:id`, `/settings`, `/audit-log`, `/upcoming-events`, `/sports-seasons/:ssId`, `/sports-seasons/:ssId/events/:eventId` |
|
||||||
|
| Standings (2) | `/leagues/:id/standings/:seasonId`, `.../teams/:teamId` |
|
||||||
|
| Auth (6) | `/login`, `/register`, `/check-email`, `/forgot-password`, `/reset-password`, `/onboarding` |
|
||||||
|
| User & entry (5) | `/settings/:section?`, `/teams/:teamId/settings`, `/i/:inviteCode`, `/user-profile`, plus a native home/dashboard replacing `/` |
|
||||||
|
|
||||||
|
**Expo DOM components (44)** — existing web components, reused verbatim:
|
||||||
|
|
||||||
|
| Group | Count | Routes |
|
||||||
|
|---|---:|---|
|
||||||
|
| Admin | 36 | `routes/admin.tsx` layout + all 35 children (sports, sports-seasons and its 16 sub-pages, participants, tournaments, templates, data-sync, standings-snapshots, users, leagues, simulators, draft-schedule) |
|
||||||
|
| Static / marketing | 5 | `/how-to-play`, `/rules`, `/support`, `/privacy-policy`, `/sports` |
|
||||||
|
| Data-dense read-only | 3 | `/upcoming-events`, `/sports-seasons/:id/tournament` (bracket trees), avatar editor within settings |
|
||||||
|
|
||||||
|
**No screen (27)** — server-only, unchanged: the 23 `api/*` resource routes, 3 cron job endpoints, `/healthz`. `/test-socket` is a dev scratch page and is not ported.
|
||||||
|
|
||||||
|
The ratio is the argument for the hybrid: **24 screens to build, 44 to inherit.**
|
||||||
|
|
||||||
|
### 4.6 Phase 0 mechanics — what actually breaks in the move
|
||||||
|
|
||||||
|
I read every build config. **Most of them survive the move untouched**, because they resolve paths relative to their own `__dirname` or `./` and therefore move with the app. Named concretely so Phase 0 is a checklist, not an exploration:
|
||||||
|
|
||||||
|
**Move to `apps/web/` unchanged:** `vite.config.ts`, `react-router.config.ts`, `vitest.config.ts`, `components.json`, `instrument.server.mjs`, `server.ts`, `scripts/`, `public/`, `.storybook/`, `cypress/`, `.oxlintrc.json`.
|
||||||
|
|
||||||
|
**Needs real edits:**
|
||||||
|
|
||||||
|
| File | What breaks | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `Dockerfile` | Enumerates directories explicitly (`COPY app/ /app/app/`, `COPY server/`, `COPY database/`, `COPY tsconfig*.json vite.config.ts …`) and does a root-level `npm ci` in three stages | Rewrite to copy the workspace root manifests + `apps/web/` + `packages/`, and run `npm ci --workspaces`. This is the largest single mechanical change. |
|
||||||
|
| `drizzle.config.ts` | `out: "./drizzle"`, `schema: "./database/schema.ts"` | Move the config **and** `drizzle/` (227 migration files) into `apps/web/`. The DB belongs to the server app; keeping migrations at the repo root while the schema moves is the setup most likely to silently generate a migration into the wrong place. |
|
||||||
|
| `tsconfig.json` / `tsconfig.vite.json` / `tsconfig.node.json` / `tsconfig.server.json` | Path maps `~/*` → `./app/*` etc. still work post-move, but the new packages are invisible | Add `@brackt/core/*` and `@brackt/contracts/*` to `paths` in all four. Keep `~/*` exactly as-is so **no import statement in the existing 92k LOC has to change**. |
|
||||||
|
| `package.json` | Single-package scripts | Root becomes a workspaces manifest; `dev`/`build`/`test:run` etc. delegate via `-w apps/web`. `lint` script (`oxlint app/ server/ database/`) becomes workspace-aware. |
|
||||||
|
| `.forgejo/workflows/deploy.yml` | `npm ci` + `npm run test:run` + `npm run lint` at root | Still works if root scripts delegate; verify the Cypress and Postgres service steps still resolve. The two cron workflows (`daily-snapshots.yml`, `sync-and-simulate.yml`) are just `curl` calls to production and need **no change**. |
|
||||||
|
| `docker-compose.yml` | Build context | Point at the new context. |
|
||||||
|
|
||||||
|
**One config deserves special attention.** `vite.config.ts` has a custom `database-context-alias` plugin that resolves `~/database/context` to `database/context.browser-stub.ts` for client builds and the real `context.ts` for SSR. That plugin exists because someone already got burned by Drizzle leaking into a client bundle. Metro has no equivalent, and writing one is avoidable: it is the concrete reason `packages/contracts` must **never** import `database/schema.ts` (§4.4). If mobile's type graph can't reach Drizzle, no resolver stub is needed.
|
||||||
|
|
||||||
|
**Order of operations, so the repo is never broken for long:**
|
||||||
|
|
||||||
|
1. Move `app/`, `server/`, `database/`, `drizzle/`, configs, and scripts into `apps/web/` in one commit. Add the workspaces root. Nothing else changes. Gate: `npm run typecheck && npm run test:all` green.
|
||||||
|
2. Fix `Dockerfile`, `docker-compose.yml`, and CI in a second commit. Gate: a successful deploy.
|
||||||
|
3. Extract `packages/core` in a third commit, moving files with their `__tests__/` and leaving `app/lib/*` re-export shims so no import site changes yet. Gate: tests green.
|
||||||
|
4. Delete the shims and rewrite import sites mechanically. Gate: tests green.
|
||||||
|
|
||||||
|
No feature work in any of these commits.
|
||||||
|
|
||||||
|
### 4.7 Library substitution map (native screens only)
|
||||||
|
|
||||||
|
DOM-component screens keep their existing libraries untouched. These substitutions apply only to the 24 native screens:
|
||||||
|
|
||||||
|
| Web | Native | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Tailwind v4 + `app.css` `@theme` tokens | **NativeWind** with the same CSS-variable token names | The design tokens (`--color-electric`, `--amber-accent`, `--coral-accent`, radius scale) port; the app is already hard-coded dark, which removes theme-switching work |
|
||||||
|
| 12 × `@radix-ui/*` + shadcn `ui/` | **react-native-reusables** (shadcn-shaped, NativeWind-based) | Closest available analogue to the existing API surface |
|
||||||
|
| `lucide-react` | `lucide-react-native` | Near drop-in |
|
||||||
|
| `recharts` (`PointProgressionChart`) | `victory-native` / `react-native-svg` | Or keep as a DOM component — charts are a reasonable DOM candidate |
|
||||||
|
| `@dnd-kit/*` (queue + draft order) | `react-native-draggable-flatlist` (Reanimated) | Upgrade, not a downgrade |
|
||||||
|
| `@tanstack/react-virtual` | `FlatList` | Simpler natively |
|
||||||
|
| `sonner` toasts | RN toast library | |
|
||||||
|
| `react-image-crop` + `AvatarEditor` | Stays a **DOM component** | Canvas-based; genuinely web tech |
|
||||||
|
| `@marsidev/react-turnstile` | WebView | Registration flow only |
|
||||||
|
| `nprogress` (`NavigationProgress`) | Dropped | Meaningless in RN |
|
||||||
|
| Inline SVG (`FlagSvg`, `BracktGradients`, `BracketDecor`) | `react-native-svg` | Mechanical but non-trivial; the *config* generation (`flag-generator.ts`) is already shared via `packages/core` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Auth on mobile
|
||||||
|
|
||||||
|
better-auth is already in place, has an official Expo integration, and this is largely configuration rather than invention.
|
||||||
|
|
||||||
|
- Server: add the `expo()` plugin and the `bearer()` plugin to `betterAuth({...})` in `app/lib/auth.server.ts`. Register the mobile app's scheme in `trustedOrigins`.
|
||||||
|
- Mobile: `@better-auth/expo` client plugin + `expo-secure-store` for token storage. `createAuthClient({ baseURL })` — the web's `authClient` has no `baseURL` because it relies on same-origin; mobile must set it explicitly.
|
||||||
|
- OAuth (Google, Discord) uses the system browser plus a deep-link callback; the better-auth Expo plugin handles the cookie-to-URL-parameter conversion.
|
||||||
|
- **Web is unaffected.** It keeps cookie sessions. Server handlers already read sessions via `auth.api.getSession({ headers })`, which works for both a cookie and a bearer header — so the ~57 existing call sites need no change.
|
||||||
|
- Turnstile on registration needs a WebView on mobile, or the mobile registration flow defers to the system browser.
|
||||||
|
|
||||||
|
Biometric unlock was not selected as a v1 must-have; long-lived secure-store sessions cover the "never logged out mid-draft" need on their own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Realtime on mobile
|
||||||
|
|
||||||
|
### 6.1 Fix socket authentication first (security work, not mobile work)
|
||||||
|
|
||||||
|
Mobile has no ambient cookie jar for the handshake, so it forces the fix — but the fix is owed to the website regardless.
|
||||||
|
|
||||||
|
- Client: `io(url, { auth: { token } })`.
|
||||||
|
- Server: add `io.use()` middleware that verifies the better-auth session (bearer token from mobile, cookie from web) and attaches the resolved `userId` to the socket.
|
||||||
|
- **Stop trusting the client's `teamId`.** `join-draft(seasonId, teamId?)` currently takes `teamId` as a parameter and only checks it belongs to the season. Change the handler to derive the caller's team from `userId + seasonId` server-side. This closes the private-queue leak described in §2.
|
||||||
|
- Reject `join-draft` for private draft boards when the caller is not a member, matching the 401/403 logic already in the draft room's loader.
|
||||||
|
|
||||||
|
### 6.2 The mobile client is socket-first
|
||||||
|
|
||||||
|
The website maintains two sources of truth — the SSR loader and the socket — and pays for it with reconciliation machinery (`isRevalidatingRef`, `pendingPicksDuringRevalidationRef` in `useDraftAuthRecovery.ts`) that buffers socket picks landing mid-revalidation and merges them by pick id.
|
||||||
|
|
||||||
|
The mobile client sidesteps this entirely: `join-draft` → `draft-state-sync` is the *only* hydration path, and every subsequent change arrives as an event. One source of truth, no reconciliation buffer. This is a genuine simplification, and once it is proven on mobile it is worth evaluating whether the web draft room should adopt the same model.
|
||||||
|
|
||||||
|
### 6.3 The native draft room — already designed
|
||||||
|
|
||||||
|
The web draft room's mobile breakpoint is not a fallback; it is a deliberate phone layout that the native screen should port structurally rather than reinvent:
|
||||||
|
|
||||||
|
- **Header** — logo, "Draft Room", exit.
|
||||||
|
- **On-the-clock bar** (`md:hidden`, `aria-live="assertive"`) — whose turn, the countdown, and a distinct treatment for *your* turn versus overnight pause. This becomes a persistent native header component.
|
||||||
|
- **Bottom tab bar** with five tabs, driven by `mobileTab` in `useDraftRoomState.ts:66`: `available | queue | board | teams | controls`. This maps one-to-one onto an Expo Router bottom-tab navigator inside the draft route.
|
||||||
|
- **Commissioner controls** already live in their own `controls` tab on mobile (`CommissionerDraftControls` is `hidden md:flex` in the header), so the native screen inherits that placement.
|
||||||
|
|
||||||
|
Two things become *better* natively: `AvailableParticipantsSection` (784 LOC) currently uses `@tanstack/react-virtual` and becomes a plain `FlatList`; the queue's `@dnd-kit` sortable becomes a native draggable list with real gesture handling.
|
||||||
|
|
||||||
|
### 6.4 Backgrounding
|
||||||
|
|
||||||
|
The deadline-based timer makes this tractable: the client stores an absolute `expiresAt` and derives the countdown from wall-clock time, so a backgrounded app resumes with a correct clock rather than a stale tick count. On foreground, re-emit `join-draft` and let `draft-state-sync` replace local state wholesale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Push notifications
|
||||||
|
|
||||||
|
The decision logic already exists server-side — `sendOnTheClockEmail` (`app/services/draft-email.server.ts`) and `notifyPickMadeOnDiscord` (`app/services/draft-discord.server.ts`) are both already called from `app/routes/api/draft.make-pick.ts` and `server/timer.ts`. Push is a **fourth fan-out alongside email and Discord**, not a redesign.
|
||||||
|
|
||||||
|
- New `deviceTokens` table (user, token, platform, app version, timestamps) + a Drizzle migration via `npm run db:generate`.
|
||||||
|
- New `app/services/push.server.ts` mirroring the shape of the Discord and email services, reusing `enqueuePickNotification`'s per-league serialization so pushes arrive in pick order.
|
||||||
|
- Respect the existing per-user notification preferences pattern (`draftEmailNotificationsEnabled`, `discordPingEnabled`) — add a push equivalent to `app/components/user/settings/NotificationsSection.tsx`.
|
||||||
|
- Because the server owns an absolute `pick_deadline_at`, "your pick expires in 60 seconds" can be **scheduled accurately**, not just fired reactively.
|
||||||
|
|
||||||
|
Events to send: you're on the clock; your pick is about to expire; the draft is starting soon; the draft started; your autodraft made a pick for you; draft complete.
|
||||||
|
|
||||||
|
**Background draft queue** (the second must-have): queue mutations already run through `api/queue/{add,remove,clear,reorder}` with optimistic updates and rollback on the web. Mobile adds a write queue that persists pending mutations across a background/kill and replays them on reconnect, reconciling against the authoritative `draft-state-sync`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Prerequisite refactors
|
||||||
|
|
||||||
|
These land in `apps/web/` before or alongside mobile work, and each stands on its own merits.
|
||||||
|
|
||||||
|
1. **Extract a headless draft-room core.** `$leagueId.draft.$seasonId.tsx` is **1,785 lines** — loader, ~40 `useState` slices, ~15 mutation handlers, derived-state `useMemo`s, and the full responsive JSX tree in one file. The hook decomposition (`useDraftRoomState`, `useDraftSocketEvents`, `useDraftAuthRecovery`) is already ~80% of the way there; what remains is pulling the mutation handlers and derived state out into a platform-agnostic `useDraftRoom()` that both the web JSX and the RN screen consume. **This is the single highest-leverage refactor and should happen before any RN screen is written.**
|
||||||
|
2. **Socket authentication** (§6.1) — security fix, ships independently.
|
||||||
|
3. **zod contracts for socket payloads** — retires the triple declaration and the four drifted events.
|
||||||
|
4. **Correct the stale docs** — `CLAUDE.md` and `AGENTS.md` say Clerk; `docs/agents/architecture.md` lists a dead `timer-update` event and omits ~8 live ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phased delivery
|
||||||
|
|
||||||
|
Each phase ends with something that works and is worth having on its own.
|
||||||
|
|
||||||
|
| Phase | Deliverable | Depends on |
|
||||||
|
|---|---|---|
|
||||||
|
| **0 — Foundations** | npm workspaces monorepo; web moved to `apps/web/`; `packages/core` extracted with its tests passing; CI, Dockerfile, and drizzle config updated. Web app behaves identically. | — |
|
||||||
|
| **1 — Contracts & security** | `packages/contracts` with socket + draft/queue API schemas; server-side validation wired in; socket handshake auth; `teamId` derived server-side. Website gets a real security fix. | 0 |
|
||||||
|
| **2 — Headless draft core** | `useDraftRoom()` extracted; web draft room refactored onto it and verified against the existing Cypress `draft-room.cy.ts`. | 1 |
|
||||||
|
| **3 — App shell** | Expo scaffold, Expo Router, NativeWind, better-auth Expo login/register/OAuth, deep links, session persistence. Runs on device. | 0, 1 |
|
||||||
|
| **4 — Native draft room** | The core deliverable. Socket-first draft room, queue, board, autodraft, commissioner controls, offline write queue. | 2, 3 |
|
||||||
|
| **5 — Push** | `deviceTokens` table, `push.server.ts`, on-the-clock and expiry-warning sends, preference UI on both platforms. | 4 |
|
||||||
|
| **6 — Native league surfaces** | League home, standings, team detail, settings, invite-accept — requires converting those route actions to contract-validated endpoints. | 1, 3 |
|
||||||
|
| **7 — DOM long tail** | Admin, marketing/rules, tournament brackets, avatar editor via `'use dom'`. Parity reached. | 3 |
|
||||||
|
| **8 — Release** | App Store and Play Store submission, EAS Build/Submit pipeline, OTA update channel, crash reporting via the existing Sentry account. | 4–7 |
|
||||||
|
|
||||||
|
Phases 3 and 7 unblock "full parity" much earlier than a pure-native path would, which is the whole point of the hybrid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Testing
|
||||||
|
|
||||||
|
The repo mandates tests for every feature (`docs/agents/testing.md`) and has 179 test files. The mobile work extends that rather than inventing a parallel regime.
|
||||||
|
|
||||||
|
- **`packages/core`** inherits the existing co-located `__tests__/` suites — these become the *shared* guarantee that both platforms compute draft eligibility, snake order, and timers identically.
|
||||||
|
- **`packages/contracts`** gets round-trip tests: server response → zod parse → expected type, so a wire-format change fails loudly on both platforms at once.
|
||||||
|
- **Web** keeps vitest + Cypress. `cypress/e2e/draft-room.cy.ts` is the regression gate for the Phase 2 headless refactor.
|
||||||
|
- **Mobile** uses vitest + React Native Testing Library for logic and components; Maestro or Detox for the one E2E flow worth automating (login → join draft → make a pick).
|
||||||
|
- **Contract conformance** — one test per endpoint asserting the real handler's response parses against its contract schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| **Socket.IO is single-instance and stateful.** `connectedTeams` presence and `draftRoomClosureTimers` are in-memory Maps; deployment relies on sticky sessions. More clients per draft (web + phone for the same user) increases the blast radius of a restart. | Existing `docs/infrastructure-roadmap.md` Phase C covers Traefik sticky sessions; the Redis Socket.IO adapter is the known next step. Not a blocker for v1, but track it. |
|
||||||
|
| **The Phase 0 monorepo move.** Smaller than it looks — most configs are `__dirname`-relative and move intact — but the `Dockerfile` enumerates directories explicitly and drizzle's paths are hard-coded. | The four-commit sequence in §4.6, each with its own green gate. Keep `~/*` path aliases identical so no existing import changes. |
|
||||||
|
| **The 1,785-line draft room refactor could regress the live draft.** | Cypress `draft-room.cy.ts` as the gate; refactor is behavior-preserving by construction; ship Phase 2 separately from any mobile code. |
|
||||||
|
| **Expo DOM components have real constraints** — only serializable props and async callbacks cross the bridge, and they don't get React Router's loader data for free. | Verify the pattern on one admin route in a spike *before* committing Phase 7. See §12 open question. |
|
||||||
|
| **Solo maintenance of three surfaces** (web, native, DOM). | The DOM tier is deliberately the *unmaintained* tier — it tracks the website automatically. Native surface is capped at ~15 screens. |
|
||||||
|
| **App Store review** for a fantasy sports app; some reviewers scrutinize anything resembling contests. | Brackt has no wagering or entry fees; positioning is straightforward, but budget a review cycle. |
|
||||||
|
| **Metro vs Vite resolution differences** for workspace packages and path aliases. | Resolved in Phase 0 by making `packages/*` source-only TypeScript with matching alias config on both bundlers. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Verification
|
||||||
|
|
||||||
|
- **Phase 0:** `npm run typecheck && npm run test:all` green from the workspace root; `npm run dev` serves the site identically; `docker compose up` builds; CI workflows pass unchanged in behavior.
|
||||||
|
- **Phase 1:** new contract tests pass; a socket client without a valid session is rejected at handshake; a client passing another team's `teamId` to `join-draft` no longer receives that team's `queue-updated`.
|
||||||
|
- **Phase 2:** `cypress run --spec cypress/e2e/draft-room.cy.ts` passes against the refactored room; a manual two-browser draft produces identical behavior to `main`.
|
||||||
|
- **Phase 4:** run a real draft with a phone and a browser in the same season — picks, timer, queue, and autodraft stay in sync; background the app for five minutes mid-draft and confirm the clock is correct on resume.
|
||||||
|
- **Phase 5:** on-the-clock push arrives on a locked device within seconds of the previous pick landing.
|
||||||
|
- **Phase 7:** every route in `app/routes.ts` is reachable in the app, natively or via DOM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Open questions
|
||||||
|
|
||||||
|
Resolved by a spike during Phase 0/3, not by discussion:
|
||||||
|
|
||||||
|
1. **The DOM-component spike — do this before committing to Phase 7.** `'use dom'` only passes serializable props and async function callbacks across the bridge, and the admin page components currently read `useLoaderData()`. Pick one representative admin route (suggest `admin.users.tsx` — table-shaped, low risk) and answer: what is the minimum wrapper shape that feeds it loader data as props? Does Expo bundle Tailwind's `app.css` inside the DOM component? What happens to a react-router `<Link>` rendered inside one — does it need intercepting and forwarding to Expo Router? If the answers are ugly, the fallback is an authenticated in-app browser for admin, which is worse but still parity.
|
||||||
|
2. **Metro resolution for the workspace.** Symlink handling and `unstable_enablePackageExports` for source-only `packages/*`. Resolve during the Phase 3 scaffold; the mitigation is well-trodden (Expo documents monorepo setups) but it should be proven with a real cross-package import before Phase 4 depends on it.
|
||||||
|
3. Whether the web draft room should adopt the mobile client's socket-first model (§6.2) and shed its reconciliation buffers, once mobile has proven the pattern.
|
||||||
|
4. Apple Developer and Google Play accounts, and EAS Build tier — needed before Phase 8, not before Phase 0.
|
||||||
|
|
@ -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));
|
app.use((_, __, next) => DatabaseContext.run(db, next));
|
||||||
|
|
||||||
// Block common bot probe paths before React Router (and Sentry) see them.
|
// 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.
|
|
||||||
const BOT_PROBE_RE =
|
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) => {
|
app.use((req, res, next) => {
|
||||||
if (BOT_PROBE_RE.test(req.path)) {
|
if (BOT_PROBE_RE.test(req.path)) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
"app/models/**/*.ts",
|
"app/models/**/*.ts",
|
||||||
"app/services/**/*.ts",
|
"app/services/**/*.ts",
|
||||||
"app/lib/**/*.ts",
|
"app/lib/**/*.ts",
|
||||||
"app/test/fixtures/**/*.ts",
|
|
||||||
"app/types/**/*.ts",
|
"app/types/**/*.ts",
|
||||||
"vite.config.ts"
|
"vite.config.ts"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue