Compare commits

..

4 commits

Author SHA1 Message Date
Claude
929e30c58c
Add --name/--sport filters to the QP backfill script
The reported bug was scoped to Wimbledon, and men's/women's majors share the
same tournament name ("Wimbledon") with gender only distinguished by the
sport, so a name-only filter can't isolate one gender. Add --sport (matched
against the sport's name, e.g. "Tennis - Men") alongside --name, and log the
sport name per tournament in --dry output so the target is easy to verify
before running for real.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
2026-07-03 21:51:23 +00:00
Claude
83e41e9cf1
Fix lint errors from oxlint: array-sort and function-scoping
All checks were successful
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
- Use Array#toSorted() instead of Array#sort() in the QP global-rank builder
  (qualifying-points-discord.server.ts) and the test's withRanks helper.
- Move the discord.test.ts withRanks helper to module scope — it captured
  nothing from the enclosing describe block, which oxlint's
  consistent-function-scoping rule flags as an error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
2026-07-03 21:33:25 +00:00
Claude
ad238e6bfb
Address code review: route cloned windows correctly, dedupe canonical query
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m3s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 48s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Follow-up to the QP scoring fix, from self-review:

- Cloned qualifying windows carry a bracketTemplateId (copied by
  cloneSportsSeason) but no playoff matches, so they hit the bracket branch,
  where processQualifyingBracketEvent derives zero states and writes no QP —
  the canonical-tie-count fix never ran for them. processQualifyingEvent now
  routes to the bracket writer only when the window actually has playoff
  matches; otherwise it falls through to the placement/canonical path like any
  no-template sibling.

- The canonical tournament_results tie span was queried once per window inside
  the fan-out loop. syncTournamentResults already loads the canonical results,
  so it now builds the tie-count map once (buildTieCountByPlacement) and passes
  it to each processQualifyingEvent call; direct callers still query it lazily.

- syncTournamentResults no longer re-stamps completedAt on windows that are
  already complete, so a backfill re-run doesn't churn timestamps.

Adds regression tests: a cloned window (template id, no matches) scores R16
losers at 1.5 via the canonical path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
2026-07-03 21:17:18 +00:00
Claude
efec504c08
Fix qualifying points scoring, Discord rounding, and majors count
Four related bugs in the Qualifying Points subsystem surfaced on Wimbledon:

1. Some leagues stored 2 QP instead of 1.5 for Round-of-16 losers. Sibling/
   mirror windows scored via the placement-group path in processQualifyingEvent,
   which took the tie span from the players present on that window's roster
   (a draftable subset) instead of the full field. A window holding fewer than
   the 8 tied R16 losers split the 9-16 points too few ways and over-awarded.
   The tie span is now derived from the canonical tournament_results (the whole
   field) for tournament-linked events, falling back to the live count only for
   standalone events. Every league now scores identically.

2. Discord notifications rounded QP with Math.round, turning 1.5 into "2".
   Both the "Points Awarded" and "QP Standings" values now use a 2-decimal
   formatter mirroring the web UI's formatQP, so Discord and the site agree.

3. The Discord "QP Standings" block ranked players only among that event's
   scorers, showing two R16 losers as T1 instead of T9. Rank is now computed
   over the full season field and passed through to the notifier.

4. "N of M majors completed" reached "11 of 4": majorsCompleted was a stored
   counter incremented on every fan-out sync with a guard that misfired. It is
   now derived on read (getMajorsCompleted = count of completed qualifying
   events), which is self-correcting; the increment/decrement writes are removed
   along with the now-unused hasProcessedQualifyingPlacement helper.

Adds scripts/backfill-qp-resplit.ts to silently re-score existing majors so
already-corrupted seasons are corrected (2 -> 1.5), and threads a
skipNotifications option through processQualifyingEvent / syncTournamentResults
so the backfill does not re-ping every league.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
2026-07-03 20:40:47 +00:00
94 changed files with 1317 additions and 12440 deletions

View file

@ -9,14 +9,17 @@
"command": ".claude/hooks/lint-on-edit.sh",
"timeout": 30,
"statusMessage": "Linting..."
},
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"if": "Write(*.ts)|Write(*.tsx)|Edit(*.ts)|Edit(*.tsx)|MultiEdit(*.ts)|MultiEdit(*.tsx)",
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"TypeCheck failed:\\n%s\"}}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
"timeout": 60,
"statusMessage": "Type-checking...",
"async": true
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"systemMessage\":\"TypeCheck failed:\\n%s\"}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
"timeout": 60
}
]
}

View file

@ -31,8 +31,3 @@ CLOUDINARY_API_SECRET=""
# Must match the CRON_SECRET repo secret in Forgejo.
# Generate with: openssl rand -hex 32
CRON_SECRET=""
# OC Blacktop motorsport API — used to sync IndyCar championship standings
# (fresher than ESPN's aggregate). Free tier at https://ocblacktop.com/api.
# If unset, IndyCar standings fall back to ESPN.
OCBLACKTOP_API_KEY=""

View file

@ -14,7 +14,7 @@ const scoringRules = {
};
describe("QualifyingPointsStandings", () => {
it("renders fractional QP to at most hundredths with trailing zeros trimmed", () => {
it("renders fractional QP to hundredths", () => {
render(
<QualifyingPointsStandings
standings={[
@ -52,8 +52,7 @@ describe("QualifyingPointsStandings", () => {
);
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
// 0.50 trims its trailing zero to 0.5; 14.67 and 0.43 are unaffected.
expect(screen.getByText("0.5 QP")).toBeInTheDocument();
expect(screen.getByText("0.50 QP")).toBeInTheDocument();
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
});
});

View file

@ -1,16 +1,13 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import { useRoundTransition } from "~/hooks/useRoundTransition";
import type { FeederMap } from "~/lib/bracket-layout";
import type { BracketTemplate } from "~/lib/bracket-templates";
import {
TreeColumns,
BracketMatchSlot,
bracketGeometry,
windowGeometry,
SLOT_WIDTH,
LABEL_HEIGHT,
DESIRED_CARD_HEIGHT,
CARD_GAP,
MAX_CARD_HEIGHT,
type BracketMatch,
type BracketOwnership,
@ -24,8 +21,6 @@ interface BracketTreePaginatedProps {
/** Index of the first scoring round — default page starts here */
firstScoringRoundIdx?: number;
thirdPlaceRound?: string;
feeders?: FeederMap;
template?: BracketTemplate;
}
export function BracketTreePaginated({
@ -35,68 +30,63 @@ export function BracketTreePaginated({
userParticipantIds,
firstScoringRoundIdx,
thirdPlaceRound,
feeders,
template,
}: BracketTreePaginatedProps) {
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
// Pages are pairs of layout columns, not pairs of rounds: a column can mix rounds
// when teams enter the bracket at different points (see computeGroupLayout).
const geometry = bracketGeometry(
mainRounds,
matchesByRound,
feeders,
template?.rounds.map((r) => r.name) ?? mainRounds
);
const columns = geometry.layout.columns;
const lastPage = Math.max(columns.length - 2, 0);
const defaultPage = Math.max(
0,
Math.min(
firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : lastPage,
lastPage,
firstScoringRoundIdx !== undefined
? Math.max(0, firstScoringRoundIdx - 1)
: mainRounds.length - 2,
mainRounds.length - 2,
),
);
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
lastPage,
mainRounds.length - 2,
defaultPage,
);
const pageGeometry = (p: number) => windowGeometry(geometry, p, p + 1);
const labelFor = (p: number) => {
const [a, b] = [columns[p]?.label, columns[p + 1]?.label];
return b ? `${a}${b}` : (a ?? "");
const targetPage = anim ? anim.toPage : page;
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
const label = labelRounds[1] ? `${labelRounds[0]}${labelRounds[1]}` : labelRounds[0];
const calcHeight = (p: number) => {
const rs = mainRounds.slice(p, p + 2);
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
};
const label = labelFor(anim ? anim.toPage : page);
const pageHeight = calcHeight(page);
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
const pageG = pageGeometry(page);
const animFromG = anim ? pageGeometry(anim.fromPage) : pageG;
const animToG = anim ? pageGeometry(anim.toPage) : pageG;
const visibleRounds = mainRounds.slice(page, page + 2);
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
let leftPage: number;
let rightPage: number | null = null;
let leftG = pageG;
let rightG = pageG;
let leftRounds: string[];
let rightRounds: string[] = [];
let leftHeight: number;
let rightHeight = 0;
let settlingTransition = false;
if (anim?.phase === "sliding") {
leftPage = anim.dir === "right" ? anim.fromPage : anim.toPage;
rightPage = anim.dir === "right" ? anim.toPage : anim.fromPage;
leftG = anim.dir === "right" ? animFromG : animToG;
rightG = anim.dir === "right" ? animToG : animFromG;
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
} else if (anim?.phase === "settling") {
leftPage = anim.toPage;
leftG = animToG;
leftRounds = toRounds;
leftHeight = animToHeight;
settlingTransition = true;
} else {
leftPage = page;
leftRounds = visibleRounds;
leftHeight = pageHeight;
}
const containerMinHeight =
anim?.phase === "settling" ? animToG.bracketHeight : animFromG.bracketHeight;
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
return (
@ -119,7 +109,7 @@ export function BracketTreePaginated({
variant="ghost"
size="icon"
onClick={() => navigate(page + 1)}
disabled={page >= lastPage || !!anim}
disabled={page + 2 >= mainRounds.length || !!anim}
className="h-7 w-7 shrink-0"
aria-label="Next rounds"
>
@ -139,24 +129,22 @@ export function BracketTreePaginated({
>
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns
geometry={leftG}
columnRange={[leftPage, leftPage + 1]}
visibleRounds={leftRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
bracketHeight={leftHeight}
transitionDuration={settlingTransition ? 500 : undefined}
/>
</div>
{anim?.phase === "sliding" && rightPage !== null && (
{anim?.phase === "sliding" && (
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns
geometry={rightG}
columnRange={[rightPage, rightPage + 1]}
visibleRounds={rightRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
bracketHeight={rightHeight}
/>
</div>
)}
@ -176,8 +164,6 @@ export function BracketTreePaginated({
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/>
</div>
)}

View file

@ -1,13 +1,5 @@
import { avatarColor } from "~/lib/avatar-colors";
import { BRACKT_GRADIENT } from "~/lib/brand";
import {
computeGroupLayout,
describeSlotSource,
matchKey,
type BracketLayout,
type FeederMap,
} from "~/lib/bracket-layout";
import type { BracketTemplate } from "~/lib/bracket-templates";
export interface BracketMatch {
id: string;
@ -54,8 +46,6 @@ function formatScore(score: string | null): string | null {
interface ParticipantRowProps {
name: string | null;
/** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */
feedLabel?: string | null;
isTbd: boolean;
isWinner: boolean;
isLoser: boolean;
@ -70,7 +60,6 @@ interface ParticipantRowProps {
function ParticipantRow({
name,
feedLabel,
isTbd,
isWinner,
isLoser,
@ -125,7 +114,7 @@ function ParticipantRow({
.filter(Boolean)
.join(" ")}
>
{name ?? feedLabel ?? "TBD"}
{name ?? "TBD"}
</span>
{/* Owner name below participant name */}
@ -161,8 +150,6 @@ interface BracketMatchSlotProps {
slotHeight: number;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
feeders?: FeederMap;
template?: BracketTemplate;
}
export function BracketMatchSlot({
@ -170,8 +157,6 @@ export function BracketMatchSlot({
slotHeight,
ownershipMap,
userParticipantIds,
feeders,
template,
}: BracketMatchSlotProps) {
const rowHeight = slotHeight / 2;
const showText = rowHeight >= 10;
@ -202,13 +187,6 @@ export function BracketMatchSlot({
const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20)));
// An empty slot reads better as "Loser of Winners SF 2" than "TBD" — especially for
// the feeds that cross between the winners and elimination brackets, which render as
// separate trees and so can never be joined by a line.
const slotSources = feeders?.get(matchKey(match.round, match.matchNumber));
const feed1 = describeSlotSource(slotSources?.[0], template);
const feed2 = describeSlotSource(slotSources?.[1], template);
return (
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
@ -230,7 +208,6 @@ export function BracketMatchSlot({
>
<ParticipantRow
name={match.participant1?.name ?? null}
feedLabel={feed1}
isTbd={isTbd1}
isWinner={p1IsWinner}
isLoser={p1IsLoser}
@ -244,7 +221,6 @@ export function BracketMatchSlot({
/>
<ParticipantRow
name={match.participant2?.name ?? null}
feedLabel={feed2}
isTbd={isTbd2}
isWinner={p2IsWinner}
isLoser={p2IsLoser}
@ -261,45 +237,54 @@ export function BracketMatchSlot({
);
}
// ─── Connector column ─────────────────────────────────────────────────────────
// ─── Per-pair connector column ────────────────────────────────────────────────
interface ConnectorColumnProps {
/** Edges crossing this gutter, in slot units. */
edges: { fromCenter: number; toCenter: number }[];
rowHeight: number;
offset: number;
currentMatches: BracketMatch[];
nextMatches: BracketMatch[];
bracketHeight: number;
}
/**
* Draws the feeder edges crossing one gutter. Because the layout assigns columns by
* depth from the final, every edge spans exactly one gutter so a card that enters the
* bracket late is drawn in the column where it actually plays, and there is never an
* edge to route across a skipped column.
*/
function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorColumnProps) {
function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: ConnectorColumnProps) {
const mid = CONNECTOR_WIDTH / 2;
// Merge the two edges feeding one card into a single elbow, so a pair reads as one
// bracket join rather than two overlapping lines.
const byTarget = new Map<number, number[]>();
for (const { fromCenter, toCenter } of edges) {
const sources = byTarget.get(toCenter) ?? [];
sources.push(fromCenter);
byTarget.set(toCenter, sources);
}
const paths: string[] = [];
for (const [toCenter, sources] of byTarget) {
const destY = toCenter * rowHeight - offset;
const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b);
if (ys.length === 1) {
paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
continue;
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals)
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) {
// Standard single-elimination halving: U-shape connectors
for (let k = 0; k < nextMatches.length; k++) {
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
const midY = k * nextSlotH + nextSlotH / 2;
const botIdx = 2 * k + 1;
if (botIdx < currentMatches.length) {
const botY = botIdx * currentSlotH + currentSlotH / 2;
paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`);
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
} else {
paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`);
}
paths.push(`M 0 ${ys[0]} H ${mid} V ${ys[ys.length - 1]} H 0`);
for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`);
paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`);
}
} else {
// Non-standard (byes, play-ins, etc.): trace winners by participantId
const winnerToIdx = new Map<string, number>();
currentMatches.forEach((m, idx) => {
if (m.winnerId) winnerToIdx.set(m.winnerId, idx);
});
nextMatches.forEach((nextMatch, nextIdx) => {
const destY = nextIdx * nextSlotH + nextSlotH / 2;
for (const pId of [nextMatch.participant1Id, nextMatch.participant2Id]) {
if (!pId) continue;
const srcIdx = winnerToIdx.get(pId);
if (srcIdx === undefined) continue;
const srcY = srcIdx * currentSlotH + currentSlotH / 2;
paths.push(`M 0 ${srcY} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
}
});
}
return (
@ -325,131 +310,52 @@ function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorC
// ─── Tree columns (shared by full + paginated) ───────────────────────────────
export interface BracketGeometry {
layout: BracketLayout<BracketMatch>;
/** Height of one leaf row. */
rowHeight: number;
/** Height of the card area, excluding the round labels. */
bracketHeight: number;
/** Narrowest the columns and gutters can be drawn without overlapping. */
minWidth: number;
/** Pixels trimmed off the top, non-zero only for a cropped column window. */
offset: number;
}
/**
* Lay out a group's matches from the feeder graph and derive its pixel geometry.
*
* Height comes from the number of leaf rows rather than the largest round, so a bracket
* whose widest column isn't its first still gets the room it needs.
*/
export function bracketGeometry(
visibleRounds: string[],
matchesByRound: Map<string, BracketMatch[]>,
feeders: FeederMap | undefined,
templateRoundOrder: string[]
): BracketGeometry {
const layout = computeGroupLayout(
visibleRounds,
matchesByRound,
feeders ?? new Map(),
templateRoundOrder
);
const rowHeight = DESIRED_CARD_HEIGHT + CARD_GAP;
const columnCount = Math.max(layout.columns.length, 1);
return {
layout,
rowHeight,
bracketHeight: Math.max(layout.leafCount, 1) * rowHeight,
minWidth: columnCount * COLUMN_WIDTH + (columnCount - 1) * CONNECTOR_WIDTH,
offset: 0,
};
}
/**
* Crop a layout to a window of columns, as the mobile pager does.
*
* Card positions are absolute within the whole bracket, so showing a slice of columns
* means trimming the empty space above them rather than re-flowing otherwise a later
* page would render its two columns stranded at the bottom of a full-height bracket.
*/
export function windowGeometry(
geometry: BracketGeometry,
firstColumn: number,
lastColumn: number
): BracketGeometry {
const centers = geometry.layout.columns
.slice(firstColumn, lastColumn + 1)
.flatMap((c) => c.matches.map((m) => m.center));
if (centers.length === 0) return geometry;
const min = Math.min(...centers);
const max = Math.max(...centers);
return {
...geometry,
bracketHeight: (max - min + 1) * geometry.rowHeight,
offset: (min - 0.5) * geometry.rowHeight,
};
}
interface TreeColumnsProps {
geometry: BracketGeometry;
visibleRounds: string[];
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
bracketHeight: number;
transitionDuration?: number;
feeders?: FeederMap;
template?: BracketTemplate;
/** Restrict rendering to a window of columns (used by the mobile pager). */
columnRange?: [number, number];
}
export function TreeColumns({
geometry,
visibleRounds,
matchesByRound,
ownershipMap,
userParticipantIds,
bracketHeight,
transitionDuration,
feeders,
template,
columnRange,
}: TreeColumnsProps) {
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
const { layout, rowHeight, bracketHeight, offset } = geometry;
const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1];
const visible = layout.columns.slice(firstColumn, lastColumn + 1);
// Cards keep a fixed height regardless of how many share a column — stretching a
// lone final to fill its column is what made it tower over the rest of the bracket.
const cardHeight = Math.min(
Math.max(rowHeight - CARD_GAP, 1),
MAX_CARD_HEIGHT
);
return (
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
{visible.map((column, vi) => {
const ci = firstColumn + vi;
const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci);
{visibleRounds.map((round, ri) => {
const roundMatches = matchesByRound.get(round) ?? [];
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT);
const cardTop = (slotHeight - cardHeight) / 2;
const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null;
const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : [];
return (
<div key={column.label + ci} style={{ display: "contents" }}>
<div key={round} style={{ display: "contents" }}>
{/* Round column */}
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
<div
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
>
{column.label}
{round}
</div>
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
{column.matches.map(({ match, center }) => (
{roundMatches.map((match, matchIdx) => (
<div
key={match.id}
data-match-id={match.id}
style={{
position: "absolute",
top: center * rowHeight - offset - cardHeight / 2,
top: matchIdx * slotHeight + cardTop,
left: 0,
right: 0,
height: cardHeight,
@ -461,8 +367,6 @@ export function TreeColumns({
slotHeight={cardHeight}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/>
</div>
))}
@ -470,11 +374,10 @@ export function TreeColumns({
</div>
{/* Connector between this column and the next */}
{vi < visible.length - 1 && (
{nextRound && (
<ConnectorColumn
edges={gutterEdges}
rowHeight={rowHeight}
offset={offset}
currentMatches={roundMatches}
nextMatches={nextMatches}
bracketHeight={bracketHeight}
/>
)}
@ -493,8 +396,6 @@ interface BracketTreeViewProps {
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
thirdPlaceRound?: string;
feeders?: FeederMap;
template?: BracketTemplate;
}
export function BracketTreeView({
@ -503,19 +404,13 @@ export function BracketTreeView({
ownershipMap,
userParticipantIds,
thirdPlaceRound,
feeders,
template,
}: BracketTreeViewProps) {
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
const geometry = bracketGeometry(
mainRounds,
matchesByRound,
feeders,
template?.rounds.map((r) => r.name) ?? mainRounds
);
const { bracketHeight, minWidth } = geometry;
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP);
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH;
return (
<div
@ -524,11 +419,11 @@ export function BracketTreeView({
>
<div style={{ minWidth }}>
<TreeColumns
geometry={geometry}
visibleRounds={mainRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
bracketHeight={bracketHeight}
/>
{thirdPlaceMatch && (
<div style={{ display: "flex", paddingTop: 20 }}>
@ -546,8 +441,6 @@ export function BracketTreeView({
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/>
</div>
</div>

View file

@ -1,11 +1,5 @@
import type { BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
import type { FeederMap } from "~/lib/bracket-layout";
import {
TreeColumns,
bracketGeometry,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
import type { ConferenceGroup } from "~/lib/bracket-templates";
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated";
interface NbaBracketLayoutProps {
@ -16,10 +10,11 @@ interface NbaBracketLayoutProps {
userParticipantIds: Set<string>;
conferenceGroups: ConferenceGroup[];
scoringRoundIdx: number;
feeders?: FeederMap;
template?: BracketTemplate;
}
const DESIRED_CARD_HEIGHT = 112;
const CARD_GAP = 14;
function splitMatchesByConference(
matchesByRound: Map<string, BracketMatch[]>,
group: ConferenceGroup
@ -33,6 +28,11 @@ function splitMatchesByConference(
return result;
}
function bracketHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
}
export function NbaBracketLayout({
rounds,
matchesByRound,
@ -40,10 +40,7 @@ export function NbaBracketLayout({
userParticipantIds,
conferenceGroups,
scoringRoundIdx,
feeders,
template,
}: NbaBracketLayoutProps) {
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
// Rounds that belong to any conference group
const conferenceRoundSet = new Set(
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
@ -60,7 +57,7 @@ export function NbaBracketLayout({
const sharedMatches = new Map(
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
);
const sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder);
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
return (
<>
@ -69,7 +66,7 @@ export function NbaBracketLayout({
{conferenceGroups.map((group, gi) => {
const confRounds = conferenceRounds[gi];
const confMatches = splitMatchesByConference(matchesByRound, group);
const geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder);
const height = bracketHeight(confMatches, confRounds);
return (
<div key={group.name}>
@ -77,11 +74,11 @@ export function NbaBracketLayout({
{group.name}
</p>
<TreeColumns
geometry={geometry}
visibleRounds={confRounds}
matchesByRound={confMatches}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
bracketHeight={height}
/>
</div>
);
@ -90,11 +87,11 @@ export function NbaBracketLayout({
{sharedRounds.length > 0 && (
<div>
<TreeColumns
geometry={sharedGeometry}
visibleRounds={sharedRounds}
matchesByRound={sharedMatches}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
bracketHeight={sharedHeight}
/>
</div>
)}
@ -108,8 +105,6 @@ export function NbaBracketLayout({
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
firstScoringRoundIdx={scoringRoundIdx}
feeders={feeders}
template={template}
/>
</div>
</>

View file

@ -13,8 +13,7 @@ import { GradientIcon } from "~/components/ui/GradientIcon";
import { RankingsRow } from "./RankingsRow";
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated";
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
import { buildFeederMap } from "~/lib/bracket-layout";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { NbaBracketLayout } from "./NbaBracketLayout";
import { TabbedBracketLayout } from "./TabbedBracketLayout";
@ -77,6 +76,43 @@ export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
return byRound;
}
/**
* For a standard single-elimination bracket, slot p1 of match N in round R
* comes from match (2N-1) in the previous round, and slot p2 from match 2N.
*/
export function buildFeederMap(
matchesByRound: Map<string, Match[]>,
orderedRounds: string[]
): Map<string, { round: string; matchNumber: number }> {
const feederMap = new Map<string, { round: string; matchNumber: number }>();
for (let ri = 1; ri < orderedRounds.length; ri++) {
const currentRound = orderedRounds[ri];
const prevRound = orderedRounds[ri - 1];
const prevMatchNums = new Set(
(matchesByRound.get(prevRound) || []).map((m) => m.matchNumber)
);
for (const match of matchesByRound.get(currentRound) || []) {
const p1Src = 2 * (match.matchNumber - 1) + 1;
const p2Src = 2 * (match.matchNumber - 1) + 2;
if (prevMatchNums.has(p1Src)) {
feederMap.set(`${currentRound}:${match.matchNumber}:p1`, {
round: prevRound,
matchNumber: p1Src,
});
}
if (prevMatchNums.has(p2Src)) {
feederMap.set(`${currentRound}:${match.matchNumber}:p2`, {
round: prevRound,
matchNumber: p2Src,
});
}
}
}
return feederMap;
}
interface EliminatedEntry {
participant: Participant;
score: string | null;
@ -138,182 +174,6 @@ export function computeEliminatedByRound(
return result;
}
/** The score recorded for one participant in a match, or null if they didn't play in it. */
function participantScore(match: Match, participantId: string | null): string | null {
if (!participantId) return null;
if (participantId === match.participant1Id) return match.participant1Score;
if (participantId === match.participant2Id) return match.participant2Score;
return null;
}
/**
* A consolation final: a round contested by the losers of an earlier round, which
* splits the positions those losers would otherwise share. FIFA's "Third Place Game"
* (fed by the Semifinals) is the only one in the templates today.
*/
export interface ConsolationRound {
/** The consolation round itself, e.g. "Third Place Game". */
round: string;
/** The round whose losers contest it, e.g. "Semifinals". */
feederRound: string;
}
/**
* Find the template's consolation round, if it has one.
*
* A consolation round must be TERMINAL its winner plays no further game, which is
* what lets its result split two exact positions. `loserFeedsInto` alone is not
* enough: a double-elimination bracket (llws_20) uses it on every winners-bracket
* round to route losers into the elimination bracket, and those targets are ordinary
* rounds that feed onward. Picking the first `loserFeedsInto` there would mistake
* "Elimination Round 1" for a third-place game and corrupt the final rankings.
*
* Exported for unit testing.
*/
export function findConsolationRound(
template: BracketTemplate | undefined
): ConsolationRound | undefined {
const isTerminal = (roundName: string) =>
template?.rounds.find((r) => r.name === roundName)?.feedsInto === null;
const feeder = template?.rounds.find(
(r) => r.loserFeedsInto && isTerminal(r.loserFeedsInto)
);
if (!feeder?.loserFeedsInto) return undefined;
return { round: feeder.loserFeedsInto, feederRound: feeder.name };
}
/**
* Round names whose losers are placed by some LATER round rather than finishing where
* they lost i.e. double-elimination winners-bracket rounds, whose losers drop into
* the elimination bracket.
*
* The consolation feeder is deliberately excluded: its losers do finish at that tier
* (the consolation game splits their two positions), so it still consumes them.
*
* Exported for unit testing.
*/
export function roundsWithLosersPlacedLater(
template: BracketTemplate | undefined,
consolation: ConsolationRound | undefined
): Set<string> {
return new Set(
(template?.rounds ?? [])
.filter((r) => r.loserFeedsInto && r.name !== consolation?.feederRound)
.map((r) => r.name)
);
}
/**
* Build the ordered final-rankings list from completed matches.
*
* Ranks are derived by walking rounds latest-first: the final's loser is 2nd, the
* previous round's losers share the next tier, and so on each round consuming as
* many positions as it has matches.
*
* A consolation round needs different handling, because its winner never loses a
* match and so the loser-driven walk above would leave them unranked and "in
* contention" forever. Its two places are exactly the top of the tier its feeder
* round's losers would otherwise share, so it is resolved *at the feeder round*
* the winner takes that tier's first position and the loser the second and the
* consolation round itself consumes no positions. Positions are derived rather than
* hardcoded, so a consolation round hanging off a different feeder still lands right.
*
* Exported for unit testing.
*/
export function computeRankedEntries(
matches: Match[],
rounds: string[],
matchesByRound: Map<string, Match[]>,
consolation: ConsolationRound | undefined,
ownershipMap: Map<string, TeamOwnership>,
/** See roundsWithLosersPlacedLater. Empty for single-elimination brackets. */
losersPlacedLater: Set<string> = new Set()
): EliminatedEntry[] {
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
// Only take the consolation path when both rounds actually have matches; otherwise
// fall through to the loser-driven walk so nothing is dropped.
const consolationActive =
consolation &&
rounds.includes(consolation.round) &&
rounds.includes(consolation.feederRound);
// Consolation matches we can place exactly. Anything else in that round (still in
// progress, or missing its hydrated winner/loser) deliberately stays eligible for
// the loser-driven walk rather than being silently dropped.
const consolationMatches =
consolationActive && consolation
? (matchesByRound.get(consolation.round) ?? []).filter(
(m): m is Match & { winner: Participant; loser: Participant } =>
m.isComplete && !!m.winner && !!m.loser
)
: [];
const exactlyPlacedMatchIds = new Set(consolationMatches.map((m) => m.id));
const entryFor = (
match: Match,
participant: Participant,
participantId: string | null
): Omit<EliminatedEntry, "rankLabel"> => ({
participant,
score: participantScore(match, participantId),
ownership: ownershipMap.get(participant.id) || null,
});
const losersByRound = new Map<string, Omit<EliminatedEntry, "rankLabel">[]>();
for (const match of matches) {
if (!match.isComplete || !match.loser) continue;
if (exactlyPlacedMatchIds.has(match.id)) continue;
if (!eliminatedByRound.get(match.round)?.includes(match.loser.id)) continue;
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
losersByRound.get(match.round)?.push(entryFor(match, match.loser, match.loserId));
}
const rankedEntries: EliminatedEntry[] = [];
let nextRank = 2;
for (let ri = rounds.length - 1; ri >= 0; ri--) {
const roundName = rounds[ri];
// The consolation match splits the top of its feeder round's tier, so it is
// placed first and the round's remaining losers share what's left below it.
let tierRank = nextRank;
if (consolationActive && roundName === consolation?.feederRound) {
for (const match of consolationMatches) {
rankedEntries.push({
...entryFor(match, match.winner, match.winnerId),
rankLabel: `${tierRank}`,
});
rankedEntries.push({
...entryFor(match, match.loser, match.loserId),
rankLabel: `${tierRank + 1}`,
});
tierRank += 2;
}
}
for (const loser of losersByRound.get(roundName) ?? []) {
rankedEntries.push({ ...loser, rankLabel: `T${tierRank}` });
}
// The consolation round's places belong to its feeder round's tier, so it
// consumes none of its own.
if (consolationActive && roundName === consolation?.round) continue;
// A round normally consumes one position per match — its losers finish here,
// whether or not the games have been played yet (four semifinalists occupy 14
// regardless). But in a double-elimination bracket a winners-bracket loss places
// nobody: the loser drops into the elimination bracket and is ranked by whatever
// knocks them out later. Those rounds must consume nothing, or every position
// below inflates (a 20-team llws_20 bracket would end at "T23").
if (losersPlacedLater.has(roundName)) continue;
nextRank += matchesByRound.get(roundName)?.length ?? 0;
}
return rankedEntries;
}
/** Find the index of the first round that has scoring matches. */
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
for (let i = 0; i < rounds.length; i++) {
@ -355,13 +215,13 @@ export function PlayoffBracket({
const matchesByRound = groupMatchesByRound(matches);
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
// What fills each slot, used for both card placement and naming empty slots.
const feeders = buildFeederMap(template);
const consolation = findConsolationRound(template);
const thirdPlaceRound = consolation?.round;
const thirdPlaceRound = template?.rounds
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
?.name;
// Build elimination rankings
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
let bracketWinner: Participant | null = null;
const lastRound = rounds[rounds.length - 1];
@ -370,20 +230,43 @@ export function PlayoffBracket({
: null;
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
for (const match of matches) {
if (!match.isComplete || !match.loser) continue;
const eliminatedInRound = eliminatedByRound.get(match.round);
if (!eliminatedInRound?.includes(match.loser.id)) continue;
const loserScore =
match.loserId === match.participant1Id
? match.participant1Score
: match.participant2Score;
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
losersByRound.get(match.round)?.push({
participant: match.loser,
score: loserScore,
ownership: ownershipMap.get(match.loser.id) || null,
});
}
const allBracketParticipantIds = new Set<string>();
for (const match of matches) {
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
}
const rankedEntries = computeRankedEntries(
matches,
rounds,
matchesByRound,
consolation,
ownershipMap,
roundsWithLosersPlacedLater(template, consolation)
);
const rankedEntries: EliminatedEntry[] = [];
let nextRank = 2;
for (let ri = rounds.length - 1; ri >= 0; ri--) {
const roundName = rounds[ri];
const roundLosers = losersByRound.get(roundName) || [];
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
if (roundLosers.length > 0) {
const rankLabel = `T${nextRank}`;
for (const loser of roundLosers) {
rankedEntries.push({ ...loser, rankLabel });
}
}
nextRank += totalMatchesInRound;
}
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
@ -403,7 +286,7 @@ export function PlayoffBracket({
.map((id) => participantMap.get(id))
.filter((p): p is Participant => p !== undefined)
// Owned-by-a-manager players first, then alphabetical by name.
.toSorted((a, b) => {
.sort((a, b) => {
const aOwned = ownershipMap.has(a.id);
const bOwned = ownershipMap.has(b.id);
if (aOwned !== bOwned) return aOwned ? -1 : 1;
@ -444,8 +327,6 @@ export function PlayoffBracket({
userParticipantIds={userParticipantSet}
phases={template.phases}
scoringRoundIdx={scoringRoundIdx}
feeders={feeders}
template={template}
/>
) : template?.conferenceGroups ? (
<NbaBracketLayout
@ -456,8 +337,6 @@ export function PlayoffBracket({
userParticipantIds={userParticipantSet}
conferenceGroups={template.conferenceGroups}
scoringRoundIdx={scoringRoundIdx}
feeders={feeders}
template={template}
/>
) : (
<>
@ -469,8 +348,6 @@ export function PlayoffBracket({
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
thirdPlaceRound={thirdPlaceRound}
feeders={feeders}
template={template}
/>
</div>
@ -483,8 +360,6 @@ export function PlayoffBracket({
userParticipantIds={userParticipantSet}
firstScoringRoundIdx={scoringRoundIdx}
thirdPlaceRound={thirdPlaceRound}
feeders={feeders}
template={template}
/>
</div>
</>

View file

@ -55,9 +55,7 @@ interface QualifyingPointsStandingsProps {
function formatQP(raw: string): string {
const n = parseFloat(raw);
if (isNaN(n)) return "—";
// At most 2 decimals, trailing zeros trimmed (1.50→1.5). Mirrors the Discord
// embed's formatQPValue (app/services/discord.ts) so the site and Discord agree.
return parseFloat(n.toFixed(2)).toString();
return n % 1 === 0 ? n.toString() : n.toFixed(2);
}
export function QualifyingPointsStandings({

View file

@ -1,18 +1,8 @@
import { cn } from "~/lib/utils";
import type { BracketPhase, BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
import type { FeederMap } from "~/lib/bracket-layout";
import {
TreeColumns,
BracketMatchSlot,
bracketGeometry,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated";
/** Card height for the play-in columns, which lay themselves out rather than via TreeColumns. */
const CARD_H = 112;
interface TabbedBracketLayoutProps {
rounds: string[];
matchesByRound: Map<string, BracketMatch[]>;
@ -20,10 +10,11 @@ interface TabbedBracketLayoutProps {
userParticipantIds: Set<string>;
phases: BracketPhase[];
scoringRoundIdx: number;
feeders?: FeederMap;
template?: BracketTemplate;
}
const CARD_H = 112;
const CARD_GAP = 14;
function groupMatches(
matchesByRound: Map<string, BracketMatch[]>,
group: ConferenceGroup
@ -38,6 +29,11 @@ function groupMatches(
return out;
}
function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (CARD_H + CARD_GAP);
}
// ─── Play-In Layout ───────────────────────────────────────────────────────────
interface PlayInColumnProps {
@ -145,10 +141,7 @@ export function TabbedBracketLayout({
userParticipantIds,
phases,
scoringRoundIdx,
feeders,
template,
}: TabbedBracketLayoutProps) {
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
return (
<div className="space-y-10">
{phases.map((phase) => {
@ -159,22 +152,7 @@ export function TabbedBracketLayout({
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
// Restrict each round to the match numbers this phase's groups actually claim.
// Rounds can be shared across phases (LLWS runs U.S. and International through
// the same rounds), so without this the mobile view would merge both sides into
// one column. No-op where a phase's groups already cover every match in the
// round (NCAA regions, NBA conferences) and for sharedRounds, which have no
// group filter.
const phaseMatchesByRound = new Map(
phaseRounds.map((r) => {
const all = matchesByRound.get(r) ?? [];
if (!phase.groups || sharedRounds.includes(r)) return [r, all] as const;
const allowed = new Set(
phase.groups.flatMap((g) => g.roundMatchNumbers[r] ?? [])
);
return [r, allowed.size > 0 ? all.filter((m) => allowed.has(m.matchNumber)) : all] as const;
})
);
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
@ -201,87 +179,50 @@ export function TabbedBracketLayout({
{phase.groups.map((group) => {
const gMatches = groupMatches(matchesByRound, group);
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
const geometry = bracketGeometry(gRounds, gMatches, feeders, roundOrder);
return (
<div key={group.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
{group.name}
</p>
<div className="w-full overflow-x-auto">
<div style={{ minWidth: geometry.minWidth }}>
<TreeColumns
geometry={geometry}
visibleRounds={gRounds}
matchesByRound={gMatches}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
bracketHeight={phaseHeight(gMatches, gRounds)}
/>
</div>
</div>
</div>
);
})}
{sharedRounds.length > 0 && (
<TreeColumns
geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
visibleRounds={sharedRounds}
matchesByRound={sharedMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
/>
)}
</div>
) : (
<TreeColumns
geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
visibleRounds={phaseRounds}
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
/>
)}
</div>
{/* Mobile paged one group at a time, matching the desktop split. Paging a
whole phase would merge the winners and elimination brackets into one
tree, and a double-elimination phase is a DAG rather than a tree: the
same game feeds forward and sideways, so its column placement would be
arbitrary. */}
<div className="md:hidden space-y-6">
{/* Mobile */}
<div className="md:hidden">
{phase.layout === "play-in" ? (
<PlayInLayout
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
) : phase.groups ? (
<>
{phase.groups.map((group) => (
<div key={group.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
{group.name}
</p>
<BracketTreePaginated
rounds={groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined)}
matchesByRound={groupMatches(matchesByRound, group)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/>
</div>
))}
{sharedRounds.length > 0 && (
<BracketTreePaginated
rounds={sharedRounds}
matchesByRound={sharedMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/>
)}
</>
) : (
<BracketTreePaginated
rounds={phaseRounds}
@ -289,8 +230,6 @@ export function TabbedBracketLayout({
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
feeders={feeders}
template={template}
/>
)}
</div>

View file

@ -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);
});
});

View file

@ -1,16 +1,5 @@
import { describe, it, expect } from "vitest";
import { render, screen, within } from "@testing-library/react";
import {
PlayoffBracket,
groupMatchesByRound,
computeEliminatedByRound,
computeRankedEntries,
findConsolationRound,
roundsWithLosersPlacedLater,
type Match,
} from "../PlayoffBracket";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { resolveLLWSAdvancement } from "~/models/playoff-match";
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
// ---------------------------------------------------------------------------
// Helpers
@ -63,72 +52,88 @@ describe("groupMatchesByRound", () => {
});
// ---------------------------------------------------------------------------
// Rendered LLWS bracket — geometry and empty-slot labels
// buildFeederMap
// ---------------------------------------------------------------------------
describe("PlayoffBracket — rendered LLWS bracket", () => {
const LLWS_ROUNDS = (getBracketTemplate("llws_20")?.rounds ?? []).map((r) => r.name);
/** Every LLWS match, all unplayed, so each slot shows what will fill it. */
function emptyLlwsMatches(): Match[] {
const template = getBracketTemplate("llws_20");
const matches: Match[] = [];
for (const round of template?.rounds ?? []) {
for (let n = 1; n <= round.matchCount; n++) {
matches.push({
...makeMatch(round.name, n, { participant1Id: null, participant2Id: null }),
participant1: null,
participant2: null,
});
}
}
return matches;
}
it("names empty slots after the game that feeds them", () => {
render(
<PlayoffBracket
matches={emptyLlwsMatches()}
rounds={LLWS_ROUNDS}
bracketTemplateId="llws_20"
/>
);
// A winners-bracket loss drops into the elimination bracket — an edge that spans
// two separately rendered trees, so the label is the only way to show it.
expect(screen.getAllByText("Loser of Winners SF 1").length).toBeGreaterThan(0);
expect(screen.getAllByText("Winner of Opening 1").length).toBeGreaterThan(0);
describe("buildFeederMap", () => {
it("returns an empty map when there is only one round", () => {
const matches = [makeMatch("Finals", 1)];
const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]);
expect(map.size).toBe(0);
});
it("still shows TBD for a directly seeded slot", () => {
render(
<PlayoffBracket
matches={emptyLlwsMatches()}
rounds={LLWS_ROUNDS}
bracketTemplateId="llws_20"
/>
);
it("maps SF slots to the correct QF matches for an 8-team bracket", () => {
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
const matches = [
makeMatch("Quarterfinals", 1),
makeMatch("Quarterfinals", 2),
makeMatch("Quarterfinals", 3),
makeMatch("Quarterfinals", 4),
makeMatch("Semifinals", 1),
makeMatch("Semifinals", 2),
makeMatch("Finals", 1),
];
// The opening round is seeded, not fed, so it has nothing to name.
expect(screen.getAllByText("TBD").length).toBeGreaterThan(0);
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
// SF Match 1, slot p1 ← QF Match 1
expect(map.get("Semifinals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
// SF Match 1, slot p2 ← QF Match 2
expect(map.get("Semifinals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
// SF Match 2, slot p1 ← QF Match 3
expect(map.get("Semifinals:2:p1")).toEqual({ round: "Quarterfinals", matchNumber: 3 });
// SF Match 2, slot p2 ← QF Match 4
expect(map.get("Semifinals:2:p2")).toEqual({ round: "Quarterfinals", matchNumber: 4 });
});
it("gives every card the same height, including a lone final", () => {
const { container } = render(
<PlayoffBracket
matches={emptyLlwsMatches()}
rounds={LLWS_ROUNDS}
bracketTemplateId="llws_20"
/>
);
it("maps Finals slots to the correct SF matches", () => {
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
const matches = [
makeMatch("Quarterfinals", 1),
makeMatch("Quarterfinals", 2),
makeMatch("Quarterfinals", 3),
makeMatch("Quarterfinals", 4),
makeMatch("Semifinals", 1),
makeMatch("Semifinals", 2),
makeMatch("Finals", 1),
];
const heights = new Set(
[...container.querySelectorAll<HTMLElement>("[data-match-id]")].map(
(el) => el.style.height
)
);
// Previously a one-match column stretched its card to fill the bracket height.
expect(heights.size).toBe(1);
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 });
expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 });
});
it("does not add an entry when the source match does not exist in the previous round", () => {
const rounds = ["Quarterfinals", "Finals"];
const matches = [
makeMatch("Quarterfinals", 1),
makeMatch("Quarterfinals", 2),
makeMatch("Finals", 1),
];
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
expect(map.get("Finals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
expect(map.get("Finals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
expect(map.has("Finals:2:p1")).toBe(false);
});
it("handles a 16-team bracket correctly for Round of 16 → Quarterfinals", () => {
const rounds = ["Round of 16", "Quarterfinals", "Semifinals", "Finals"];
const matches = [
...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => makeMatch("Round of 16", n)),
...[1, 2, 3, 4].map((n) => makeMatch("Quarterfinals", n)),
...[1, 2].map((n) => makeMatch("Semifinals", n)),
makeMatch("Finals", 1),
];
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
expect(map.get("Quarterfinals:1:p1")).toEqual({ round: "Round of 16", matchNumber: 1 });
expect(map.get("Quarterfinals:1:p2")).toEqual({ round: "Round of 16", matchNumber: 2 });
expect(map.get("Quarterfinals:4:p1")).toEqual({ round: "Round of 16", matchNumber: 7 });
expect(map.get("Quarterfinals:4:p2")).toEqual({ round: "Round of 16", matchNumber: 8 });
});
});
@ -286,526 +291,3 @@ describe("computeEliminatedByRound", () => {
});
});
});
// ---------------------------------------------------------------------------
// computeRankedEntries — consolation ("third place") round handling
// ---------------------------------------------------------------------------
// fifa_48 round order. The Third Place Game sits between the Semifinals (whose
// losers feed it) and the Finals.
const FIFA_ROUNDS = ["Quarterfinals", "Semifinals", "Third Place Game", "Finals"];
const FIFA_CONSOLATION = {
round: "Third Place Game",
feederRound: "Semifinals",
};
type MatchOpts = {
/** Which slot the winner occupies. Defaults to 1. */
winnerSlot?: 1 | 2;
winnerScore?: string;
loserScore?: string;
/**
* Drop the hydrated `winner` relation, keeping `loser` and both ids the shape a
* hand-built match object can arrive in. The loser is still placeable this way.
*/
missingWinnerRelation?: boolean;
};
/** A completed match. */
function makeRankedMatch(
round: string,
matchNumber: number,
winnerId: string,
loserId: string,
opts: MatchOpts = {}
): Match {
const winnerIsP1 = (opts.winnerSlot ?? 1) === 1;
const p1 = winnerIsP1 ? winnerId : loserId;
const p2 = winnerIsP1 ? loserId : winnerId;
return {
id: `${round}-${matchNumber}`,
round,
matchNumber,
participant1Id: p1,
participant2Id: p2,
winnerId,
loserId,
isComplete: true,
participant1Score: (winnerIsP1 ? opts.winnerScore : opts.loserScore) ?? null,
participant2Score: (winnerIsP1 ? opts.loserScore : opts.winnerScore) ?? null,
participant1: { id: p1, name: p1 },
participant2: { id: p2, name: p2 },
winner: opts.missingWinnerRelation ? null : { id: winnerId, name: winnerId },
loser: { id: loserId, name: loserId },
};
}
/**
* A scheduled-but-unplayed match. Bracket rows are pre-generated with their slots
* filled as earlier rounds resolve, so an unplayed 3PG still lists both SF losers.
*/
function makePendingMatch(
round: string,
matchNumber: number,
participant1Id: string | null,
participant2Id: string | null
): Match {
return {
id: `${round}-${matchNumber}`,
round,
matchNumber,
participant1Id,
participant2Id,
winnerId: null,
loserId: null,
isComplete: false,
participant1Score: null,
participant2Score: null,
participant1: participant1Id ? { id: participant1Id, name: participant1Id } : null,
participant2: participant2Id ? { id: participant2Id, name: participant2Id } : null,
winner: null,
loser: null,
};
}
/** A full fifa_48-shaped knockout tail: 4 QF, 2 SF, the 3PG, and the Final. */
function fifaMatches(
overrides: { played?: boolean; thirdPlace?: Match } = {}
): Match[] {
const played = overrides.played ?? true;
return [
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
overrides.thirdPlace ??
(played
? makeRankedMatch("Third Place Game", 1, "sfB", "sfD")
: makePendingMatch("Third Place Game", 1, "sfB", "sfD")),
played
? makeRankedMatch("Finals", 1, "sfA", "sfC")
: makePendingMatch("Finals", 1, "sfA", "sfC"),
];
}
/** Rank the fifa fixture, defaulting to the fifa_48 consolation config. */
function rankFifa(
matches: Match[] = fifaMatches(),
ownership: Map<string, { participantId: string; teamName: string; teamId: string }> = new Map(),
consolation: typeof FIFA_CONSOLATION | undefined = FIFA_CONSOLATION
) {
return computeRankedEntries(
matches,
FIFA_ROUNDS,
groupMatchesByRound(matches),
consolation,
ownership
);
}
function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
return entries.find((e) => e.participant.id === id)?.rankLabel;
}
// ---------------------------------------------------------------------------
// llws_20 — double elimination, where a winners-bracket loss places nobody
// ---------------------------------------------------------------------------
/** Stable participant id for an llws_20 bracket slot. */
function llwsTeam(i: number): string {
return `t${String(i).padStart(2, "0")}`;
}
/**
* Play a full 20-team LLWS tournament, always advancing the lower-numbered
* participant id so the outcome is deterministic, and return every match.
* Routing comes from the real advancement map rather than being hand-listed.
*/
function llwsMatches(): Match[] {
const template = getBracketTemplate("llws_20");
if (!template) throw new Error("llws_20 template missing");
// round → matchNumber → [p1, p2]
const slots = new Map<string, Map<number, [string | null, string | null]>>();
for (const round of template.rounds) {
const byNumber = new Map<number, [string | null, string | null]>();
for (let n = 1; n <= round.matchCount; n++) byNumber.set(n, [null, null]);
slots.set(round.name, byNumber);
}
const put = (round: string, n: number, slot: 0 | 1, id: string) => {
const pair = slots.get(round)?.get(n);
if (pair) pair[slot] = id;
};
// Seed the Opening Round and the four byes, mirroring generateLLWS20Bracket.
for (const [base, roundBase] of [[0, 1], [10, 5]] as const) {
for (let local = 0; local < 4; local++) {
put("Opening Round", roundBase + local, 0, llwsTeam(base + local * 2));
put("Opening Round", roundBase + local, 1, llwsTeam(base + local * 2 + 1));
}
}
put("Winners Round 2", 1, 0, llwsTeam(8));
put("Winners Round 2", 2, 0, llwsTeam(9));
put("Winners Round 2", 3, 0, llwsTeam(18));
put("Winners Round 2", 4, 0, llwsTeam(19));
const matches: Match[] = [];
for (const round of template.rounds) {
for (let n = 1; n <= round.matchCount; n++) {
const [p1, p2] = slots.get(round.name)?.get(n) ?? [null, null];
if (!p1 || !p2) throw new Error(`${round.name} #${n} was not filled`);
// Deterministic: the lower id always wins.
const winnerId = p1 < p2 ? p1 : p2;
const loserId = p1 < p2 ? p2 : p1;
matches.push(
makeRankedMatch(round.name, n, winnerId, loserId, {
winnerSlot: winnerId === p1 ? 1 : 2,
})
);
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
if (winner) put(winner.round, winner.matchNumber, winner.slot === "participant1Id" ? 0 : 1, winnerId);
if (loser) put(loser.round, loser.matchNumber, loser.slot === "participant1Id" ? 0 : 1, loserId);
}
}
return matches;
}
describe("computeRankedEntries — llws_20 double elimination", () => {
const template = getBracketTemplate("llws_20");
const rounds = template?.rounds.map((r) => r.name) ?? [];
function rankLlws() {
const matches = llwsMatches();
const consolation = findConsolationRound(template);
return computeRankedEntries(
matches,
rounds,
groupMatchesByRound(matches),
consolation,
new Map(),
roundsWithLosersPlacedLater(template, consolation)
);
}
it("ranks all 19 non-champions exactly once", () => {
const entries = rankLlws();
expect(entries).toHaveLength(19);
expect(new Set(entries.map((e) => e.participant.id)).size).toBe(19);
});
it("gives the top 8 the positions the scoring tiers depend on", () => {
const entries = rankLlws();
const labels = entries.map((e) => e.rankLabel);
// 2nd (World Championship loser), then 3rd and 4th decided by the consolation
// game, then the two 56 and two 78 tier teams.
expect(labels[0]).toBe("T2");
expect(labels.filter((l) => l === "3")).toHaveLength(1);
expect(labels.filter((l) => l === "4")).toHaveLength(1);
expect(labels.filter((l) => l === "T5")).toHaveLength(2);
expect(labels.filter((l) => l === "T7")).toHaveLength(2);
});
it("does not inflate positions below the top 8", () => {
// Winners-bracket losses place nobody — those teams are ranked by the
// elimination-bracket game that actually knocks them out. If the winners
// rounds consumed positions, the last tier would read T23 in a 20-team field.
const entries = rankLlws();
const labels = entries.map((e) => e.rankLabel);
expect(labels.filter((l) => l === "T9")).toHaveLength(4);
expect(labels.filter((l) => l === "T13")).toHaveLength(4);
expect(labels.filter((l) => l === "T17")).toHaveLength(4);
// 1 champion (not in the list) + 19 ranked = the full 20-team field.
expect(labels.some((l) => Number(l.replace("T", "")) > 17)).toBe(false);
});
it("never ranks a winners-bracket loser at the round they first lost", () => {
const entries = rankLlws();
// t00 wins every game it plays (lowest id), so take a team that loses in the
// winners bracket but survives: the Opening Round M1 loser, t01.
const t01 = entries.find((e) => e.participant.id === "t01");
expect(t01).toBeDefined();
// Losing the opening game must not park them in the bottom tier — they got a
// second life in the elimination bracket.
expect(t01?.rankLabel).not.toBe("T17");
});
});
describe("findConsolationRound", () => {
it("identifies the fifa_48 third place game and the round that feeds it", () => {
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
round: "Third Place Game",
feederRound: "Semifinals",
});
});
it("returns undefined for a template with no consolation round", () => {
expect(findConsolationRound(getBracketTemplate("ncaa_64"))).toBeUndefined();
});
it("returns undefined when there is no template", () => {
expect(findConsolationRound(undefined)).toBeUndefined();
});
it("ignores double-elimination loser routing and finds the real consolation game", () => {
// llws_20 sets loserFeedsInto on every winners-bracket round to route losers
// into the elimination bracket. Only the Bracket Championship feeds a terminal
// round; taking the first loserFeedsInto instead would mistake "Elimination
// Round 1" for a third-place game and corrupt the final rankings.
expect(findConsolationRound(getBracketTemplate("llws_20"))).toEqual({
round: "Consolation Third Place",
feederRound: "Bracket Championship",
});
});
});
describe("computeRankedEntries", () => {
describe("fifa_48 third place game", () => {
it("ranks the third place game winner 3rd — they never lose a match after the SF", () => {
// sfB lost the semifinal, then won the 3PG.
expect(rankOf(rankFifa(), "sfB")).toBe("3");
});
it("ranks the third place game loser 4th, not 3rd", () => {
expect(rankOf(rankFifa(), "sfD")).toBe("4");
});
it("gives quarterfinal losers T5 — the 3PG consumes no positions of its own", () => {
const entries = rankFifa();
expect(rankOf(entries, "qf1")).toBe("T5");
expect(rankOf(entries, "qf2")).toBe("T5");
expect(rankOf(entries, "qf3")).toBe("T5");
expect(rankOf(entries, "qf4")).toBe("T5");
});
it("ranks the finals loser 2nd and leaves the champion out of the list", () => {
const entries = rankFifa();
expect(rankOf(entries, "sfC")).toBe("T2");
expect(rankOf(entries, "sfA")).toBeUndefined();
});
it("orders the list by rank: 2nd, 3rd, 4th, then the T5 tier", () => {
expect(rankFifa().map((e) => e.rankLabel)).toEqual([
"T2",
"3",
"4",
"T5",
"T5",
"T5",
"T5",
]);
});
it("leaves semifinal losers unranked until the third place game is played", () => {
const entries = rankFifa(fifaMatches({ played: false }));
// Both SF losers are still alive for the 3PG.
expect(rankOf(entries, "sfB")).toBeUndefined();
expect(rankOf(entries, "sfD")).toBeUndefined();
// QF losers are still T5 — the later rounds still consume their positions.
expect(rankOf(entries, "qf1")).toBe("T5");
});
it("carries ownership through onto the third place entries", () => {
const ownership = new Map([
["sfB", { participantId: "sfB", teamName: "Team Nine", teamId: "t9" }],
]);
const entries = rankFifa(fifaMatches(), ownership);
expect(entries.find((e) => e.participant.id === "sfB")?.ownership?.teamName).toBe(
"Team Nine"
);
expect(entries.find((e) => e.participant.id === "sfD")?.ownership).toBeNull();
});
});
describe("scores", () => {
it("reads each participant's own score regardless of which slot they occupied", () => {
const matches = fifaMatches({
// Winner sits in slot 2 this time, so a slot-blind lookup would swap the scores.
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
winnerSlot: 2,
winnerScore: "3",
loserScore: "1",
}),
});
const entries = rankFifa(matches);
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("3");
expect(entries.find((e) => e.participant.id === "sfD")?.score).toBe("1");
});
it("reads a loser's score from the slot they actually played in", () => {
const matches: Match[] = [
makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
winnerSlot: 2,
winnerScore: "4",
loserScore: "2",
}),
];
const entries = computeRankedEntries(
matches,
["Semifinals"],
groupMatchesByRound(matches),
undefined,
new Map()
);
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("2");
});
it("reports no score for a participant who occupies neither slot", () => {
// A stale row after a bracket edit: loserId no longer matches either slot.
// Attributing the other team's score here would look entirely plausible.
const stale: Match = {
...makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
winnerScore: "4",
loserScore: "2",
}),
loserId: "ghost",
loser: { id: "ghost", name: "ghost" },
};
const entries = computeRankedEntries(
[stale],
["Semifinals"],
groupMatchesByRound([stale]),
undefined,
new Map()
);
expect(entries.find((e) => e.participant.id === "ghost")?.score).toBeNull();
});
});
describe("a consolation round somewhere other than 3rd/4th", () => {
// No template ships this today, but the positions must come from the feeder
// round rather than being hardcoded to 3 and 4.
const ROUNDS = ["Quarterfinals", "Semifinals", "Fifth Place Game", "Finals"];
const CONSOLATION = { round: "Fifth Place Game", feederRound: "Quarterfinals" };
const matches: Match[] = [
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
makeRankedMatch("Fifth Place Game", 1, "qf1", "qf2"),
makeRankedMatch("Finals", 1, "sfA", "sfC"),
];
it("places the consolation pair at its feeder round's tier, not at 3rd and 4th", () => {
const entries = computeRankedEntries(
matches,
ROUNDS,
groupMatchesByRound(matches),
CONSOLATION,
new Map()
);
expect(rankOf(entries, "qf1")).toBe("5");
expect(rankOf(entries, "qf2")).toBe("6");
// The feeder round's other losers start below the pair, not alongside them.
expect(rankOf(entries, "qf3")).toBe("T7");
expect(rankOf(entries, "qf4")).toBe("T7");
// The rounds above it are unaffected.
expect(rankOf(entries, "sfC")).toBe("T2");
expect(rankOf(entries, "sfB")).toBe("T3");
});
});
describe("consolation matches that cannot be placed exactly", () => {
it("still ranks the loser when the winner relation is missing", () => {
const matches = fifaMatches({
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
missingWinnerRelation: true,
}),
});
const entries = rankFifa(matches);
// The winner cannot be placed without a participant object, but the loser must
// not silently vanish the way it would if the round were skipped wholesale.
expect(rankOf(entries, "sfD")).toBeDefined();
});
it("falls back to the loser-driven walk when the feeder round has no matches", () => {
const matches: Match[] = [
makeRankedMatch("Third Place Game", 1, "sfB", "sfD"),
makeRankedMatch("Finals", 1, "sfA", "sfC"),
];
const entries = computeRankedEntries(
matches,
["Third Place Game", "Finals"],
groupMatchesByRound(matches),
FIFA_CONSOLATION,
new Map()
);
expect(rankOf(entries, "sfC")).toBe("T2");
expect(rankOf(entries, "sfD")).toBeDefined();
});
});
describe("rendered output", () => {
/** The card the 3PG winner was incorrectly appearing in. */
function inContentionNames() {
const card = screen.queryByText("In Contention")?.closest('[data-slot="card"]');
if (!card) return [];
return within(card as HTMLElement)
.getAllByRole("row")
.map((r) => r.textContent ?? "");
}
it("does not list the third place game winner as in contention", () => {
render(
<PlayoffBracket matches={fifaMatches()} rounds={FIFA_ROUNDS} bracketTemplateId="fifa_48" />
);
// sfB won the third place game — they are finished, not still playing.
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(false);
});
it("still lists semifinalists as in contention before the third place game", () => {
render(
<PlayoffBracket
matches={fifaMatches({ played: false })}
rounds={FIFA_ROUNDS}
bracketTemplateId="fifa_48"
/>
);
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(true);
});
});
describe("brackets without a consolation round", () => {
const ROUNDS = ["Quarterfinals", "Semifinals", "Finals"];
it("ranks losers by round with tie labels, unchanged", () => {
const matches: Match[] = [
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
makeRankedMatch("Finals", 1, "sfA", "sfC"),
];
const entries = computeRankedEntries(
matches,
ROUNDS,
groupMatchesByRound(matches),
undefined,
new Map()
);
expect(rankOf(entries, "sfC")).toBe("T2");
expect(rankOf(entries, "sfB")).toBe("T3");
expect(rankOf(entries, "sfD")).toBe("T3");
expect(rankOf(entries, "qf1")).toBe("T5");
expect(rankOf(entries, "sfA")).toBeUndefined();
});
});
});

View file

@ -1,26 +1,18 @@
import * as Sentry from "@sentry/react-router";
import { PassThrough } from "node:stream";
import { logger } from "~/lib/logger";
import { shouldReportServerError } from "~/lib/error-reporting";
import type { AppLoadContext, EntryContext, HandleErrorFunction } from "react-router";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
const sentryHandleError = Sentry.createSentryHandleError({
export const handleError = Sentry.createSentryHandleError({
logErrors: true,
});
export const handleError: HandleErrorFunction = (error, args) => {
// Unrecognised URLs and methods are bot scans, not bugs. Skipping early also
// keeps them out of the `logErrors` console output; morgan still logs the request.
if (!shouldReportServerError(error, args.request)) return;
return sentryHandleError(error, args);
};
export const streamTimeout = 5_000;
async function handleRequest(

View file

@ -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/
);
});
});

View file

@ -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");
});
});

View file

@ -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);
});
});

View file

@ -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;
}

View file

@ -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 };
}

View file

@ -19,32 +19,6 @@ export interface BracketRound {
* When set, the loser of each match in this round is placed into the target round.
*/
loserFeedsInto?: string | null;
/**
* Floor position banked by the WINNER of a *non-scoring* round.
*
* Omit for the default behavior: winners entering the first scoring round bank a
* T5T8 floor (position 5), everyone else banks nothing. Set an explicit number when
* that default is wrong in a double-elimination losers bracket a win can guarantee
* a worse finish than 5th (llws_20 "Elimination Round 3" 7). Set null to bank no
* floor even though the next round scores.
*
* Has no effect on scoring rounds, which use RoundScoringConfig.winnerFloor instead.
*/
nonScoringWinnerFloor?: number | null;
/**
* Floor position every team is guaranteed simply by being *seeded into* this
* round when the bracket is generated before a single match is played.
*
* Omit (the default) for rounds where entering guarantees nothing: a team that
* loses its first match earns 0. Set a number when the bracket structure locks
* in a scoring tier on entry e.g. afl_10's Qualifying Finals, where the loser
* still gets a Semi-Final and so cannot finish worse than the 5th-6th tier.
*
* Only teams actually assigned to a match slot at generation receive this floor;
* TBD slots filled later by advancing winners get their floor from the round they
* won (nonScoringWinnerFloor / RoundScoringConfig.winnerFloor) instead.
*/
entryFloor?: number | null;
}
export interface GroupStageConfig {
@ -703,8 +677,7 @@ export const NFL_14: BracketTemplate = {
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
* - Week 1 Finals:
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
* - Elimination Finals: the two Wildcard winners are re-seeded by ladder position, so
* 5th hosts the lower-ranked winner and 6th the higher-ranked one (losers share 7th-8th)
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th)
* - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th)
* - Week 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
* - Week 4: Grand Final (1st vs 2nd)
@ -722,32 +695,18 @@ export const AFL_10: BracketTemplate = {
matchCount: 2,
feedsInto: "Elimination Finals",
isScoring: false, // Losers get 0 points (9th-10th)
// A Wildcard win only buys an Elimination Final; losing that is the 7th-8th
// tier, so the winner banks 7 — not the generic "entering a scoring round
// means top-8" default of 5, which would over-award them a 5th-6th floor.
nonScoringWinnerFloor: 7,
},
{
name: "Qualifying Finals",
matchCount: 2,
feedsInto: "Preliminary Finals", // Winners get bye
isScoring: false, // Losers get second chance (go to Semi-Finals)
// Seeds 1-4 have the double chance from the moment the bracket is drawn:
// lose the QF, lose the Semi-Final, and you still finish in the 5th-6th tier.
entryFloor: 5,
// Winning the QF is a bye straight to a Preliminary Final; losing that is the
// 3rd-4th tier, so the winner's floor is 3 rather than the generic default of 5.
nonScoringWinnerFloor: 3,
},
{
name: "Elimination Finals",
matchCount: 2,
feedsInto: "Semi-Finals",
isScoring: true, // Losers share 7th-8th
// Seeds 5-6 are seeded straight into this round, so the 7th-8th tier is
// locked in for them at generation. (The other slot is a TBD Wildcard winner,
// who banks the same floor by winning the Wildcard Round.)
entryFloor: 7,
},
{
name: "Semi-Finals",
@ -975,258 +934,6 @@ export const NBA_20: BracketTemplate = {
],
};
// ── LLWS 20 ───────────────────────────────────────────────────────────────────
/** Side-local match numbers → global match numbers, per round shape. */
const LLWS_OPENING_OFFSET = 4; // Opening Round: US M14, Intl M58
const LLWS_PAIR_OFFSET = 2; // 4-match rounds: US M12, Intl M34
const LLWS_SOLO_OFFSET = 1; // 2-match rounds: US M1, Intl M2
/** Rounds with 4 matches (2 per side). Opening Round has 8; the rest have 2. */
export const LLWS_FOUR_MATCH_ROUNDS = new Set([
"Winners Round 2",
"Elimination Round 1",
"Winners Semifinals",
"Elimination Round 2",
"Elimination Round 3",
]);
/**
* Returns the global match number for a side-local match in an LLWS round.
* side 0 = United States, side 1 = International.
*/
export function llwsMatchNumber(round: string, side: 0 | 1, localMatch: number): number {
const offset =
round === "Opening Round"
? LLWS_OPENING_OFFSET
: LLWS_FOUR_MATCH_ROUNDS.has(round)
? LLWS_PAIR_OFFSET
: LLWS_SOLO_OFFSET;
return localMatch + side * offset;
}
/**
* Inverse of llwsMatchNumber: global match number { side, localMatch }.
*/
export function llwsSideAndLocal(
round: string,
matchNumber: number
): { side: 0 | 1; localMatch: number } {
const offset =
round === "Opening Round"
? LLWS_OPENING_OFFSET
: LLWS_FOUR_MATCH_ROUNDS.has(round)
? LLWS_PAIR_OFFSET
: LLWS_SOLO_OFFSET;
const side: 0 | 1 = matchNumber > offset ? 1 : 0;
return { side, localMatch: matchNumber - side * offset };
}
/**
* Little League Baseball World Series (20 teams, 2025+ double-elimination format)
*
* Two independent 10-team double-elimination brackets United States and
* International each producing a side champion, then a World Championship game and
* a Consolation Third Place game between the two side runners-up.
*
* Rounds are shared across both sides: U.S. matches take the low match numbers and
* International the high ones (see llwsMatchNumber). The phases/groups config splits
* them back apart for display.
*
* A loss in the winners bracket is NOT an elimination it drops the team into the
* elimination bracket at a specific slot (see advanceLLWSWinner in models/playoff-match).
* A loss in the elimination bracket is final.
*
* There is deliberately NO "if necessary" game: the winners-bracket champion is out if
* it loses the Bracket Championship, dropping to the Consolation game rather than
* forcing a rematch. This is the official LLWS modified double-elimination format.
*
* Placement tiers (only 8 teams score the field is exactly 8 when Elim R4 begins):
* 1st / 2nd World Championship
* 3rd / 4th Consolation Third Place (real game, so positions are distinct)
* 5th / 6th Elimination Final losers
* 7th / 8th Elimination Round 4 losers
* 0 pts the 12 teams eliminated in Elimination Rounds 13
*
* Participant array layout (20 slots):
* [07] U.S. Opening Round teams, two per game (M1..M4)
* [8, 9] U.S. bye teams, entering Winners Round 2 M1 / M2 at participant1
* [1017] International Opening Round teams, two per game (M5..M8)
* [18,19] International bye teams, entering Winners Round 2 M3 / M4 at participant1
*/
export const LLWS_20: BracketTemplate = {
id: "llws_20",
name: "Little League World Series (20 teams)",
totalTeams: 20,
scoringStartsAtRound: "Winners Final",
// Ordered by the real schedule so non-phased views read chronologically.
rounds: [
{
name: "Opening Round",
matchCount: 8,
feedsInto: "Winners Round 2",
isScoring: false,
loserFeedsInto: "Elimination Round 1",
nonScoringWinnerFloor: null, // 16 teams still alive — nothing guaranteed
},
{
name: "Winners Round 2",
matchCount: 4,
feedsInto: "Winners Semifinals",
isScoring: false,
loserFeedsInto: "Elimination Round 2",
nonScoringWinnerFloor: null,
},
{
name: "Elimination Round 1",
matchCount: 4,
feedsInto: "Elimination Round 2",
isScoring: false, // losers finish 13th16th
nonScoringWinnerFloor: null,
},
{
name: "Winners Semifinals",
matchCount: 4,
feedsInto: "Winners Final",
isScoring: false,
loserFeedsInto: "Elimination Round 3",
// Reaching the Winners Final guarantees at worst 5th (lose it, then lose the
// Elimination Final). Same value as the engine default, stated explicitly.
nonScoringWinnerFloor: 5,
},
{
name: "Elimination Round 2",
matchCount: 4,
feedsInto: "Elimination Round 3",
isScoring: false, // losers finish 11th12th
nonScoringWinnerFloor: null,
},
{
name: "Elimination Round 3",
matchCount: 4,
feedsInto: "Elimination Round 4",
isScoring: false, // losers finish 9th10th
// Winners reach Elimination Round 4, where a loss is 7th — not 5th.
nonScoringWinnerFloor: 7,
},
{
name: "Winners Final",
matchCount: 2,
feedsInto: "Bracket Championship",
isScoring: true, // loser drops to the Elimination Final (provisional 5th)
loserFeedsInto: "Elimination Final",
},
{
name: "Elimination Round 4",
matchCount: 2,
feedsInto: "Elimination Final",
isScoring: true, // losers share 7th8th
},
{
name: "Elimination Final",
matchCount: 2,
feedsInto: "Bracket Championship",
isScoring: true, // losers share 5th6th
},
{
name: "Bracket Championship",
matchCount: 2,
feedsInto: "World Championship",
isScoring: true, // loser drops to the Consolation game (provisional 4th)
loserFeedsInto: "Consolation Third Place",
},
{
name: "Consolation Third Place",
matchCount: 1,
feedsInto: null,
isScoring: true, // winner 3rd, loser 4th
},
{
name: "World Championship",
matchCount: 1,
feedsInto: null,
isScoring: true, // winner 1st, loser 2nd
},
],
// Region assignments rotate year to year (which region draws the bye changes), so
// these are positional slot labels rather than region names. Kept short — the admin
// form renders them in a narrow fixed-width column alongside each participant picker.
participantLabels: [
"US G1 Home", "US G1 Away",
"US G2 Home", "US G2 Away",
"US G3 Home", "US G3 Away",
"US G4 Home", "US G4 Away",
"US Bye 1", "US Bye 2",
"Intl G1 Home", "Intl G1 Away",
"Intl G2 Home", "Intl G2 Away",
"Intl G3 Home", "Intl G3 Away",
"Intl G4 Home", "Intl G4 Away",
"Intl Bye 1", "Intl Bye 2",
],
phases: [
{
name: "United States",
groups: [
{
name: "U.S. Winner's Bracket",
roundMatchNumbers: {
"Opening Round": [1, 2, 3, 4],
"Winners Round 2": [1, 2],
"Winners Semifinals": [1, 2],
"Winners Final": [1],
},
},
{
name: "U.S. Elimination Bracket",
roundMatchNumbers: {
"Elimination Round 1": [1, 2],
"Elimination Round 2": [1, 2],
"Elimination Round 3": [1, 2],
"Elimination Round 4": [1],
"Elimination Final": [1],
},
},
{
name: "U.S. Championship",
roundMatchNumbers: { "Bracket Championship": [1] },
},
],
},
{
name: "International",
groups: [
{
name: "International Winner's Bracket",
roundMatchNumbers: {
"Opening Round": [5, 6, 7, 8],
"Winners Round 2": [3, 4],
"Winners Semifinals": [3, 4],
"Winners Final": [2],
},
},
{
name: "International Elimination Bracket",
roundMatchNumbers: {
"Elimination Round 1": [3, 4],
"Elimination Round 2": [3, 4],
"Elimination Round 3": [3, 4],
"Elimination Round 4": [2],
"Elimination Final": [2],
},
},
{
name: "International Championship",
roundMatchNumbers: { "Bracket Championship": [2] },
},
],
},
{
name: "Championship",
rounds: ["Consolation Third Place", "World Championship"],
},
],
};
/**
* All available bracket templates
*/
@ -1244,7 +951,6 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
tennis_128: TENNIS_128,
cfp_12: CFP_12,
nba_20: NBA_20,
llws_20: LLWS_20,
};
/**
@ -1300,18 +1006,6 @@ export function getScoringRoundType(
const round = template.rounds.find((r) => r.name === roundName);
if (!round || !round.isScoring) return null;
// Special handling for LLWS double elimination: match counts don't identify the
// tier (Elimination Round 4 and the Elimination Final both have 2 matches), and
// the Winners Final eliminates nobody.
if (template.id === "llws_20") {
if (roundName === "Elimination Round 4") return "quarterfinals"; // losers share 7-8th
if (roundName === "Elimination Final") return "quarterfinals"; // losers share 5-6th
if (roundName === "Bracket Championship") return "semifinals"; // losers play for 3-4th
if (roundName === "Consolation Third Place") return "semifinals"; // finalizes 3rd/4th
if (roundName === "World Championship") return "finals"; // 1st and 2nd
return null; // Winners Final: loser drops to the elimination bracket, nobody is out
}
// Special handling for AFL finals
if (template.id === "afl_10") {
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th

View file

@ -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);
}

View file

@ -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) };
}

View file

@ -7,8 +7,7 @@
import { describe, it, expect } from "vitest";
import { AFL_10, getScoringRoundType } from "~/lib/bracket-templates";
import { calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints, type ScoringRules } from "../scoring-rules";
import { getBracketEntryFloor } from "../scoring-calculator";
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
const DEFAULT_SCORING: ScoringRules = {
pointsFor1st: 100,
@ -207,69 +206,3 @@ describe("AFL Finals System - Phase 3.3", () => {
});
});
});
describe("AFL guaranteed floors from seeding (afl_10)", () => {
const byName = (name: string) => AFL_10.rounds.find((r) => r.name === name);
describe("getBracketEntryFloor — banked the moment the bracket is set", () => {
it("gives seeds 1-4 the 5th-6th tier: the double chance is locked in at seeding", () => {
// Worst case for a top-4 seed is lose the Qualifying Final, then lose the
// Semi-Final — which is the 5th-6th tier. They can never finish below it.
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
expect(calculateBracketPoints(5, DEFAULT_SCORING, "afl_10")).toBe(25);
});
it("gives seeds 5-6 the 7th-8th tier: they are seeded straight into a scoring round", () => {
expect(getBracketEntryFloor("Elimination Finals", "afl_10")).toBe(7);
expect(calculateBracketPoints(7, DEFAULT_SCORING, "afl_10")).toBe(15);
});
it("gives seeds 7-10 nothing: a Wildcard loss is worth 0", () => {
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
});
it("returns null when the template is unknown or missing", () => {
expect(getBracketEntryFloor("Qualifying Finals", null)).toBeNull();
expect(getBracketEntryFloor("Qualifying Finals", "not_a_template")).toBeNull();
});
it("does not hand out floors for TBD rounds nobody is seeded into yet", () => {
// These rounds do carry a loser tier, but every slot is empty at generation,
// so applyBracketEntryFloors has no participant to write against.
expect(byName("Semi-Finals")?.entryFloor).toBeUndefined();
expect(byName("Preliminary Finals")?.entryFloor).toBeUndefined();
});
});
describe("nonScoringWinnerFloor — the generic top-8 default is wrong for both AFL non-scoring rounds", () => {
it("Qualifying Finals winners bank 3, not 5 — the bye means a Prelim loss is 3rd-4th", () => {
expect(byName("Qualifying Finals")?.nonScoringWinnerFloor).toBe(3);
});
it("Wildcard winners bank 7, not 5 — winning only buys an Elimination Final", () => {
expect(byName("Wildcard Round")?.nonScoringWinnerFloor).toBe(7);
});
});
describe("floors only ever improve along every AFL path", () => {
const pts = (position: number) => calculateBracketPoints(position, DEFAULT_SCORING, "afl_10");
it("top-4 seed: entry 5 → QF win 3 → PF win 2 → GF win 1", () => {
expect(pts(5)).toBeLessThan(pts(3));
expect(pts(3)).toBeLessThan(pts(2));
expect(pts(2)).toBeLessThan(pts(1));
});
it("top-4 seed losing the QF holds the entry floor, then finalizes at 5th-6th", () => {
// QF losers advance to the Semi-Final, so nothing is written at the QF —
// the entry floor of 5 carries them until the Semi-Final resolves.
const entryFloor = getBracketEntryFloor("Qualifying Finals", "afl_10");
expect(entryFloor).toBe(5);
expect(pts(entryFloor ?? 0)).toBe(25); // unchanged by the loss
});
it("seeds 5-6 and Wildcard winners share a 7th-8th floor, below the top-4's", () => {
expect(pts(7)).toBeLessThan(pts(5));
});
});
});

View file

@ -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"
);
});
});

View file

@ -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));
});
});

View file

@ -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();
});
});

View file

@ -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 5th8th average when the
* template id is null, so losing "llws_20" makes a team locked into 5th6th and one
* locked into 7th8th 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();
});
});

View file

@ -1,481 +0,0 @@
/**
* LLWS 20-Team Double-Elimination Bracket Tests
*
* Verifies the llws_20 template against the official 2026 LLBWS bracket
* (Williamsport, Aug 1930). The PDF numbers its games 138; those numbers appear
* throughout as `G<n>` so the routing can be checked against the printed bracket.
*
* The critical property under test is the double-elimination loser routing: a loss in
* the winners bracket drops a team into the elimination bracket at a specific slot,
* while a loss in the elimination bracket is final.
*/
import { describe, it, expect, vi } from "vitest";
import {
LLWS_20,
getScoringRoundType,
llwsMatchNumber,
llwsSideAndLocal,
} from "~/lib/bracket-templates";
import {
doesLoserAdvance,
generateBracketFromTemplate,
resolveLLWSAdvancement,
} from "../playoff-match";
import {
calculateBracketPoints,
calculateAveragedPoints,
type ScoringRules,
} from "../scoring-rules";
import {
GAME_TO_MATCH,
EXPECTED_SLOTS,
gameNumberFor,
required,
destinationGame,
} from "~/test/fixtures/llws-bracket";
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
// minimal stub is enough to capture the generated rows.
const insertedRows: Record<string, unknown>[] = [];
vi.mock("~/database/context", () => ({
database: () => ({
insert: () => ({
values: (rows: Record<string, unknown>[]) => ({
returning: async () => {
insertedRows.push(...rows);
return rows;
},
}),
}),
}),
}));
const DEFAULT_SCORING: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 20,
pointsFor7th: 15,
pointsFor8th: 10,
};
describe("LLWS 20 Bracket Template", () => {
describe("Template structure", () => {
it("has correct identity and size", () => {
expect(LLWS_20.id).toBe("llws_20");
expect(LLWS_20.totalTeams).toBe(20);
expect(LLWS_20.scoringStartsAtRound).toBe("Winners Final");
});
it("has 12 rounds totalling 38 matches", () => {
expect(LLWS_20.rounds).toHaveLength(12);
const total = LLWS_20.rounds.reduce((sum, r) => sum + r.matchCount, 0);
expect(total).toBe(38);
});
it("has the expected match count per round", () => {
const counts = Object.fromEntries(
LLWS_20.rounds.map((r) => [r.name, r.matchCount])
);
expect(counts).toEqual({
"Opening Round": 8,
"Winners Round 2": 4,
"Elimination Round 1": 4,
"Winners Semifinals": 4,
"Elimination Round 2": 4,
"Elimination Round 3": 4,
"Winners Final": 2,
"Elimination Round 4": 2,
"Elimination Final": 2,
"Bracket Championship": 2,
"Consolation Third Place": 1,
"World Championship": 1,
});
});
it("marks exactly the point-awarding rounds as scoring", () => {
const scoring = LLWS_20.rounds.filter((r) => r.isScoring).map((r) => r.name);
expect(scoring).toEqual([
"Winners Final",
"Elimination Round 4",
"Elimination Final",
"Bracket Championship",
"Consolation Third Place",
"World Championship",
]);
});
it("lists rounds in chronological order", () => {
// Elimination Round 1 (Aug 22) is played before Winners Semifinals (Aug 23).
const names = LLWS_20.rounds.map((r) => r.name);
expect(names.indexOf("Elimination Round 1")).toBeLessThan(
names.indexOf("Winners Semifinals")
);
expect(names.indexOf("Winners Final")).toBeLessThan(
names.indexOf("Elimination Final")
);
});
it("gives elimination-bracket winners a floor matching their real worst case", () => {
const byName = (n: string) => LLWS_20.rounds.find((r) => r.name === n);
// Winning Elim R3 only guarantees 7th (a loss in Elim R4 is the 78 tier),
// so the engine's default floor of 5 would overstate it.
expect(byName("Elimination Round 3")?.nonScoringWinnerFloor).toBe(7);
// Reaching the Winners Final guarantees 5th at worst.
expect(byName("Winners Semifinals")?.nonScoringWinnerFloor).toBe(5);
// Nothing is guaranteed earlier than that.
expect(byName("Opening Round")?.nonScoringWinnerFloor).toBeNull();
expect(byName("Winners Round 2")?.nonScoringWinnerFloor).toBeNull();
expect(byName("Elimination Round 1")?.nonScoringWinnerFloor).toBeNull();
expect(byName("Elimination Round 2")?.nonScoringWinnerFloor).toBeNull();
});
it("has 20 participant labels", () => {
expect(LLWS_20.participantLabels).toHaveLength(20);
});
it("splits display into U.S., International and Championship phases", () => {
expect(LLWS_20.phases?.map((p) => p.name)).toEqual([
"United States",
"International",
"Championship",
]);
});
it("assigns every match to exactly one phase group", () => {
const claimed = new Map<string, number>();
for (const phase of LLWS_20.phases ?? []) {
for (const group of phase.groups ?? []) {
for (const [round, numbers] of Object.entries(group.roundMatchNumbers)) {
for (const n of numbers) {
const key = `${round}#${n}`;
claimed.set(key, (claimed.get(key) ?? 0) + 1);
}
}
}
}
// Every per-side match claimed exactly once (36 games; the 2 finals live in
// the Championship phase's plain round list, not in a group).
expect(claimed.size).toBe(36);
expect([...claimed.values()].every((c) => c === 1)).toBe(true);
});
});
describe("Bracket generation", () => {
const PARTICIPANTS = Array.from({ length: 20 }, (_, i) => `team-${i}`);
async function generate() {
insertedRows.length = 0;
await generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS);
return insertedRows.map((r) => ({
round: r.round as string,
matchNumber: r.matchNumber as number,
participant1Id: (r.participant1Id ?? null) as string | null,
participant2Id: (r.participant2Id ?? null) as string | null,
isScoring: r.isScoring as boolean,
}));
}
it("creates all 38 matches", async () => {
const rows = await generate();
expect(rows).toHaveLength(38);
});
it("creates the right number of matches per round", async () => {
const rows = await generate();
for (const round of LLWS_20.rounds) {
expect(
rows.filter((r) => r.round === round.name),
`${round.name} match count`
).toHaveLength(round.matchCount);
}
});
it("numbers matches 1..n within each round", async () => {
const rows = await generate();
for (const round of LLWS_20.rounds) {
const numbers = rows
.filter((r) => r.round === round.name)
.map((r) => r.matchNumber)
.toSorted((a, b) => a - b);
expect(numbers).toEqual(
Array.from({ length: round.matchCount }, (_, i) => i + 1)
);
}
});
it("seeds the Opening Round two teams at a time, U.S. then International", async () => {
const rows = await generate();
const opening = rows
.filter((r) => r.round === "Opening Round")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
// U.S. slots 07 fill matches 14; International slots 1017 fill matches 58.
expect(opening.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
["team-0", "team-1"],
["team-2", "team-3"],
["team-4", "team-5"],
["team-6", "team-7"],
["team-10", "team-11"],
["team-12", "team-13"],
["team-14", "team-15"],
["team-16", "team-17"],
]);
});
it("seats the four bye teams in Winners Round 2 awaiting an opponent", async () => {
const rows = await generate();
const wr2 = rows
.filter((r) => r.round === "Winners Round 2")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
expect(wr2.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
["team-8", null],
["team-9", null],
["team-18", null],
["team-19", null],
]);
});
it("uses each participant exactly once and leaves every other slot empty", async () => {
const rows = await generate();
const seeded = rows
.flatMap((r) => [r.participant1Id, r.participant2Id])
.filter((id): id is string => id !== null);
expect(seeded).toHaveLength(20);
expect(new Set(seeded).size).toBe(20);
expect(new Set(seeded)).toEqual(new Set(PARTICIPANTS));
});
it("stamps isScoring from the template", async () => {
const rows = await generate();
for (const round of LLWS_20.rounds) {
for (const row of rows.filter((r) => r.round === round.name)) {
expect(row.isScoring, `${round.name} #${row.matchNumber}`).toBe(round.isScoring);
}
}
});
it("rejects a participant count other than 20", async () => {
await expect(
generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS.slice(0, 19))
).rejects.toThrow(/requires 20 participants/);
});
});
describe("Side / match-number mapping", () => {
it("round-trips every match number through side-local form", () => {
for (const round of LLWS_20.rounds) {
if (round.matchCount === 1) continue; // shared finals have no side
for (let n = 1; n <= round.matchCount; n++) {
const { side, localMatch } = llwsSideAndLocal(round.name, n);
expect(llwsMatchNumber(round.name, side, localMatch)).toBe(n);
}
}
});
it("puts U.S. matches in the low half and International in the high half", () => {
expect(llwsSideAndLocal("Opening Round", 4).side).toBe(0);
expect(llwsSideAndLocal("Opening Round", 5).side).toBe(1);
expect(llwsSideAndLocal("Winners Semifinals", 2).side).toBe(0);
expect(llwsSideAndLocal("Winners Semifinals", 3).side).toBe(1);
expect(llwsSideAndLocal("Winners Final", 1).side).toBe(0);
expect(llwsSideAndLocal("Winners Final", 2).side).toBe(1);
});
});
describe("Advancement matches the official bracket", () => {
/**
* Replay the whole tournament through resolveLLWSAdvancement and record which
* feed label ends up in each slot, then compare against the printed bracket.
*/
const actualSlots: Record<number, [string | null, string | null]> = {};
for (const game of Object.keys(EXPECTED_SLOTS)) {
actualSlots[Number(game)] = [null, null];
}
for (const [gameStr, { round, matchNumber }] of Object.entries(GAME_TO_MATCH)) {
const game = Number(gameStr);
const { winner, loser } = resolveLLWSAdvancement(round, matchNumber);
for (const [dest, label] of [
[winner, `W${game}`],
[loser, `L${game}`],
] as const) {
if (!dest) continue;
const targetGame = gameNumberFor(dest.round, dest.matchNumber);
const slotIndex = dest.slot === "participant1Id" ? 0 : 1;
actualSlots[targetGame][slotIndex] = label;
}
}
it.each(Object.keys(EXPECTED_SLOTS).map(Number).toSorted((a, b) => a - b))(
"Game %i has the printed participants",
(game) => {
expect(actualSlots[game]).toEqual(EXPECTED_SLOTS[game]);
}
);
it("fills every slot in the bracket exactly once", () => {
// 38 games × 2 slots = 76. 20 are seeded directly (16 opening teams + 4 byes),
// leaving 56 to be filled by advancement.
const filled = Object.values(actualSlots)
.flat()
.filter((s) => s !== null).length;
expect(filled).toBe(56);
});
});
describe("Double-elimination loser routing", () => {
it("routes every winners-bracket loser into the elimination bracket", () => {
const winnersRounds = [
"Opening Round",
"Winners Round 2",
"Winners Semifinals",
"Winners Final",
];
for (const roundName of winnersRounds) {
const round = LLWS_20.rounds.find((r) => r.name === roundName);
if (!round) throw new Error(`missing round ${roundName}`);
for (let n = 1; n <= round.matchCount; n++) {
const { loser } = resolveLLWSAdvancement(roundName, n);
expect(loser, `${roundName} #${n} loser should advance`).not.toBeNull();
expect(loser?.round.startsWith("Elimination")).toBe(true);
}
}
});
it("eliminates every elimination-bracket loser", () => {
const elimRounds = [
"Elimination Round 1",
"Elimination Round 2",
"Elimination Round 3",
"Elimination Round 4",
"Elimination Final",
];
for (const roundName of elimRounds) {
const round = LLWS_20.rounds.find((r) => r.name === roundName);
if (!round) throw new Error(`missing round ${roundName}`);
for (let n = 1; n <= round.matchCount; n++) {
const { loser } = resolveLLWSAdvancement(roundName, n);
expect(loser, `${roundName} #${n} loser should be out`).toBeNull();
}
}
});
it("keeps the winners-bracket final loser alive via the Elimination Final", () => {
// G30 (U.S. Winners Final) loser → G34, not out. This is the defining
// double-elimination behavior: a first loss never eliminates.
const { winner, loser } = resolveLLWSAdvancement("Winners Final", 1);
expect(destinationGame(loser)).toBe(34);
expect(destinationGame(winner)).toBe(36);
});
it("sends the side-championship loser to the consolation game, not out", () => {
// No "if necessary" rematch: the winners-bracket champion that loses G36 is
// done in the bracket, but still plays G37 for 3rd/4th.
const us = resolveLLWSAdvancement("Bracket Championship", 1);
expect(destinationGame(us.winner)).toBe(38);
expect(destinationGame(us.loser)).toBe(37);
expect(required(us.winner).slot).toBe("participant1Id");
expect(required(us.loser).slot).toBe("participant1Id");
const intl = resolveLLWSAdvancement("Bracket Championship", 2);
expect(required(intl.winner).slot).toBe("participant2Id");
expect(required(intl.loser).slot).toBe("participant2Id");
});
it("flags winners-bracket losers as advancing so they are not marked eliminated", () => {
// doesLoserAdvance is what stops the scoring engine writing a 0-point
// elimination (and announcing a knockout) for a team that is still alive.
// Winners Final and Bracket Championship are scoring rounds and are covered
// by loserIsPartial instead, so they are deliberately not listed here.
for (const round of ["Opening Round", "Winners Round 2", "Winners Semifinals"]) {
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(true);
}
for (const round of [
"Elimination Round 1",
"Elimination Round 2",
"Elimination Round 3",
"Elimination Round 4",
"Elimination Final",
]) {
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(false);
}
});
it("does not apply LLWS loser routing to other templates", () => {
expect(doesLoserAdvance("Opening Round", 1, "ncaa_68")).toBe(false);
expect(doesLoserAdvance("Winners Semifinals", 1, "")).toBe(false);
});
it("advances nobody out of the two final games", () => {
for (const round of ["Consolation Third Place", "World Championship"]) {
expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null });
}
});
it("never crosses a team between the U.S. and International sides", () => {
for (const round of LLWS_20.rounds) {
if (round.name === "Bracket Championship") continue; // the crossover point
if (round.matchCount === 1) continue;
for (let n = 1; n <= round.matchCount; n++) {
const { side } = llwsSideAndLocal(round.name, n);
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
for (const dest of [winner, loser]) {
if (!dest) continue;
const destRound = LLWS_20.rounds.find((r) => r.name === dest.round);
if (!destRound || destRound.matchCount === 1) continue;
expect(llwsSideAndLocal(dest.round, dest.matchNumber).side).toBe(side);
}
}
}
});
});
describe("Placement tiers", () => {
it("classifies scoring rounds correctly", () => {
expect(getScoringRoundType("Elimination Round 4", LLWS_20)).toBe("quarterfinals");
expect(getScoringRoundType("Elimination Final", LLWS_20)).toBe("quarterfinals");
expect(getScoringRoundType("Bracket Championship", LLWS_20)).toBe("semifinals");
expect(getScoringRoundType("World Championship", LLWS_20)).toBe("finals");
// Nobody is eliminated in the Winners Final — the loser drops to the
// elimination bracket — so it has no placement tier.
expect(getScoringRoundType("Winners Final", LLWS_20)).toBeNull();
});
it("pays 3rd and 4th distinctly (there is a real consolation game)", () => {
expect(calculateBracketPoints(3, DEFAULT_SCORING, "llws_20")).toBe(50);
expect(calculateBracketPoints(4, DEFAULT_SCORING, "llws_20")).toBe(40);
});
it("splits 58 into two two-team tiers", () => {
const upper = calculateAveragedPoints([5, 6], DEFAULT_SCORING); // (25+20)/2
const lower = calculateAveragedPoints([7, 8], DEFAULT_SCORING); // (15+10)/2
expect(calculateBracketPoints(5, DEFAULT_SCORING, "llws_20")).toBe(upper);
expect(calculateBracketPoints(6, DEFAULT_SCORING, "llws_20")).toBe(upper);
expect(calculateBracketPoints(7, DEFAULT_SCORING, "llws_20")).toBe(lower);
expect(calculateBracketPoints(8, DEFAULT_SCORING, "llws_20")).toBe(lower);
// Surviving Elimination Round 4 is worth more than losing it.
expect(upper).toBeGreaterThan(lower);
});
it("awards nothing below 8th", () => {
// The 12 teams knocked out in Elimination Rounds 13 finish 9th20th.
expect(calculateBracketPoints(9, DEFAULT_SCORING, "llws_20")).toBe(0);
expect(calculateBracketPoints(0, DEFAULT_SCORING, "llws_20")).toBe(0);
});
it("has exactly 8 teams alive when the first scoring elimination game is played", () => {
// Elimination Round 4 is the 7th8th tier, so the field must be 8 at that point:
// per side the Winners Final winner, the Winners Final loser, and the two
// Elimination Round 3 winners.
const eliminatedBeforeElimR4 =
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 1")?.matchCount ?? 0) +
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 2")?.matchCount ?? 0) +
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 3")?.matchCount ?? 0);
expect(eliminatedBeforeElimR4).toBe(12);
expect(LLWS_20.totalTeams - eliminatedBeforeElimR4).toBe(8);
});
});
});

View file

@ -190,26 +190,12 @@ describe("processMatchResult", () => {
});
});
it("AFL Wildcard Round: loser=0, winner gets T7 floor (a Wildcard win only buys an Elimination Final)", async () => {
// The generic "entering a scoring round ⇒ top-8" default would bank 5 here,
// over-awarding the 5th-6th tier to a team whose next loss is the 7th-8th tier.
it("AFL Wildcard Round: loser=0, winner gets T5 floor (feeds into Elimination Finals = scoring)", async () => {
const { db, insertedRows } = makeDb();
await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db);
expect(insertedRows).toHaveLength(2);
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 7, isPartialScore: true });
});
it("AFL Qualifying Finals: winner gets T3 floor (bye to a Preliminary Final), loser holds their entry floor", async () => {
const { db, insertedRows } = makeDb();
await processMatchResult(
{ ...BASE, bracketTemplateId: "afl_10", round: "Qualifying Finals", isScoring: false, loserAdvances: true },
db
);
// Only the winner is written: the loser still has a Semi-Final, so their
// seeding-derived floor of 5 stands untouched.
expect(insertedRows).toHaveLength(1);
expect(insertedRows[0]).toMatchObject({ participantId: "winner-1", finalPosition: 3, isPartialScore: true });
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true });
});
describe("NBA Play-In loserAdvances=true (7v8 game)", () => {
@ -319,22 +305,6 @@ describe("processMatchResult", () => {
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
});
it("skips only the probability refresh when asked, still announcing", async () => {
// For a caller scoring several matches in a loop: the refresh is season-wide and, for a
// bracket-aware sport, a full Monte Carlo run, so it belongs once after the loop rather
// than once per match. Standings and the announcement still happen per match.
const { db } = makeDb();
await processMatchResult(
{ ...BASE, round: "Quarterfinals", isScoring: true, skipProbabilities: true },
db
);
expect(updateProbabilitiesAfterResult).not.toHaveBeenCalled();
// recalculateAffectedLeagues still ran: it is the only thing that reads seasonSports.
expect(db.query.seasonSports.findMany).toHaveBeenCalled();
});
it("does not throw even if probability update fails", async () => {
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("network error")

View file

@ -104,40 +104,4 @@ describe("processQualifyingEvent — QP change notification", () => {
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
});
it("announces newly-eliminated players even when no QP changed (mirror non-scoring-round exit)", async () => {
// A mirror window re-scored on fan-out: nobody's QP changed, but the primary
// bracket reports p2 knocked out in a non-scoring round. The notification must
// still fire, passing the eliminated id through as the 5th arg so the "Knocked
// Out" section isn't dropped on the mirror.
const rows: ResultRow[] = [
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
{ id: "r2", seasonParticipantId: "p2", placement: null, qualifyingPointsAwarded: "0.00", scoringEvent },
];
const db = makeDb(rows, rows.map((r) => ({ ...r })));
await processQualifyingEvent(EVENT_ID, db, {
newlyEliminatedParticipantIds: new Set(["p2"]),
});
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
const [, , , changed, eliminated] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
// No QP change this sync.
expect([...(changed as Set<string>)]).toEqual([]);
// The knocked-out player is forwarded to the notifier.
expect([...(eliminated as Set<string>)]).toEqual(["p2"]);
});
it("does not notify on a re-sync with no QP change and no eliminations", async () => {
const rows: ResultRow[] = [
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
];
const db = makeDb(rows, rows.map((r) => ({ ...r })));
await processQualifyingEvent(EVENT_ID, db, {
newlyEliminatedParticipantIds: new Set(),
});
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
});
});

View file

@ -1,192 +0,0 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { countSeasonRaces, hasRaceRun } from "../season-races";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
const NOW = new Date("2026-08-17T12:00:00.000Z");
const TODAY = "2026-08-17";
interface EventRow {
eventType: string;
isComplete: boolean;
eventDate: string | null;
eventStartsAt: Date | null;
}
function makeEvent(overrides: Partial<EventRow> = {}): EventRow {
return {
eventType: "schedule_event",
isComplete: false,
eventDate: null,
eventStartsAt: null,
...overrides,
};
}
async function mockEvents(events: EventRow[]) {
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue({
query: {
scoringEvents: {
findMany: vi.fn().mockResolvedValue(events),
},
},
});
}
beforeEach(async () => {
await mockEvents([]);
});
describe("hasRaceRun", () => {
it("trusts isComplete when an admin has set it", () => {
expect(
hasRaceRun(
{ isComplete: true, eventDate: "2026-12-31", eventStartsAt: null },
NOW,
TODAY
)
).toBe(true);
});
it("prefers eventStartsAt over eventDate", () => {
// Started yesterday and long finished, even though eventDate is unset.
expect(
hasRaceRun(
{
isComplete: false,
eventDate: null,
eventStartsAt: new Date("2026-08-16T18:00:00.000Z"),
},
NOW,
TODAY
)
).toBe(true);
expect(
hasRaceRun(
{
isComplete: false,
eventDate: TODAY,
eventStartsAt: new Date("2026-08-17T18:00:00.000Z"),
},
NOW,
TODAY
)
).toBe(false);
});
it("does not call a race run the moment it goes green", () => {
// Declaring the finale finished at the green flag would publish the
// pre-race leader as champion at 100%, from standings without that race.
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
expect(
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
).toBe(false);
});
it("counts a race run once it has had time to finish", () => {
const greenFlag = new Date(NOW.getTime() - 7 * 60 * 60 * 1000);
expect(
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
).toBe(true);
});
it("still honours isComplete for a race that just started", () => {
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
expect(
hasRaceRun({ isComplete: true, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
).toBe(true);
});
it("treats a past date as run even when nobody marked it complete", () => {
expect(
hasRaceRun(
{ isComplete: false, eventDate: "2026-08-16", eventStartsAt: null },
NOW,
TODAY
)
).toBe(true);
});
it("treats a race happening today as still upcoming", () => {
expect(
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: null }, NOW, TODAY)
).toBe(false);
});
it("treats an undated row as upcoming", () => {
expect(
hasRaceRun({ isComplete: false, eventDate: null, eventStartsAt: null }, NOW, TODAY)
).toBe(false);
});
});
describe("countSeasonRaces", () => {
it("counts schedule_event rows as races", async () => {
// This is the whole bug: a season_standings calendar is stored as
// schedule_event rows, and the simulator used to skip them.
await mockEvents([
makeEvent({ eventDate: "2026-03-01" }),
makeEvent({ eventDate: "2026-04-01" }),
makeEvent({ eventDate: "2026-09-01" }),
]);
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 2,
remaining: 1,
total: 3,
});
});
it("excludes the final_standings scoring row", async () => {
await mockEvents([
makeEvent({ eventDate: "2026-03-01" }),
makeEvent({ eventType: "final_standings", eventDate: "2026-11-01" }),
]);
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 1,
remaining: 0,
total: 1,
});
});
it("counts other event types too, whichever type the admin used", async () => {
await mockEvents([
makeEvent({ eventType: "major_tournament", eventDate: "2026-03-01" }),
makeEvent({ eventType: "playoff_game", eventDate: "2026-09-01" }),
]);
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 1,
remaining: 1,
total: 2,
});
});
it("returns zeroes when the season has no events", async () => {
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 0,
remaining: 0,
total: 0,
});
});
it("counts a realistic late-season IndyCar calendar", async () => {
const calendar = [
...Array.from({ length: 15 }, (_, i) =>
makeEvent({ eventDate: `2026-0${((i % 6) + 3)}-0${(i % 9) + 1}` })
),
// The next race goes green in a few hours — still remaining.
makeEvent({ eventStartsAt: new Date("2026-08-17T18:00:00.000Z") }),
makeEvent({ eventStartsAt: new Date("2026-08-30T18:00:00.000Z") }),
makeEvent({ eventType: "final_standings" }),
];
await mockEvents(calendar);
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 15,
remaining: 2,
total: 17,
});
});
});

View file

@ -111,50 +111,4 @@ describe("simulator input model", () => {
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull();
});
it("hides an Elo flagged as projection-derived so the projection is re-derived", async () => {
// This is what stops a stale Elo from winning the baseEloPriority race. A row
// carrying projectedWins and a projectedWins method flag must surface with a
// null sourceElo, so resolveSourceElos falls through to the projection rather
// than reusing an Elo that was itself derived from an older projection.
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
{ id: "projected" },
{ id: "hand-entered" },
]);
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
{
participantId: "projected",
sourceOdds: null,
sourceElo: 1561,
worldRanking: null,
rating: null,
projectedWins: "95.00",
projectedTablePoints: null,
seed: null,
region: null,
metadata: { sourceEloMethod: "projectedWins" },
},
{
participantId: "hand-entered",
sourceOdds: null,
sourceElo: 1561,
worldRanking: null,
rating: null,
projectedWins: "95.00",
projectedTablePoints: null,
seed: null,
region: null,
metadata: {},
},
]);
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
const inputs = await getParticipantSimulatorInputs("season-1");
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
expect(byParticipant.get("projected")?.sourceElo).toBeNull();
expect(byParticipant.get("projected")?.projectedWins).toBe(95);
// No flag means the admin entered that Elo themselves — it is trusted as direct.
expect(byParticipant.get("hand-entered")?.sourceElo).toBe(1561);
});
});

View file

@ -80,9 +80,6 @@ function makeDb(
},
scoringEvents: {
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
// getBracketTemplateIdsForSportsSeasons filters to events that carry a
// template, so "no bracket template" is an empty result, not a null row.
findMany: vi.fn().mockResolvedValue([]),
},
seasonParticipantResults: {
findMany: vi.fn().mockResolvedValue(seasonResults),

View file

@ -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 5th8th tier when the template id is null, which
* silently collapses the two-tier templates (llws_20, afl_10) so a team locked into
* 5th6th and one locked into 7th8th both score the flat 58 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;
}

View file

@ -7,7 +7,6 @@ import {
calculateBracketPoints,
calculateSharedPlacementPoints,
} from "./scoring-rules";
import { getBracketTemplateIdsForSportsSeasons } from "./bracket-template";
export async function createDraftPick(data: {
seasonId: string;
@ -176,10 +175,18 @@ export async function getDraftedParticipantsWithPoints(
}
// Batch-fetch bracket template IDs (one per sports season)
const bracketTemplateMap =
bracketSeasonIds.size > 0
? await getBracketTemplateIdsForSportsSeasons([...bracketSeasonIds], db)
: new Map<string, string | null>();
const bracketTemplateMap = new Map<string, string | null>();
if (bracketSeasonIds.size > 0) {
const events = await db.query.scoringEvents.findMany({
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]),
columns: { sportsSeasonId: true, bracketTemplateId: true },
});
for (const ev of events) {
if (!bracketTemplateMap.has(ev.sportsSeasonId)) {
bracketTemplateMap.set(ev.sportsSeasonId, ev.bracketTemplateId ?? null);
}
}
}
// Batch-fetch QP totals for qualifying_points participants
const qpMap = new Map<string, number>(); // participantId → totalQP

View file

@ -1,4 +1,4 @@
import { eq, and, inArray } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
@ -104,33 +104,6 @@ export async function deleteParticipantResultsBySportsSeasonId(
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
}
/**
* Delete the results of specific participants within one sports season.
*
* The season-wide delete above is too blunt for a single bracket: results are keyed by
* sports season, not by event, so wiping the season takes every other event's placements
* with it. Scoping to the participants a bracket actually holds lets reprocess-bracket
* rebuild that bracket from scratch while leaving the rest of the season alone.
*
* No-ops on an empty id list `inArray` with no values is not a valid SQL predicate.
*/
export async function deleteParticipantResultsForParticipants(
sportsSeasonId: string,
participantIds: string[],
providedDb?: ReturnType<typeof database>
): Promise<void> {
if (participantIds.length === 0) return;
const db = providedDb || database();
await db
.delete(schema.seasonParticipantResults)
.where(
and(
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
inArray(schema.seasonParticipantResults.participantId, participantIds)
)
);
}
/**
* Set result for a participant in a sports season
* Points are calculated on-demand based on each fantasy league's scoring rules

View file

@ -6,19 +6,8 @@ import {
getBracketTemplate,
buildNCAA68SlotMap,
matchIndexForSeedSlot,
llwsMatchNumber,
llwsSideAndLocal,
STANDARD_BRACKET_SEEDING,
} from "~/lib/bracket-templates";
import {
LLWS_LOSER_ADVANCES_ROUNDS,
resolveLLWSAdvancement,
type LLWSResolvedDestination,
} from "~/lib/llws-bracket";
import {
resolveAflWildcardPlacements,
type AflWildcardResult,
} from "~/lib/afl-wildcard-reseed";
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
@ -478,11 +467,6 @@ export async function generateBracketFromTemplate(
return await generateNBA20Bracket(eventId, template, participantIds);
}
// LLWS 20 requires special handling for its two double-elimination brackets
if (templateId === "llws_20") {
return await generateLLWS20Bracket(eventId, template, participantIds);
}
const matches: NewPlayoffMatch[] = [];
// Generate matches for each round in the template
@ -746,11 +730,9 @@ async function generateNFL14Bracket(
* Structure:
* - Wildcard Round: 7v10, 8v9
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
* - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder
* position 5th draws the lower-ranked winner, 6th the higher-ranked one
* - Semi-Finals: SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner
* - Preliminary Finals: PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner
* (the crossover keeps a QF loser away from the side that just beat it)
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
* - Semi-Finals: QF losers vs EF winners
* - Preliminary Finals: QF winners vs SF winners
* - Grand Final: PF winners
*/
async function generateAFL10Bracket(
@ -802,16 +784,14 @@ async function generateAFL10Bracket(
});
}
// Elimination Finals: 5th and 6th host the two Wildcard winners. Which winner lands
// where is decided by ladder position once both games are played (see
// resolveAflWildcardPlacements), not by a fixed crossover from a Wildcard match.
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
const eliminationSeeding = [
{ higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
{ higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
];
for (let i = 0; i < eliminationSeeding.length; i++) {
const { higher, opponent } = eliminationSeeding[i];
const { higher, wildcard } = eliminationSeeding[i];
matches.push({
scoringEventId: eventId,
round: "Elimination Finals",
@ -821,11 +801,11 @@ async function generateAFL10Bracket(
isComplete: false,
isScoring: true, // Losers share 7th-8th
templateRound: "Elimination Finals",
seedInfo: participantIds ? `${higher + 1} vs ${opponent}` : null,
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null,
});
}
// Semi-Finals: SF n = QF n loser vs EF n winner (TBD vs TBD)
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
for (let i = 0; i < 2; i++) {
matches.push({
scoringEventId: eventId,
@ -871,257 +851,15 @@ async function generateAFL10Bracket(
return await createManyPlayoffMatches(matches);
}
/** What a re-seed changed, by Elimination Finals match number. */
export interface AflEliminationReseed {
vacated: number[];
filled: Array<{ matchNumber: number; participantId: string }>;
}
/**
* Put the decided Wildcard winners in the Elimination Finals they belong in.
*
* The two winners are re-seeded by ladder position 5th meets the lower-ranked one and
* 6th the higher-ranked one rather than crossing over from a fixed Wildcard match. That
* destination depends on both games, so this reconciles both slots against the results
* recorded so far every time it runs: it places a winner whose slot only became certain
* once the other game was decided, and moves one that an earlier (or corrected) result,
* or a bracket advanced before this rule existed, put in the other slot.
*
* `pending` supplies a result that may not be in the database yet the row read back
* while advancing a match can predate the winner being written to it.
*
* Idempotent: pairings that are already right do no writes.
*/
export async function reseedAflEliminationFinals(
eventId: string,
pending?: { matchId: string; winnerId: string }
): Promise<AflEliminationReseed> {
const [wcMatches, efMatches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"),
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
]);
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
if (wcMatches.length === 0 || efMatches.length === 0) {
throw new Error(
`Event ${eventId} has no AFL Wildcard Round / Elimination Finals matches to re-seed`
);
}
const winnerByMatchNumber = new Map<number, string>();
for (const wc of wcMatches) {
const decidedWinner =
pending && wc.id === pending.matchId ? pending.winnerId : wc.isComplete ? wc.winnerId : null;
if (decidedWinner) winnerByMatchNumber.set(wc.matchNumber, decidedWinner);
}
const results: AflWildcardResult[] = wcMatches.map((wc) => {
const decidedWinner = winnerByMatchNumber.get(wc.matchNumber) ?? null;
if (decidedWinner === null) return { matchNumber: wc.matchNumber, winnerSlot: null };
if (decidedWinner === wc.participant1Id) return { matchNumber: wc.matchNumber, winnerSlot: 1 };
if (decidedWinner === wc.participant2Id) return { matchNumber: wc.matchNumber, winnerSlot: 2 };
throw new Error(
`Wildcard Round match ${wc.matchNumber} winner is not one of its participants`
);
});
const wanted = new Map<number, string>();
for (const placement of resolveAflWildcardPlacements(results)) {
const placedWinner = winnerByMatchNumber.get(placement.wildcardMatchNumber);
if (placedWinner) wanted.set(placement.eliminationMatchNumber, placedWinner);
}
// Only these teams can legitimately be moved between the two Elimination Finals;
// anyone else in a slot came from somewhere this function knows nothing about.
const wildcardParticipants = new Set<string>();
for (const wc of wcMatches) {
if (wc.participant1Id) wildcardParticipants.add(wc.participant1Id);
if (wc.participant2Id) wildcardParticipants.add(wc.participant2Id);
}
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
for (const efMatch of efMatches) {
const occupant = efMatch.participant2Id;
const belongsHere = wanted.get(efMatch.matchNumber) ?? null;
if (occupant === belongsHere) continue;
if (occupant !== null && !wildcardParticipants.has(occupant)) {
throw new Error(`EF ${efMatch.matchNumber} participant2 already filled`);
}
// Re-seeding a game that has already been played would rewrite who contested a
// recorded result. Surface that (this message is not one callers swallow) rather
// than quietly corrupting the bracket.
if (occupant !== null && (efMatch.isComplete || efMatch.winnerId)) {
throw new Error(
`Elimination Finals match ${efMatch.matchNumber} already has a recorded result, ` +
`so its Wildcard qualifier cannot be re-seeded — clear and regenerate the bracket`
);
}
if (occupant !== null) slotsToClear.push({ id: efMatch.id, matchNumber: efMatch.matchNumber });
if (belongsHere !== null) {
slotsToFill.push({ id: efMatch.id, matchNumber: efMatch.matchNumber, participantId: belongsHere });
}
}
const reseed: AflEliminationReseed = {
vacated: slotsToClear.map((slot) => slot.matchNumber),
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
};
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
// One transaction, vacating before filling: a half-applied re-seed would leave the
// same team in both Elimination Finals.
const db = database();
await db.transaction(async (tx) => {
const now = new Date();
for (const slot of slotsToClear) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: null, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
for (const slot of slotsToFill) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: slot.participantId, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
});
return reseed;
}
/** What a Semi-Finals re-seed changed, by Semi-Finals match number. */
export interface AflSemiFinalReseed {
vacated: number[];
filled: Array<{ matchNumber: number; participantId: string }>;
}
/**
* Put the decided Elimination Final winners in the Semi-Finals they belong in.
*
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
* n, so SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the EF2
* winner. The crossover in this system comes a round later, at Semi-Final Preliminary
* Final, so that a Qualifying Final loser cannot meet the side that just beat it.
*
* Brackets advanced before this was fixed crossed the two winners the EF1 winner went
* to SF2 and the EF2 winner to SF1 which is why this reconciles both slots against the
* results recorded so far rather than writing the one it was called for: a winner sitting
* in the wrong Semi-Final is vacated, and a corrected Elimination Final result pulls the
* beaten team back out instead of leaving it alive.
*
* `pending` supplies a result that may not be in the database yet the row read back
* while advancing a match can predate the winner being written to it.
*
* Idempotent: pairings that are already right do no writes.
*/
export async function reseedAflSemiFinals(
eventId: string,
pending?: { matchId: string; winnerId: string }
): Promise<AflSemiFinalReseed> {
const [efMatches, sfMatches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals"),
]);
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
if (efMatches.length === 0 || sfMatches.length === 0) {
throw new Error(
`Event ${eventId} has no AFL Elimination Finals / Semi-Finals matches to re-seed`
);
}
// Elimination Final n feeds Semi-Final n, so a decided winner's destination never
// depends on the other game.
const wanted = new Map<number, string>();
for (const ef of efMatches) {
const decidedWinner =
pending && ef.id === pending.matchId ? pending.winnerId : ef.isComplete ? ef.winnerId : null;
if (!decidedWinner) continue;
if (decidedWinner !== ef.participant1Id && decidedWinner !== ef.participant2Id) {
throw new Error(
`Elimination Finals match ${ef.matchNumber} winner is not one of its participants`
);
}
wanted.set(ef.matchNumber, decidedWinner);
}
// Only these teams can legitimately be moved between the two Semi-Finals; anyone else
// in a slot came from somewhere this function knows nothing about.
const eliminationParticipants = new Set<string>();
for (const ef of efMatches) {
if (ef.participant1Id) eliminationParticipants.add(ef.participant1Id);
if (ef.participant2Id) eliminationParticipants.add(ef.participant2Id);
}
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
for (const sfMatch of sfMatches) {
const occupant = sfMatch.participant2Id;
const belongsHere = wanted.get(sfMatch.matchNumber) ?? null;
if (occupant === belongsHere) continue;
if (occupant !== null && !eliminationParticipants.has(occupant)) {
throw new Error(`SF ${sfMatch.matchNumber} participant2 already filled`);
}
// Re-seeding a game that has already been played would rewrite who contested a
// recorded result. Surface that (this message is not one callers swallow) rather
// than quietly corrupting the bracket.
if (occupant !== null && (sfMatch.isComplete || sfMatch.winnerId)) {
throw new Error(
`Semi-Finals match ${sfMatch.matchNumber} already has a recorded result, ` +
`so its Elimination Finals qualifier cannot be re-seeded — clear and regenerate the bracket`
);
}
if (occupant !== null) slotsToClear.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber });
if (belongsHere !== null) {
slotsToFill.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber, participantId: belongsHere });
}
}
const reseed: AflSemiFinalReseed = {
vacated: slotsToClear.map((slot) => slot.matchNumber),
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
};
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
// One transaction, vacating before filling: a half-applied re-seed would leave the
// same team in both Semi-Finals.
const db = database();
await db.transaction(async (tx) => {
const now = new Date();
for (const slot of slotsToClear) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: null, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
for (const slot of slotsToFill) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: slot.participantId, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
});
return reseed;
}
/**
* AFL-specific advancement logic for the complex double-chance system
* Phase 3.3: Handles both winners and losers advancing to different rounds
*
* Advancement rules:
* - Wildcard Round: Winner Elimination Finals (re-seeded by ladder position)
* - Qualifying Finals: Winner Preliminary Finals, Loser Semi-Finals (QF n PF n, SF n)
* - Elimination Finals: Winner Semi-Finals (EF n SF n, a fixed pathway)
* - Semi-Finals: Winner Preliminary Finals (SF n crosses over: SF1 PF2, SF2 PF1)
* - Wildcard Round: Winner Elimination Finals
* - Qualifying Finals: Winner Preliminary Finals, Loser Semi-Finals
* - Elimination Finals: Winner Semi-Finals
* - Semi-Finals: Winner Preliminary Finals
* - Preliminary Finals: Winner Grand Final
*/
async function advanceAFLWinner(
@ -1131,10 +869,18 @@ async function advanceAFLWinner(
): Promise<void> {
const eventId = match.scoringEventId;
// Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
// position, so every result re-resolves both slots.
// Wildcard Round: Winner advances to Elimination Finals
if (match.round === "Wildcard Round") {
await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId });
// Wildcard Match 1 winner → EF Match 2, participant2Id
// Wildcard Match 2 winner → EF Match 1, participant2Id
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
return;
}
@ -1162,11 +908,18 @@ async function advanceAFLWinner(
return;
}
// Elimination Finals: Winner → Semi-Finals. EF n feeds SF n — the crossover in this
// system is a round later, at Semi-Finals → Preliminary Finals. Reconcile both slots so
// a corrected result moves the qualifier instead of leaving the beaten team alive.
// Elimination Finals: Winner → Semi-Finals
if (match.round === "Elimination Finals") {
await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
// EF Match 1 winner → SF2 participant2
// EF Match 2 winner → SF1 participant2
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
return;
}
@ -1227,15 +980,6 @@ export async function advanceWinnerTemplate(
return await advanceNBAPlayInWinner(match, winnerId, loserId);
}
// Special handling for LLWS 20 double elimination: winners-bracket losers route
// into the elimination bracket instead of being knocked out.
if (template.id === "llws_20") {
const loserId =
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
if (!loserId) throw new Error("Cannot determine loser for LLWS advancement");
return await advanceLLWSWinner(match, winnerId, loserId);
}
// Special handling for AFL 10 double-chance system
// Phase 3.3: AFL has complex winner/loser advancement rules
if (template.id === "afl_10") {
@ -1683,12 +1427,6 @@ export function doesLoserAdvance(
if (templateId === "afl_10" && round === "Qualifying Finals") {
return true;
}
// LLWS winners bracket: a loss drops the team into the elimination bracket, so it
// must not be recorded as an elimination. (Winners Final and Bracket Championship
// are scoring rounds and are handled via loserIsPartial instead.)
if (templateId === "llws_20" && LLWS_LOSER_ADVANCES_ROUNDS.has(round)) {
return true;
}
return false;
}
@ -1800,140 +1538,3 @@ async function advanceNBAPlayInWinner(
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
}
}
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
// The routing table itself is pure and lives in lib/ so the renderer can import it
// without pulling the database context into the browser bundle. Re-exported here so
// existing server-side callers and tests keep their import path.
export { LLWS_LOSER_ADVANCES_ROUNDS, resolveLLWSAdvancement, type LLWSResolvedDestination };
/**
* Generate the 20-team LLWS double-elimination bracket (38 matches).
*
* Only the Opening Round and the four bye slots receive participants up front;
* everything else is filled by advanceLLWSWinner as games complete.
*
* Participant array layout (see LLWS_20 in lib/bracket-templates):
* [07] U.S. Opening Round teams, two per game
* [8, 9] U.S. bye teams Winners Round 2 M1 / M2 participant1
* [1017] International Opening Round teams, two per game
* [18,19] International bye teams Winners Round 2 M3 / M4 participant1
*/
async function generateLLWS20Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
const p = (idx: number): string | null =>
participantIds ? (participantIds[idx] ?? null) : null;
const sides = [
{ side: 0 as const, label: "U.S.", openingBase: 0, byeBase: 8 },
{ side: 1 as const, label: "Intl", openingBase: 10, byeBase: 18 },
];
// ── Opening Round: 4 games per side, both slots seeded ──────────────────────
for (const { side, label, openingBase } of sides) {
for (let local = 1; local <= 4; local++) {
matches.push({
scoringEventId: eventId,
round: "Opening Round",
matchNumber: llwsMatchNumber("Opening Round", side, local),
participant1Id: p(openingBase + (local - 1) * 2),
participant2Id: p(openingBase + (local - 1) * 2 + 1),
isComplete: false,
isScoring: false,
templateRound: "Opening Round",
seedInfo: `${label} Opening ${local}`,
});
}
}
// ── Winners Round 2: bye team at participant1, Opening winner at participant2 ─
for (const { side, label, byeBase } of sides) {
for (let local = 1; local <= 2; local++) {
matches.push({
scoringEventId: eventId,
round: "Winners Round 2",
matchNumber: llwsMatchNumber("Winners Round 2", side, local),
participant1Id: p(byeBase + (local - 1)),
participant2Id: null, // Opening Round winner
isComplete: false,
isScoring: false,
templateRound: "Winners Round 2",
seedInfo: `${label} Bye ${local} vs Opening ${local} winner`,
});
}
}
// ── Every remaining round starts empty ──────────────────────────────────────
const remaining = template.rounds.filter(
(r) => r.name !== "Opening Round" && r.name !== "Winners Round 2"
);
for (const round of remaining) {
for (let i = 1; i <= round.matchCount; i++) {
// Championship/Consolation are single shared games; everything else is per-side.
const perSide = round.matchCount > 1;
const label = perSide
? llwsSideAndLocal(round.name, i).side === 0
? "U.S."
: "Intl"
: null;
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo: label ? `${label} ${round.name}` : null,
});
}
}
return await createManyPlayoffMatches(matches);
}
/**
* LLWS advancement: routes the winner forward and, in the winners bracket, routes the
* loser into the elimination bracket rather than eliminating them.
*
* All routing decisions live in resolveLLWSAdvancement; this function only writes.
*/
async function advanceLLWSWinner(
match: PlayoffMatch,
winnerId: string,
loserId: string
): Promise<void> {
const eventId = match.scoringEventId;
const { winner, loser } = resolveLLWSAdvancement(match.round, match.matchNumber);
// Winner and loser can land in different rounds, so resolve each independently.
const moves: Array<{ destination: LLWSResolvedDestination; participantId: string }> = [];
if (winner) moves.push({ destination: winner, participantId: winnerId });
if (loser) moves.push({ destination: loser, participantId: loserId });
for (const { destination, participantId } of moves) {
const targetMatches = await findPlayoffMatchesByEventIdAndRound(
eventId,
destination.round
);
const target = targetMatches.find((m) => m.matchNumber === destination.matchNumber);
if (!target) {
throw new Error(
`Next match not found: round=${destination.round}, matchNumber=${destination.matchNumber}`
);
}
if (target[destination.slot]) {
throw new Error(
`Next match ${destination.slot} is already filled ` +
`(round=${destination.round}, matchNumber=${destination.matchNumber})`
);
}
await updatePlayoffMatch(target.id, { [destination.slot]: participantId });
}
}

View file

@ -16,7 +16,6 @@ import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-
import { getUserDisplayName } from "~/models/user";
import { findDiscordIdsByUserIds } from "~/models/account";
import { createDailySnapshot } from "~/models/standings";
import { getBracketTemplateIdForSportsSeason } from "~/models/bracket-template";
import { recordMatchScoreEvents } from "~/models/team-score-events";
import { logger } from "~/lib/logger";
import { getEventResults } from "./event-result";
@ -114,21 +113,6 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
// 3rd place game finalizes both positions distinctly.
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
},
llws_20: {
// Winners Final loser drops to the Elimination Final, so 5th is provisional —
// winning that game lifts them back to a 4th-place floor.
"Winners Final": { loserPosition: 5, loserIsPartial: true, winnerFloor: 4 },
// Elimination Round 4 losers are the 7th8th tier (8 teams alive at this point).
"Elimination Round 4": { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 },
// Elimination Final losers are the 5th6th tier; the winner reaches the side
// championship, where the worst case is 4th (lose it, then lose the consolation).
"Elimination Final": { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 },
// Side championship loser still has the consolation game — provisional 4th.
"Bracket Championship": { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 },
// Consolation finalizes 3rd and 4th distinctly.
"Consolation Third Place": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
"World Championship": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
},
tennis_128: {
// R16 losers share 9th16th; winner advances to QF (floor 5th8th).
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
@ -142,141 +126,28 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
};
/**
* Returns the floor position that winners of a NON-scoring round should bank, or null
* to bank nothing.
* Returns true if a non-scoring round's winners are entering the first scoring round
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5T8 floor).
*
* Default: winners entering the first scoring round have guaranteed a top-8 fantasy
* placement and receive a T5T8 floor (5); everyone else gets nothing yet. For
* multi-round pre-bracket sequences like NCAA (Round of 64 Round of 32 Sweet
* Sixteen Elite Eight), only Sweet Sixteen winners are entering the scoring bracket.
* For multi-round pre-bracket sequences like NCAA (Round of 64 Round of 32
* Sweet Sixteen Elite Eight), only Sweet Sixteen winners are entering the scoring
* bracket Round of 64 and Round of 32 winners should not receive any floor yet.
*
* A round may override this with `nonScoringWinnerFloor` when the default is wrong
* in a double-elimination losers bracket a win can guarantee a worse finish than 5th
* (llws_20 "Elimination Round 3" 7), or nothing at all.
*
* Falls back to 5 when template/round info is unavailable, preserving legacy behavior.
* Falls back to true when template/round info is unavailable to preserve legacy behavior.
*/
function nonScoringWinnerFloorFor(
function doesNonScoringRoundFeedIntoScoringRound(
round: string,
bracketTemplateId: string | null | undefined
): number | null {
if (!bracketTemplateId) return 5; // Legacy: preserve old behavior
): boolean {
if (!bracketTemplateId) return true; // Legacy: preserve old behavior
const template = BRACKET_TEMPLATES[bracketTemplateId];
if (!template) return 5; // Unknown template: preserve old behavior
if (!template) return true; // Unknown template: preserve old behavior
const currentRound = template.rounds.find((r) => r.name === round);
if (!currentRound) return 5; // Unknown round: preserve old behavior
// Explicit per-round override wins, including an explicit null (bank nothing).
if (currentRound.nonScoringWinnerFloor !== undefined) {
return currentRound.nonScoringWinnerFloor;
}
if (!currentRound) return true; // Unknown round: preserve old behavior
const nextRoundName = currentRound.feedsInto;
if (!nextRoundName) return null; // No next round (shouldn't happen for non-scoring)
if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring)
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
return nextRound?.isScoring === true ? 5 : null;
}
/**
* Returns the floor position a participant banks purely by being *seeded into* the
* given round when the bracket is generated, or null when entry guarantees nothing.
*
* Two sources, in order:
* 1. The template round's explicit `entryFloor` (e.g. afl_10 "Qualifying Finals" 5:
* seeds 1-4 have the double chance, so the 5th-6th tier is locked in on day one).
* 2. Otherwise a scoring round's own loser position being drawn into a round whose
* losers score means the worst case is that round's loser tier.
*
* Non-scoring rounds with no explicit `entryFloor` return null: losing your first game
* there is worth 0, so there is nothing to bank yet.
*/
export function getBracketEntryFloor(
round: string,
bracketTemplateId: string | null | undefined
): number | null {
const template = bracketTemplateId ? BRACKET_TEMPLATES[bracketTemplateId] : undefined;
const templateRound = template?.rounds.find((r) => r.name === round);
if (templateRound?.entryFloor !== undefined) return templateRound.entryFloor;
if (!templateRound?.isScoring) return null;
return getRoundConfig(round, bracketTemplateId)?.loserPosition ?? null;
}
/**
* Write the provisional entry floors for a freshly generated (or reprocessed) bracket.
*
* A seeded bracket can guarantee points before anyone plays: an AFL top-4 seed cannot
* finish below the 5th-6th tier because a Qualifying Final loss still leaves them a
* Semi-Final. Without this, those teams sit on 0 fantasy points until their first game
* resolves, which understates every roster holding them.
*
* Only participants already assigned to a match slot are touched, and every write is
* provisional (isPartialScore=true) so it is superseded the moment a real result lands.
*
* Floors never go backwards. A participant already sitting on an equal or better
* placement is skipped, so regenerating a bracket mid-tournament (clear-bracket
* generate-bracket) cannot knock a finalist back down to their seeding floor. Combined
* with upsertParticipantResult's never-un-finalize guard, re-running over the same
* bracket is a no-op.
*
* Returns the number of participants whose floor this call actually raised.
*/
export async function applyBracketEntryFloors(
eventId: string,
providedDb?: ReturnType<typeof database>
): Promise<number> {
const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
});
if (!event?.bracketTemplateId) return 0;
const matches = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId),
});
// Highest (best) floor wins when a participant somehow appears in more than one
// round's slots — a lower position number is a better guarantee.
const floorByParticipant = new Map<string, number>();
for (const match of matches) {
const floor = getBracketEntryFloor(match.round, event.bracketTemplateId);
if (floor === null) continue;
for (const participantId of [match.participant1Id, match.participant2Id]) {
if (!participantId) continue;
const existing = floorByParticipant.get(participantId);
if (existing === undefined || floor < existing) {
floorByParticipant.set(participantId, floor);
}
}
}
// Existing placements, so a floor is only ever written when it improves on what
// the participant already has. Position 0 means eliminated / missed the bracket —
// not a better placement — so it never blocks a floor.
const existingRows = await db.query.seasonParticipantResults.findMany({
where: eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId),
columns: { participantId: true, finalPosition: true },
});
const existingPosition = new Map(
existingRows
.filter((r) => r.finalPosition !== null && r.finalPosition > 0)
.map((r) => [r.participantId, r.finalPosition as number])
);
let applied = 0;
for (const [participantId, floor] of floorByParticipant) {
const current = existingPosition.get(participantId);
if (current !== undefined && current <= floor) continue; // already as good or better
const oldFloor = await upsertParticipantResult(
participantId,
event.sportsSeasonId,
floor,
db,
true // provisional: replaced as soon as the participant wins or is eliminated
);
if (oldFloor !== null) applied++;
}
return applied;
return nextRound?.isScoring === true;
}
/**
@ -410,18 +281,19 @@ export async function processPlayoffEvent(
}
if (!isScoring) {
// Non-scoring round: losers are permanently eliminated (0 pts) unless they
// advance (double-elimination winners-bracket losers). Winners bank a
// provisional floor only when this round guarantees them one — see
// nonScoringWinnerFloorFor for how that is derived per template.
const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId);
// Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts).
// Winners only bank a provisional T5T8 floor if they're entering the first
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
for (const match of matches) {
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
if (match.loserId && !loserAdvances) {
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
}
if (match.winnerId && winnerFloor !== null) {
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true);
if (match.winnerId && awardFloor) {
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
}
}
} else {
@ -481,7 +353,7 @@ export async function processPlayoffEvent(
// Progressive floor scoring: assign guaranteed minimum points to winners.
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
// winner is already finalized as 1st above. For non-scoring rounds it also
// returns null; those winners were given their floor inline above.
// returns null (winners were given floor 5 inline above).
const guaranteedMinimum = getGuaranteedMinimumPosition(
round,
event.bracketTemplateId,
@ -547,19 +419,6 @@ export async function processMatchResult(
/** When set, Discord notification only shows this match (not all completed matches for the event). */
matchId?: string;
skipSideEffects?: boolean;
/**
* Skip only the probability refresh, still recalculating standings and announcing.
*
* For a caller scoring several matches in a loop: the refresh is season-wide and
* idempotent, so running it per match repeats the whole thing needlessly and for a
* bracket-aware sport that now means a full Monte Carlo run each time. Set this in the
* loop and call updateProbabilitiesAfterResult once when it finishes. Per-match
* announcements then project from the previous probabilities until that final call.
*
* Distinct from skipSideEffects, which also suppresses the standings recalculation and
* the announcement.
*/
skipProbabilities?: boolean;
/**
* When true, the loser of this non-scoring round advances to another match
* (e.g. NBA Play-In Round 1 7v8 loser Play-In Round 2) and must NOT be
@ -570,7 +429,7 @@ export async function processMatchResult(
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, skipProbabilities, loserAdvances } = params;
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params;
if (!isScoring) {
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
@ -582,9 +441,8 @@ export async function processMatchResult(
if (!loserAdvances) {
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
}
const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId);
if (nonScoringFloor !== null) {
await upsertParticipantResult(winnerId, sportsSeasonId, nonScoringFloor, db, true);
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
}
// Non-scoring round wins are not surfaced in the Recent Scores feed.
} else {
@ -650,7 +508,6 @@ export async function processMatchResult(
: undefined;
// Update probabilities first so the standings recalc reads fresh EVs and
// projected points reflect the new result.
if (!skipProbabilities) {
try {
await updateProbabilitiesAfterResult(sportsSeasonId, true);
} catch (error) {
@ -659,7 +516,6 @@ export async function processMatchResult(
error
);
}
}
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
}
}
@ -1024,15 +880,6 @@ export async function processQualifyingEvent(
* which fall back to querying it here.
*/
canonicalTieCountByPlacement?: Map<number, number>;
/**
* This window's season_participant ids that were knocked out this sync in a
* non-scoring round. They earn no QP (so they never surface via changed QP),
* but a manager who drafted them should still be told. Threaded down from the
* primary bracket by the fan-out (syncTournamentResults), already translated
* to THIS window's season_participant ids. See the primary path in
* app/services/match-sync/index.ts (newlyEliminatedIds).
*/
newlyEliminatedParticipantIds?: Set<string>;
} = {}
): Promise<void> {
const db = providedDb || database();
@ -1188,22 +1035,9 @@ export async function processQualifyingEvent(
afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded }))
);
// Players knocked out this sync in a non-scoring round earn no QP, so they never
// appear in changedParticipantIds. Announce them too (mirroring the primary path
// in syncTennisDraw), so a mirror window's "Knocked Out" section isn't dropped.
const eliminatedIds = options.newlyEliminatedParticipantIds ?? new Set<string>();
if (
(changedParticipantIds.size > 0 || eliminatedIds.size > 0) &&
!options.skipNotifications
) {
if (changedParticipantIds.size > 0 && !options.skipNotifications) {
try {
await notifyQualifyingPointsUpdate(
event.sportsSeasonId,
eventId,
db,
changedParticipantIds,
eliminatedIds,
);
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds);
} catch (error) {
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
}
@ -1481,7 +1315,11 @@ export async function calculateTeamScore(
if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId) ?? null;
}
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId;
}
@ -1590,7 +1428,11 @@ export async function calculateTeamProjectedScore(
if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId) ?? null;
}
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId;
}
@ -2076,10 +1918,8 @@ export async function recalculateAffectedLeagues(
// in the World Cup) does not qualify on its own — their owner hasn't earned anything yet.
// When a match qualifies because the loser is owned, the winner's manager tag is still
// shown for context (who beat them), but the winner is not Discord-pinged.
// Both managers' tags are always shown for context when their teams are drafted; the
// showLoser flag (isLoserNotifiable) only gates whether the loser is @-pinged — a loser
// who advanced rather than being eliminated (loserAdvances=true, e.g. NBA 7v8 → PIR2, or
// a World Cup semifinal loser) is named but not pinged.
// Losers who advance to another match (loserAdvances=true, e.g. NBA 7v8 → PIR2) have
// showLoser=false and are correctly suppressed.
let scoredMatches: ScoredMatch[] | undefined;
if (allCompletedMatches.length > 0) {
const relevant = allCompletedMatches.filter(
@ -2112,12 +1952,7 @@ export async function recalculateAffectedLeagues(
winnerName: x.m.winnerName ?? "",
loserName: x.m.loserName ?? "",
winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined,
// Show the loser's manager tag whenever their team is drafted, mirroring the
// winner above — even when the loser advances rather than being eliminated
// (World Cup semifinal → 3rd-place playoff, AFL Qualifying Final → Semi Final).
// The @-ping stays gated by showLoser (loserDiscordUserId below): a still-alive
// loser who neither scored nor was eliminated is named for context but not pinged.
loserUsername: x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
loserUsername: x.showLoser && x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined,
loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined,
}))

View file

@ -134,21 +134,14 @@ export function calculateSharedPlacementPoints(
* AFL is different: it has TWO distinct tiers in the 58 zone:
* - T5-T6: Semi-Finals losers (positions 5 and 6) avg([5,6])
* - T7-T8: Elimination Finals losers (positions 7 and 8) avg([7,8])
*
* LLWS has the same shape from its two elimination brackets:
* - T5-T6: Elimination Final losers (one per side) avg([5,6])
* - T7-T8: Elimination Round 4 losers (one per side) avg([7,8])
*/
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]);
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]);
/**
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
* (not averaged). Standard brackets average them because both SF losers tie.
*
* llws_20's Consolation Third Place game decides 3rd and 4th head-to-head between
* the two side runners-up.
*/
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]);
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48"]);
/**
* Calculate fantasy points for a bracket placement, averaging tied positions.

View file

@ -1,99 +0,0 @@
/**
* Race-calendar state for season-standings sports (F1, IndyCar).
*
* Kept in its own leaf module rather than in `scoring-event.ts` so that
* `simulator.ts` can read it: `scoring-event.ts` pulls in `scoring-calculator`,
* which reaches `participant-expected-value` and back into `simulator`. This
* file imports nothing but the database.
*/
import { eq } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export interface SeasonRaceCounts {
completed: number;
remaining: number;
total: number;
}
/**
* How long after the green flag a race is assumed to have finished.
*
* `event_starts_at` is a start time, so treating it as "already run" would
* declare the season over the moment the finale goes green and the simulator
* would publish the pre-race leader as champion at 100%, from standings that do
* not yet include the race being run. No race in these series comes close to
* six hours, and the standings feed updates within hours of a finish.
*/
const RACE_DURATION_MS = 6 * 60 * 60 * 1000;
/**
* Has this race already been run?
*
* `is_complete` wins when an admin has set it, but a racing calendar is stored
* as "Non-Scoring" rows that nobody ever marks complete, so the date is the real
* signal. Mirrors the Upcoming / Results Pending badge on the admin events page.
* A race happening today is still upcoming, and a row with no date at all counts
* as upcoming.
*
* @param today `now` as a `YYYY-MM-DD` string, to compare against the date-only
* `event_date` column.
*/
export function hasRaceRun(
event: {
isComplete: boolean;
eventDate: string | null;
eventStartsAt: Date | string | null;
},
now: Date,
today: string
): boolean {
if (event.isComplete) return true;
if (event.eventStartsAt) {
return new Date(event.eventStartsAt).getTime() + RACE_DURATION_MS < now.getTime();
}
if (event.eventDate) return event.eventDate < today;
return false;
}
/**
* Count the races on a season-standings calendar (F1, IndyCar).
*
* `event_type` has no race value, so a racing calendar is stored as
* `schedule_event` rows the admin default for the `season_standings` scoring
* pattern. The only other row such a season carries is the single
* `final_standings` event that assigns fantasy placements once the championship
* is settled. A race is therefore "every event except `final_standings`", not
* "every event except `schedule_event`" getting that backwards leaves the
* simulator with zero remaining races and no idea the season is in progress.
*/
export async function countSeasonRaces(
sportsSeasonId: string,
now: Date = new Date(),
providedDb?: ReturnType<typeof database>
): Promise<SeasonRaceCounts> {
const db = providedDb || database();
const events = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: {
eventType: true,
isComplete: true,
eventDate: true,
eventStartsAt: true,
},
});
const today = now.toISOString().split("T")[0];
let completed = 0;
let remaining = 0;
for (const event of events) {
if (event.eventType === "final_standings") continue;
if (hasRaceRun(event, now, today)) completed++;
else remaining++;
}
return { completed, remaining, total: completed + remaining };
}

View file

@ -15,10 +15,6 @@ import {
sourceEloRequirementLabel,
} from "~/services/simulations/input-policy";
import { SIMULATOR_TYPES, type SimulatorType } from "~/services/simulations/registry";
import { countSeasonRaces } from "~/models/season-races";
/** Simulator types driven by a race calendar plus championship standings. */
const RACE_CALENDAR_SIMULATORS: SimulatorType[] = ["f1_standings", "indycar_standings"];
export interface SimulatorProfile extends SimulatorManifestProfile {
isActive: boolean;
@ -359,28 +355,18 @@ export async function batchUpsertParticipantSimulatorInputs(
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
// tell readers whether the stored Elo/rating is generated vs. a trusted
// direct value. Two rules apply, and both always apply — they are not
// alternatives:
//
// 1. Drop the method flag for any column receiving a fresh direct
// value, otherwise a stale "generated" flag would cause that
// newly-entered Elo/rating to be filtered out as derived (see
// getParticipantSimulatorInputs).
// 2. Merge any metadata the caller supplied over the result
// (prepareSimulatorInputsForRun and the projection importers set the
// correct flags) — a merge rather than a replace so a caller that
// only needs to stamp one method flag does not wipe unrelated keys.
//
// Ordering matters: strip first, then merge, so a caller stamping one flag
// still gets the other column's stale flag cleared. Running these as
// exclusive CASE branches instead would mean a bulk row carrying both a
// direct `rating` and a `projectedWins` (which stamps sourceEloMethod)
// silently kept a stale ratingMethod, hiding the rating it just set.
metadata: sql`(
COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
// direct value. When a caller supplies explicit metadata, use it as-is
// (prepareSimulatorInputsForRun and the projection importer set the
// correct flags). Otherwise preserve existing metadata, but drop the
// method flag for any column receiving a fresh direct value — otherwise a
// stale "generated" flag would cause that newly-entered Elo/rating to be
// filtered out as derived (see getParticipantSimulatorInputs).
metadata: sql`CASE
WHEN excluded.metadata IS NOT NULL THEN excluded.metadata
ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
) || COALESCE(excluded.metadata, '{}'::jsonb)`,
END`,
updatedAt: sql`excluded.updated_at`,
},
});
@ -502,19 +488,6 @@ export async function validateSimulatorReadiness(
}
}
if (RACE_CALENDAR_SIMULATORS.includes(config.simulatorType)) {
// Without a calendar the simulator cannot tell how many races are left, so
// it falls back to futures odds and ignores the championship standings
// entirely. A warning, not a blocker — a season drafted before the schedule
// is published still needs to run.
const races = await countSeasonRaces(sportsSeasonId);
if (races.total === 0) {
warnings.push(
"No race calendar found for this season. Add the schedule on the events page — until then the simulation uses futures odds only and ignores championship standings."
);
}
}
if (config.profile.setupSections.includes("regularStandings")) {
warnings.push("Regular-season standings may be needed for in-season accuracy.");
}

View file

@ -5,7 +5,6 @@ import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
import { logger } from "~/lib/logger";
import { getParticipantEV } from "./participant-expected-value";
import { getBracketTemplateIdForSportsSeason } from "./bracket-template";
import { calculateEV } from "~/services/ev-calculator";
// Re-export types from shared types file
@ -164,7 +163,11 @@ export async function getTeamScoreBreakdown(
if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId) ?? null;
}
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId;
}

View file

@ -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 58 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 58 into two tiers
* (llws_20, afl_10) it reported a team locked into 5th6th and a team locked into
* 7th8th 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 5th6th tier 25 points, not 20", () => {
expect(evFromProbs({ ...ZERO, probFifth: "0.5", probSixth: "0.5" })).toBe(25);
});
it("gives a team locked into the 7th8th tier 15 points, not 20", () => {
expect(evFromProbs({ ...ZERO, probSeventh: "0.5", probEighth: "0.5" })).toBe(15);
});
it("still gives a single 5th8th 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);
});
});

View file

@ -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");
});
});

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import { newlyDecidedLosers } from "../admin.sports-seasons.$id.events.$eventId.bracket.helpers";
describe("newlyDecidedLosers", () => {
it("returns losers of matches that were not already complete", () => {
expect(
newlyDecidedLosers([
{ wasComplete: false, loserId: "sp-1" },
{ wasComplete: false, loserId: "sp-2" },
])
).toEqual(["sp-1", "sp-2"]);
});
it("drops re-scores of already-complete matches (no re-announce)", () => {
// sp-1's match just finished; sp-2's match was already complete before this
// action — only sp-1 is a NEW knockout.
expect(
newlyDecidedLosers([
{ wasComplete: false, loserId: "sp-1" },
{ wasComplete: true, loserId: "sp-2" },
])
).toEqual(["sp-1"]);
});
it("skips entries with an unknown loser", () => {
expect(
newlyDecidedLosers([
{ wasComplete: false, loserId: null },
{ wasComplete: false, loserId: "sp-3" },
])
).toEqual(["sp-3"]);
});
it("returns an empty array when nothing was newly decided", () => {
expect(
newlyDecidedLosers([
{ wasComplete: true, loserId: "sp-1" },
{ wasComplete: true, loserId: "sp-2" },
])
).toEqual([]);
expect(newlyDecidedLosers([])).toEqual([]);
});
});

View file

@ -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();
});
});

View file

@ -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",
});
});
});

View file

@ -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",
});
});
});

View file

@ -31,7 +31,7 @@ import {
projectedWinsToElo,
} from '~/services/probability-engine';
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
import { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from '~/models/simulator';
import { getSportsSeasonSimulatorConfig } from '~/models/simulator';
// Simulator types that use worldRanking in addition to sourceElo
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
@ -80,38 +80,13 @@ export async function loader({ params }: Route.LoaderArgs) {
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
const simulatorInputs = await getParticipantSimulatorInputs(sportsSeasonId);
// The projection a participant was actually saved with. Read it back verbatim:
// deriving the field from the stored Elo instead (as this page used to) shows the
// admin a different number than they typed, because wins → Elo rounds to an
// integer Elo and a simulation run then re-resolves that Elo through the input
// policy (clamping, and blending in futures odds when a season has them).
const projectionsByParticipant = new Map(
simulatorInputs.map((input) => [
input.participantId,
{ projectedWins: input.projectedWins, projectedTablePoints: input.projectedTablePoints },
])
);
const existingData: Record<
string,
{ elo: number | null; ranking: number | null; projectedWins: number | null; projectedTablePoints: number | null }
> = {};
for (const participant of participants) {
const projection = projectionsByParticipant.get(participant.id);
existingData[participant.id] = {
elo: null,
ranking: null,
projectedWins: projection?.projectedWins ?? null,
projectedTablePoints: projection?.projectedTablePoints ?? null,
};
}
const existingData: Record<string, { elo: number | null; ranking: number | null }> = {};
for (const ev of existingEVs) {
const existing = existingData[ev.participantId];
if (!existing) continue;
existing.elo = ev.sourceElo ?? null;
existing.ranking = ev.worldRanking ?? null;
existingData[ev.participantId] = {
elo: ev.sourceElo ?? null,
ranking: ev.worldRanking ?? null,
};
}
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
@ -277,16 +252,7 @@ export default function AdminSportsSeasonEloRatings() {
if (simulatorConfig) {
participants.forEach(p => {
const d = existingData[p.id];
// A stored projection is shown exactly as it was entered. Only fall back to
// deriving it from the Elo when this season has no projection saved (a
// season that has only ever had Elos entered still gets a useful starting
// point) — that derived value is lossy and must never overwrite a real one.
const stored = simulatorConfig.projectionInput === 'tablePoints'
? d?.projectedTablePoints
: d?.projectedWins;
if (stored !== null && stored !== undefined) {
initial[p.id] = stored.toString();
} else if (d?.elo !== null && d?.elo !== undefined) {
if (d?.elo !== null && d?.elo !== undefined) {
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
: eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
@ -299,8 +265,8 @@ export default function AdminSportsSeasonEloRatings() {
const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<{
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }>;
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }>;
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>;
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }>;
} | null>(null);
function findParticipantMatch(inputName: string) {
@ -325,8 +291,8 @@ export default function AdminSportsSeasonEloRatings() {
function parseBulkText() {
const lines = bulkText.split('\n');
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }> = [];
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }> = [];
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = [];
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }> = [];
const seen = new Set<string>();
for (const line of lines) {
@ -349,9 +315,9 @@ export default function AdminSportsSeasonEloRatings() {
const participant = findParticipantMatch(inputName);
if (participant && !seen.has(participant.id)) {
seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, projection: projectedWins, inputName });
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, inputName });
} else if (!participant) {
unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
unmatched.push({ inputName, elo, ranking: null });
}
} else {
const match = usesRanking
@ -376,9 +342,9 @@ export default function AdminSportsSeasonEloRatings() {
const participant = findParticipantMatch(inputName);
if (participant && !seen.has(participant.id)) {
seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, projection: null, inputName });
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName });
} else if (!participant) {
unmatched.push({ inputName, elo, ranking, projection: null });
unmatched.push({ inputName, elo, ranking });
}
}
}
@ -394,11 +360,11 @@ export default function AdminSportsSeasonEloRatings() {
for (const m of parseResults.matched) {
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
// The pasted number goes in as typed. Round-tripping it through the derived
// Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
// came back as 95.1 before anything was even saved.
if (inputMode === 'projectedWins' && m.projection !== null) {
newWins[m.participantId] = m.projection.toString();
if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) {
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
).toFixed(1);
}
}
setEloValues(newElos);
@ -523,10 +489,7 @@ Mark Selby, 2432`
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
<span className="text-muted-foreground">{m.inputName}</span>
<span className="font-medium">
{m.name} &rarr;{' '}
{m.projection !== null
? `${m.projection} ${projectionUnit} (Elo ${m.elo})`
: m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
{m.name} &rarr; {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
</span>
</div>
@ -546,9 +509,7 @@ Mark Selby, 2432`
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
<span>{u.inputName}</span>
<span className="font-medium">
{u.projection !== null
? `${u.projection} ${projectionUnit} (Elo ${u.elo})`
: u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
{u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
</span>
</div>
@ -579,7 +540,7 @@ Mark Selby, 2432`
</CardTitle>
<CardDescription>
{inputMode === 'projectedWins'
? `Enter each team's projected total season ${projectionUnit} — the number you enter is stored as-is and re-derives the Elo on every run. Mid-season it is treated as a projected final total, so the simulation spreads the difference over the games still to play. Saving will run the simulation and update expected values.`
? `Enter each team's projected total season ${projectionUnit}. Converted to Elo automatically. Saving will run the simulation and update expected values.`
: usesRanking
? `Enter each ${participantLabel.toLowerCase()}'s Elo${allowsRankOnly ? ' (optional)' : ''} and ${rankLabel}. Saving will automatically run the simulation and update expected values.`
: `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`}

View file

@ -1,25 +0,0 @@
/**
* Pure helpers for the bracket-scoring route action, extracted so the
* knockout-detection logic can be unit-tested without the full action harness.
*/
/**
* From a set of matches being scored this action, return the season_participant
* ids knocked out for the FIRST time. A loser counts only when its match was NOT
* already complete a re-score/correction of a finished match must not
* re-announce the exit (mirrors populateBracketFromDraw's first-completion rule in
* the live-sync path). Entries with an unknown loser (`null`) are skipped.
*
* The ids are season_participant ids of the primary window (playoff_matches are
* keyed by season_participant); the fan-out translates them to each mirror
* window's own season_participant id before announcing the "Knocked Out" section.
*/
export function newlyDecidedLosers(
entries: Array<{ wasComplete: boolean; loserId: string | null }>
): string[] {
return entries
.filter((e): e is { wasComplete: boolean; loserId: string } =>
!e.wasComplete && e.loserId !== null
)
.map((e) => e.loserId);
}

View file

@ -9,15 +9,12 @@ import {
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import {
findPlayoffMatchesByEventId,
deletePlayoffMatchesByEventId,
generateBracketFromTemplate,
setMatchWinner,
advanceWinnerTemplate,
findPlayoffMatchById,
assignParticipantsToKnockout,
doesLoserAdvance,
reseedAflEliminationFinals,
reseedAflSemiFinals,
} from "~/models/playoff-match";
import {
createGame,
@ -38,7 +35,6 @@ import {
recalculateAffectedLeagues,
recalculateStandings,
autoCompleteRoundIfDone,
applyBracketEntryFloors,
} from "~/models/scoring-calculator";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
@ -46,7 +42,6 @@ import {
setParticipantResult,
findParticipantResultsBySportsSeasonId,
deleteParticipantResultsBySportsSeasonId,
deleteParticipantResultsForParticipants,
} from "~/models/participant-result";
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
import { createDailySnapshot } from "~/models/standings";
@ -70,10 +65,9 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis";
import { newlyDecidedLosers } from "./admin.sports-seasons.$id.events.$eventId.bracket.helpers";
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id);
@ -141,23 +135,9 @@ async function scoreQualifyingBracket(
tournamentId: string | null;
},
db: ReturnType<typeof database>,
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2],
/**
* Season_participant ids of players knocked out for the first time by this
* operation (losers of matches that just reached completion). Forwarded to the
* fan-out so every mirror window announces the "Knocked Out" section a
* non-scoring-round exit earns no QP and is otherwise invisible to the mirror.
* Empty for re-scores/reprocesses, which decide no new losers.
*/
newlyEliminatedParticipantIds?: Set<string>
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2]
): Promise<void> {
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
// recalcs participant QP totals, AND announces the QP change to this window's leagues.
// Calling processQualifyingBracketEvent directly here would score silently — the QP
// Discord notification only fires from processQualifyingEvent. The fan-out below skips
// this (primary) window via skipEventId, so mirror windows are announced separately
// with no double-post.
await processQualifyingEvent(event.id, db, { newlyEliminatedParticipantIds });
await processQualifyingBracketEvent(event.id, db);
await recalculateAffectedLeagues(
event.sportsSeasonId,
db,
@ -165,16 +145,13 @@ async function scoreQualifyingBracket(
);
// If this is the shared major's primary window, propagate to siblings.
// Mid-tournament (a single round): don't mark complete yet.
await fanOutMajorIfPrimary(event, {
markComplete: false,
newlyEliminatedParticipantIds,
});
await fanOutMajorIfPrimary(event, { markComplete: false });
}
/**
* Mark the given participants as eliminated (finalPosition = 0) and, for fantasy
* (non-qualifying) events, announce the teams newly eliminated by this run to the
* affected leagues' Discord channels.
* affected leagues' Discord channels. Returns the number of participants marked.
*
* "Newly eliminated" = participants with no prior result row, so re-running a
* generation step never re-announces the same teams. The announcement is a
@ -182,18 +159,11 @@ async function scoreQualifyingBracket(
* the eliminations themselves are already committed. eventId is deliberately
* omitted from the recalc call so the announcement doesn't pull in unrelated
* completed matches as "Scored Matches".
*
* Returns the number of participants marked alongside whether a standings recalculation
* actually ran. The caller banks entry floors before calling this and needs them to
* reach teamStandings.totalPoints; it cannot infer that from the participant count,
* because the recalc is skipped for qualifying events, when every eliminated team
* already had a result row (the second run of a generation), and when the announcement
* threw. `recalculated` reports the fact rather than making the caller re-derive it.
*/
async function markEliminatedAndAnnounce(
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
participantIds: string[]
): Promise<{ markedCount: number; recalculated: boolean }> {
): Promise<number> {
const existingResults = await findParticipantResultsBySportsSeasonId(event.sportsSeasonId);
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
const newlyEliminatedIds = participantIds.filter((id) => !alreadyHadResult.has(id));
@ -202,8 +172,6 @@ async function markEliminatedAndAnnounce(
await setParticipantResult(participantId, event.sportsSeasonId, 0);
}
let recalculated = false;
// QPs (e.g. tennis/CS2 majors) don't get elimination announcements.
if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) {
try {
@ -211,13 +179,12 @@ async function markEliminatedAndAnnounce(
eventName: event.name ?? undefined,
eliminatedParticipantIds: newlyEliminatedIds,
});
recalculated = true;
} catch (err) {
logger.error("[Eliminations] Discord announcement failed:", err);
}
}
return { markedCount: participantIds.length, recalculated };
return participantIds.length;
}
export async function action({ request, params }: Route.ActionArgs) {
@ -303,50 +270,6 @@ export async function action({ request, params }: Route.ActionArgs) {
}
}
// The only way to repair a mis-seeded bracket: nothing else can rewrite a match's
// participants. Clearing brings back the setup form, so the admin re-seeds from there.
if (intent === "clear-bracket") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" };
const existing = await findPlayoffMatchesByEventId(params.eventId);
if (existing.length === 0) {
return { error: "This event has no bracket to clear" };
}
// Clearing discards recorded results, so make the admin confirm once games have
// actually been played.
const completed = existing.filter((m) => m.isComplete).length;
if (completed > 0 && formData.get("confirm") !== "true") {
return {
error: `This bracket has ${completed} completed match(es). Confirm to discard those results.`,
};
}
await deletePlayoffMatchesByEventId(params.eventId);
// Placements are deliberately left alone. seasonParticipantResults is keyed by
// sports season, not by event, so a season-wide delete here would wipe the
// placements of every other event in the season with nothing to rebuild them —
// and on a finalized qualifying season that means permanently zeroed standings.
// Reprocess Bracket already rebuilds placements correctly, qualifying path
// included, so point the admin at it once the new bracket is in place.
const note =
completed > 0
? " Run Reprocess Bracket after rebuilding to clear the placements those results produced."
: "";
return {
success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.${note}`,
};
} catch (error) {
logger.error("Error clearing bracket:", error);
return {
error: error instanceof Error ? error.message : "Failed to clear bracket",
};
}
}
if (intent === "generate-bracket") {
const templateId = formData.get("templateId");
@ -410,24 +333,6 @@ export async function action({ request, params }: Route.ActionArgs) {
try {
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
// The template ID has to land on the event before entry floors can be derived
// (getBracketEntryFloor reads it), so persist it here rather than after the
// elimination pass below.
await updateScoringEvent(params.eventId, {
bracketTemplateId: templateId,
scoringStartsAtRound: template.scoringStartsAtRound,
bracketRegionConfig: regionOverride,
});
// Some seedings guarantee points before a ball is bounced — an AFL top-4 seed
// has the double chance, so the 5th-6th tier is locked in at generation. Bank
// those provisional floors now, ahead of the elimination announcement below so
// the standings it posts already reflect them.
const entryFloorCount = await applyBracketEntryFloors(params.eventId);
if (entryFloorCount > 0) {
logger.log(`[BracketGeneration] Applied entry floors to ${entryFloorCount} participant(s)`);
}
// PHASE 5.3: Mark participants NOT in the bracket as eliminated (and announce).
const event = await getScoringEventById(params.eventId);
if (event) {
@ -436,23 +341,16 @@ export async function action({ request, params }: Route.ActionArgs) {
const toEliminate = allParticipants
.filter((p) => !participantsInBracket.has(p.id))
.map((p) => p.id);
const { markedCount, recalculated } = await markEliminatedAndAnnounce(event, toEliminate);
logger.log(`[BracketGeneration] Marked ${markedCount} participants as eliminated`);
const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate);
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" };
} catch (error) {
@ -500,13 +398,6 @@ export async function action({ request, params }: Route.ActionArgs) {
return { error: "Could not determine loser" };
}
// A knockout is "newly decided" only when the match wasn't already complete
// (mirrors populateBracketFromDraw's first-completion rule) — a re-score/
// correction of an already-finished match must not re-announce the exit.
const setWinnerNewlyEliminated = new Set(
newlyDecidedLosers([{ wasComplete: match.isComplete, loserId }])
);
// Set the winner
await setMatchWinner(matchId, winnerId, loserId);
@ -533,16 +424,11 @@ export async function action({ request, params }: Route.ActionArgs) {
if (event.isQualifyingEvent) {
// Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not
// fantasy points. matchIds scopes the Discord notification to just this match.
await scoreQualifyingBracket(
event,
db,
{
await scoreQualifyingBracket(event, db, {
eventId: event.id,
eventName: event.name ?? undefined,
matchIds: [matchId],
},
setWinnerNewlyEliminated
);
});
} else {
// Immediately score this match: loser gets their final placement,
// winner gets provisional floor points (isPartialScore=true).
@ -614,10 +500,6 @@ export async function action({ request, params }: Route.ActionArgs) {
let successCount = 0;
const errors: string[] = [];
const processedMatchIds: string[] = [];
// Per-match completion + loser, collected so newlyDecidedLosers() can pick out
// the batch's first-time knockouts to fan out to mirror windows (a non-scoring-
// round exit earns no QP and is otherwise invisible to the mirror).
const decidedEntries: Array<{ wasComplete: boolean; loserId: string | null }> = [];
for (const { matchId, winnerId } of winnerAssignments) {
try {
@ -686,10 +568,6 @@ export async function action({ request, params }: Route.ActionArgs) {
successCount++;
processedMatchIds.push(matchId);
// Only after the match fully succeeded: record its prior completion so
// newlyDecidedLosers() announces this loser only if it's a first-time exit
// (and never for a match whose write failed above).
decidedEntries.push({ wasComplete: match.isComplete, loserId });
} catch (error) {
logger.error(`Error setting winner for match ${matchId}:`, error);
errors.push(
@ -703,14 +581,9 @@ export async function action({ request, params }: Route.ActionArgs) {
// previously completed matches in the event.
if (successCount > 0) {
const db = database();
// Qualifying majors: derive QP from the full bracket once for the batch, and
// announce the QP change to this window's leagues. processQualifyingEvent (not
// processQualifyingBracketEvent) is what sends the QP Discord notification; the
// fan-out below skips this window (skipEventId) so mirrors don't double-post.
// Qualifying majors: derive QP from the full bracket once for the batch.
if (event.isQualifyingEvent) {
await processQualifyingEvent(event.id, db, {
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
});
await processQualifyingBracketEvent(event.id, db);
}
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
// when computing projected points.
@ -725,12 +598,8 @@ export async function action({ request, params }: Route.ActionArgs) {
if (!event.isQualifyingEvent) {
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
} else {
// Shared major primary window: propagate this round to siblings, carrying
// the batch's newly-decided knockouts so mirrors announce them too.
await fanOutMajorIfPrimary(event, {
markComplete: false,
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
});
// Shared major primary window: propagate this round to siblings.
await fanOutMajorIfPrimary(event, { markComplete: false });
}
}
@ -868,101 +737,6 @@ export async function action({ request, params }: Route.ActionArgs) {
}
}
// Re-seed the AFL Wildcard winners into the Elimination Finals they belong in.
// Advancement does this on every Wildcard result, so this is only needed for a
// bracket advanced before that rule existed: the winners sit in the wrong games and
// no admin action re-runs advancement (a completed match cannot be re-submitted).
if (intent === "reseed-afl-wildcard") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" };
if (event.bracketTemplateId !== "afl_10") {
return { error: "This action only applies to AFL finals brackets" };
}
const participants = await findParticipantsBySportsSeasonId(params.id);
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
const reseed = await reseedAflEliminationFinals(params.eventId);
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
return {
success:
"Elimination Finals already match the Wildcard results — nothing to re-seed.",
};
}
// Only the qualifier slots move, so there is nothing to re-score: no placement,
// score or elimination changes, and so nothing to announce.
const moves = reseed.filled
.toSorted((a, b) => a.matchNumber - b.matchNumber)
.map((slot) => `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`)
.join(", ");
return {
success: `Re-seeded the Elimination Finals: ${moves}.`,
};
} catch (error) {
logger.error("Error re-seeding AFL Wildcard winners:", error);
return {
error:
error instanceof Error ? error.message : "Failed to re-seed the Elimination Finals",
};
}
}
// Put the Elimination Final winners in the Semi-Finals they belong in. Elimination
// Final n feeds Semi-Final n, but brackets advanced before that was fixed crossed the
// two winners, and no admin action re-runs advancement (a completed match cannot be
// re-submitted).
if (intent === "reseed-afl-semifinals") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" };
if (event.bracketTemplateId !== "afl_10") {
return { error: "This action only applies to AFL finals brackets" };
}
const participants = await findParticipantsBySportsSeasonId(params.id);
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
const reseed = await reseedAflSemiFinals(params.eventId);
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
return {
success:
"Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
};
}
// Only the qualifier slots move, so there is nothing to re-score: no placement,
// score or elimination changes, and so nothing to announce.
//
// A slot can be vacated without being refilled — un-recording an Elimination Final
// result takes its winner back out — so report those too rather than rendering an
// empty list.
const filled = reseed.filled.map((slot) => ({
matchNumber: slot.matchNumber,
text: `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`,
}));
const emptied = reseed.vacated
.filter((matchNumber) => !reseed.filled.some((slot) => slot.matchNumber === matchNumber))
.map((matchNumber) => ({ matchNumber, text: `match ${matchNumber} is back to TBD` }));
const moves = [...filled, ...emptied]
.toSorted((a, b) => a.matchNumber - b.matchNumber)
.map((move) => move.text)
.join(", ");
return {
success: `Re-seeded the Semi-Finals: ${moves}.`,
};
} catch (error) {
logger.error("Error re-seeding AFL Elimination Finals winners:", error);
return {
error:
error instanceof Error ? error.message : "Failed to re-seed the Semi-Finals",
};
}
}
if (intent === "reprocess-bracket") {
try {
const event = await getScoringEventById(params.eventId);
@ -989,68 +763,22 @@ export async function action({ request, params }: Route.ActionArgs) {
// skipDiscord: reprocess is a data-correction tool, not a result announcement.
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
}
// Re-propagate corrected QP to sibling/mirror windows (data-correction; not
// final). Call syncMajorFromPrimaryEvent directly rather than the
// swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the
// mirrors were re-scored — a silent failure here is exactly how mirrors got
// left showing stale QP behind a green "success".
const baseMessage = `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`;
if (event.isPrimary && event.tournamentId) {
try {
const report = await syncMajorFromPrimaryEvent(event.id, { markComplete: false });
// Surface a partial fan-out as an error so it renders as a warning
// banner, not a green success the admin might skim past while some
// mirror windows are left stale.
if (report.windowsFailed > 0) {
const reasons = report.failures.map((f) => f.error).join("; ");
// Re-propagate corrected QP to sibling windows (data-correction; not final).
await fanOutMajorIfPrimary(event, { markComplete: false });
return {
error: `${baseMessage} Synced ${report.windowsSynced} mirror window(s), but ${report.windowsFailed} failed (those windows may be stale): ${reasons}`,
success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`,
};
}
return { success: `${baseMessage} Synced ${report.windowsSynced} mirror window(s).` };
} catch (error) {
return {
error: `Primary window recomputed, but mirror fan-out failed (mirror windows may be stale): ${error instanceof Error ? error.message : String(error)}`,
};
}
}
return { success: `${baseMessage} No mirror windows to sync.` };
if (completed.length === 0) {
return { error: "No completed matches to reprocess" };
}
if (matches.length === 0) {
return { error: "No bracket to reprocess" };
}
// Wipe this bracket's participants' results and rebuild from scratch. Deleting
// only the partial rows would leave stale finalized ones, which the "never
// un-finalize" guard in upsertParticipantResult then refuses to correct.
//
// Scoped to the participants this bracket actually holds, not the whole season:
// seasonParticipantResults is keyed by sports season, not by event, so a
// season-wide delete takes every other event's placements with it and only this
// bracket's replay could rebuild them (the hazard clear-bracket documents).
//
// Unconditional, because zero completed matches is precisely the clear-bracket →
// regenerate → reprocess repair path: the discarded bracket's finalized
// placements are exactly what needs clearing, and there is always something to
// rebuild from — the entry floors below, then the replay.
// Delete ALL results for this sports season and rebuild from scratch.
// Only deleting partial rows leaves stale finalized rows that block
// the "never un-finalize" guard in upsertParticipantResult.
const db = database();
// Reused further down to decide who is *not* in the bracket and so eliminated.
const bracketParticipantIds = new Set<string>();
for (const match of matches) {
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
}
await deleteParticipantResultsForParticipants(
event.sportsSeasonId,
[...bracketParticipantIds],
db
);
// Re-bank the seeding-derived floors the delete above wipes (e.g. the AFL
// top-4's 5th-6th tier). Done before the replay so real match results overwrite
// them; a bracket with no completed matches still gets its guaranteed points.
const entryFloorCount = await applyBracketEntryFloors(params.eventId, db);
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
// Replay each completed match in bracket order (earlier rounds first).
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
@ -1091,6 +819,11 @@ export async function action({ request, params }: Route.ActionArgs) {
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
// This covers teams that didn't make the playoffs/play-in tournament.
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
const bracketParticipantIds = new Set<string>();
for (const match of matches) {
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
}
let eliminatedCount = 0;
for (const participant of allParticipants) {
if (!bracketParticipantIds.has(participant.id)) {
@ -1106,12 +839,7 @@ export async function action({ request, params }: Route.ActionArgs) {
// skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement.
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
return {
success:
`Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ` +
`${entryFloorCount} seeded participant(s) given their guaranteed entry floor, ` +
`${eliminatedCount} non-bracket participant(s) eliminated`,
};
return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` };
} catch (error) {
logger.error("Error reprocessing bracket:", error);
return {
@ -1296,10 +1024,7 @@ export async function action({ request, params }: Route.ActionArgs) {
const toEliminate = allParticipants
.filter((p) => !uniqueParticipants.has(p.id))
.map((p) => p.id);
const { markedCount: eliminatedCount } = await markEliminatedAndAnnounce(
groupsEvent,
toEliminate
);
const eliminatedCount = await markEliminatedAndAnnounce(groupsEvent, toEliminate);
return {
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,

View file

@ -613,110 +613,6 @@ export default function EventBracket({
</Card>
)}
{/* Re-seed AFL Wildcard winners. Advancement pairs them by ladder position on
every Wildcard result, so this is only for a bracket advanced before that
rule existed a completed match cannot be re-submitted to re-run it. */}
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Re-seed Wildcard Winners</CardTitle>
<CardDescription>
Pair the Elimination Finals by ladder position: 5th hosts the
lower-ranked Wildcard winner and 6th the higher-ranked one. Only moves
the qualifier slots no results, scores or placements change, and
nothing is announced. Does nothing if the pairings are already right.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="reseed-afl-wildcard" />
<Button type="submit" variant="outline">
Re-seed Wildcard Winners
</Button>
</Form>
</CardContent>
</Card>
)}
{/* Fix the Semi-Final pairings. Elimination Final n feeds Semi-Final n, but
brackets advanced before that was fixed crossed the two winners, and no
admin action re-runs advancement. */}
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Fix Semi-Final Pairings</CardTitle>
<CardDescription>
Feed each Elimination Final into the Semi-Final it belongs to: EF1
winner into SF1 and EF2 winner into SF2. Only moves the qualifier slots
no results, scores or placements change, and nothing is announced.
Does nothing if the pairings are already right.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="reseed-afl-semifinals" />
<Button type="submit" variant="outline">
Fix Semi-Final Pairings
</Button>
</Form>
</CardContent>
</Card>
)}
{/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else
can rewrite a match's participants, so a wrong seeding has to be torn down
and rebuilt via the setup form below, which reappears once this runs. */}
{matches.length > 0 && (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle>Clear Bracket</CardTitle>
<CardDescription>
Delete every match in this bracket so it can be set up again from
scratch. Use this when the wrong participants were seeded. Placements
are left alone run Reprocess Bracket after rebuilding to clear any
that the discarded results produced.
</CardDescription>
</CardHeader>
<CardContent>
<Form
method="post"
className="space-y-3"
onSubmit={(e) => {
if (
!confirm(
`Delete all ${matches.length} match(es) in this bracket? Recorded results will be lost.`
)
) {
e.preventDefault();
}
}}
>
<input type="hidden" name="intent" value="clear-bracket" />
{/* The server refuses to discard completed matches unless this is
checked. Sending it unconditionally from a hidden field would make
that guard unreachable, including for a submit without JS. */}
{matches.some((m: { isComplete: boolean }) => m.isComplete) && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="confirm-clear-bracket"
name="confirm"
value="true"
className="h-4 w-4"
/>
<Label htmlFor="confirm-clear-bracket" className="font-normal">
Yes, discard the results already recorded in this bracket
</Label>
</div>
)}
<Button type="submit" variant="destructive">
Clear Bracket
</Button>
</Form>
</CardContent>
</Card>
)}
{/* ====== SETUP PHASE ====== */}
{showSetup && (
<Card>
@ -993,7 +889,7 @@ export default function EventBracket({
return (
// eslint-disable-next-line react/no-array-index-key
<div key={i} className="flex items-center gap-2">
<Label className="w-28 text-sm text-muted-foreground shrink-0">
<Label className="w-20 text-sm text-muted-foreground shrink-0">
{slotLabel}
</Label>
<div className="flex-1 min-w-0">

View file

@ -6,7 +6,6 @@ import {
batchUpsertParticipantEVs,
getAllParticipantEVsForSeason
} from "~/models/participant-expected-value";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id);
@ -28,6 +27,17 @@ export async function loader({ params }: Route.LoaderArgs) {
};
}
const scoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 45,
pointsFor4th: 45,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
@ -48,7 +58,7 @@ export async function action({ request, params }: Route.ActionArgs) {
probSeventh: parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100,
probEighth: parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100,
},
scoringRules: DEFAULT_SCORING_RULES,
scoringRules,
source: "manual" as const,
}));

View file

@ -19,8 +19,6 @@ import {
TableRow,
} from "~/components/ui/table";
import { ArrowLeft, Calculator } from "lucide-react";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
import { calculateEV } from "~/services/ev-calculator";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Expected Values — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
@ -28,18 +26,9 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
export { loader };
// EV is shown on the same reference scale the runner persists it with: a sports season
// is shared across leagues with different scoring, so DEFAULT_SCORING_RULES is the
// common scale and each league re-derives its own EV from the stored probabilities
// (see getPersistenceContext in services/simulations/runner.ts).
//
// Scoring: 1st=100, 2nd=70, 3rd=50, 4th=40, 5th/6th=25 each, 7th/8th=15 each.
// Sum = 100+70+50+40+25+25+15+15 = 340.
//
// The 5th8th 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 5th6th
// at probFifth=probSixth=0.5 (EV 25) and one locked into 7th8th at
// probSeventh=probEighth=0.5 (EV 15). A flat table reports both as 20.
// DEFAULT scoring values — must match DEFAULT_SCORING_RULES in the simulate route.
// Scoring: 1st=100, 2nd=70, 3rd/4th (FF losers)=45 each, 5th8th (E8 losers)=20 each.
// Sum = 100+70+45+45+20+20+20+20 = 340.
//
// Total EV invariant: Σ EV across all participants = Σ scoring values = 340,
// because each probability column sums to 1.0 across all participants.
@ -47,23 +36,20 @@ export { loader };
// 1. Stale EV records from a prior simulation run (fix: re-run simulation, which now
// zeros non-bracket participants automatically)
// 2. DB precision truncation (numeric(6,4) = 4dp; max drift ≈ ±1 for 68 teams)
export function evFromProbs(ev: {
const SCORING = [100, 70, 45, 45, 20, 20, 20, 20] as const;
function evFromProbs(ev: {
probFirst: string; probSecond: string; probThird: string; probFourth: string;
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
}): number {
return calculateEV(
{
probFirst: parseFloat(ev.probFirst),
probSecond: parseFloat(ev.probSecond),
probThird: parseFloat(ev.probThird),
probFourth: parseFloat(ev.probFourth),
probFifth: parseFloat(ev.probFifth),
probSixth: parseFloat(ev.probSixth),
probSeventh: parseFloat(ev.probSeventh),
probEighth: parseFloat(ev.probEighth),
},
DEFAULT_SCORING_RULES
);
return parseFloat(ev.probFirst) * SCORING[0]
+ parseFloat(ev.probSecond) * SCORING[1]
+ parseFloat(ev.probThird) * SCORING[2]
+ parseFloat(ev.probFourth) * SCORING[3]
+ parseFloat(ev.probFifth) * SCORING[4]
+ parseFloat(ev.probSixth) * SCORING[5]
+ parseFloat(ev.probSeventh) * SCORING[6]
+ parseFloat(ev.probEighth) * SCORING[7];
}
function fmt(val: string | number) {

View file

@ -8,8 +8,7 @@ import { batchUpsertParticipantEVs } from '~/models/participant-expected-value';
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
import { calculateEV } from '~/services/ev-calculator';
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
import { recalculateStandings } from '~/models/scoring-calculator';
import { database } from '~/database/context';
import * as schema from '~/database/schema';
@ -29,6 +28,17 @@ import { useEffect, useRef, useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
const DEFAULT_SCORING_RULES: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 45,
pointsFor4th: 45,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Golf Skills — ${data?.sportsSeason?.name ?? 'Sports Season'} - Brackt Admin` }];
}

View file

@ -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];
}

View file

@ -1,4 +1,3 @@
import { useMemo, useState } from "react";
import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
@ -26,23 +25,12 @@ import {
type UpsertParticipantSimulatorInput,
} from "~/models/simulator";
import { normalizeName } from "~/lib/fuzzy-match";
import {
simulatorInputLabel,
type SimulatorInputKey,
} from "~/services/simulations/manifest";
import {
getSimulatorInputPolicy,
resolveRatings,
resolveSourceElos,
type MissingEloStrategy,
type MissingRatingStrategy,
} from "~/services/simulations/input-policy";
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
import {
parseBaseEloPriorityChoice,
projectionMethodMetadata,
resolvedInputMethodLabel,
} from "./admin.sports-seasons.$id.simulator.helpers";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Simulator Setup - ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
@ -73,73 +61,7 @@ export async function loader({ params }: Route.LoaderArgs) {
const inputPolicy = getSimulatorInputPolicy(config.config);
// Sport-aware preview columns: the intersection of the displayable numeric keys
// with this simulator's required + optional inputs, so each season shows exactly
// the inputs its simulator consumes (F1 = odds, NBA = Elo, NCAA = rating, ...).
// Resolved here (server-only) so the client bundle never imports the simulator
// manifest/registry, which transitively pulls in `.server` modules.
const relevantInputs = new Set<SimulatorInputKey>([
...config.profile.requiredInputs,
...config.profile.optionalInputs,
]);
// The Elo each participant will actually run with, and which source produced it.
// Without this the preview is misleading: getParticipantSimulatorInputs blanks a
// generated Elo (so it is re-derived rather than frozen), which reads as "nothing
// saved" — and a raw Elo silently beating a projection is invisible.
//
// Keyed off `relevantInputs`, not requiredInputs: the preview renders these
// columns solely from these maps, so gating on "required" would blank a stored
// value for every simulator that treats the input as optional (playoff_bracket
// and ncaam_bracket for Elo, golf_qualifying_points for rating).
const resolvedEloRows = Object.fromEntries(
relevantInputs.has("sourceElo")
? [...resolveSourceElos(inputs, config.profile, config.config).values()].map((resolved) => [
resolved.participantId,
{ sourceElo: resolved.sourceElo, method: resolved.method },
])
: []
);
// Same for ratings, which are blanked by the same rule when generated. The
// preview's "missing a required input" marker reads both, so it agrees with
// readiness instead of flagging every participant a projection resolved.
const resolvedRatingRows = Object.fromEntries(
relevantInputs.has("rating")
? [...resolveRatings(inputs, config.profile, config.config).values()].map((resolved) => [
resolved.participantId,
{ rating: resolved.rating, method: resolved.method },
])
: []
);
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
key,
label: simulatorInputLabel(key),
required: config.profile.requiredInputs.includes(key),
}));
// The projection this simulator can derive Elo from, labelled here for the same
// reason as inputColumns: calling simulatorInputLabel from the rendered component
// would pull the manifest (and through it the registry and every simulator) into
// the client bundle.
const projectionEloKey = (config.profile.derivableInputs?.sourceElo ?? []).find(
(key) => key === "projectedWins" || key === "projectedTablePoints"
);
const projectionEloOption = projectionEloKey
? { key: projectionEloKey, label: simulatorInputLabel(projectionEloKey) }
: null;
return {
sportsSeason,
participants,
config,
inputRows,
readiness,
inputPolicy,
inputColumns,
resolvedEloRows,
resolvedRatingRows,
projectionEloOption,
};
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy };
}
interface ActionData {
@ -147,24 +69,6 @@ interface ActionData {
message: string;
}
/**
* Input keys the participant preview can render as a numeric column, in the order
* they appear. Keys not listed (e.g. `region`, `metadata`) are not shown as
* columns; the visible columns for a season are the intersection of this order
* with the simulator's required + optional inputs.
*/
const DISPLAY_INPUT_ORDER: SimulatorInputKey[] = [
"sourceElo",
"sourceOdds",
"worldRanking",
"rating",
"projectedWins",
"projectedTablePoints",
"seed",
];
const PARTICIPANT_PAGE_SIZE = 50;
/**
* Engine knobs that a simulator (or the shared input-policy resolver) actually
* reads from config. The structured Engine fields are limited to these so the UI
@ -182,7 +86,6 @@ const HONORED_ENGINE_KNOBS = new Set([
"baseDrawRate",
"drawDecay",
"ratingScaleFactor",
"projectedWinsWeight",
]);
function parseOptionalNumber(value: string | undefined): number | null {
@ -291,28 +194,17 @@ function parseInputCsv(
continue;
}
const sourceElo = parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined;
const projectedWins = parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined;
const projectedTablePoints = parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined;
inputs.push({
participantId,
sportsSeasonId,
sourceElo,
sourceElo: parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined,
sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined,
worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined,
rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined,
projectedWins,
projectedTablePoints,
projectedWins: parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined,
projectedTablePoints: parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined,
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined,
region: cols[indexes.get("region") ?? -1] || undefined,
// A row that supplies a projection but no explicit Elo means "derive the Elo
// from this projection". Stamping the method flag marks whatever Elo is
// already stored as generated, so getParticipantSimulatorInputs hides it and
// resolveSourceElos re-derives from the projection instead of letting a stale
// Elo win the baseEloPriority race. Mirrors the Elo Ratings page's
// projections mode.
metadata: projectionMethodMetadata(sourceElo, projectedWins, projectedTablePoints),
});
}
@ -382,7 +274,6 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
...currentPolicy,
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
baseEloPriority: parseBaseEloPriorityChoice(formData.get("baseEloPriority"), currentPolicy.baseEloPriority),
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
@ -455,7 +346,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const isSubmitting = navigation.state === "submitting";
const setupSections = config.profile.setupSections;
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
const projectionsOutrankElo = inputPolicy.baseEloPriority[0] !== "sourceElo";
const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
const showsInputPolicy =
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
@ -467,51 +357,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
.filter(([key, value]) => HONORED_ENGINE_KNOBS.has(key) && typeof value === "number")
.map(([key, value]) => [key, value as unknown as number] as [string, number]);
// Preview columns are resolved server-side in the loader (see note there) and
// arrive as plain data, so this client component never imports the manifest.
const { inputColumns, resolvedEloRows, resolvedRatingRows, projectionEloOption } = loaderData;
const requiredInputs = config.profile.requiredInputs;
const gridTemplate = `2fr repeat(${Math.max(inputColumns.length, 1)}, 1fr)`;
// For this sport the inputs live on a dedicated page, not the shared bulk paste.
const externalInputsSection = requiredInputs.length === 0
? (setupSections.includes("surfaceElo")
? { label: "Surface Elo", to: `/admin/sports-seasons/${sportsSeason.id}/surface-elo` }
: setupSections.includes("golfSkills")
? { label: "Golf Skills", to: `/admin/sports-seasons/${sportsSeason.id}/golf-skills` }
: null)
: null;
// A required Elo/rating counts as present when the input policy resolves one,
// not only when it is stored directly: getParticipantSimulatorInputs deliberately
// blanks a generated value so it is re-derived each run, so reading the raw input
// alone would mark every projection-configured participant as missing.
const isRowIncomplete = (participantId: string, input: (typeof inputRows)[number]["input"]) =>
requiredInputs.some((key) => {
if (input?.[key] !== null && input?.[key] !== undefined) return false;
if (key === "sourceElo") return resolvedEloRows[participantId] === undefined;
if (key === "rating") return resolvedRatingRows[participantId] === undefined;
return true;
});
const [search, setSearch] = useState("");
const [onlyMissing, setOnlyMissing] = useState(false);
const [page, setPage] = useState(0);
const filteredRows = useMemo(() => {
const normalizedSearch = normalizeName(search);
return inputRows.filter(({ participant, input }) => {
if (normalizedSearch && !normalizeName(participant.name).includes(normalizedSearch)) return false;
if (onlyMissing && !isRowIncomplete(participant.id, input)) return false;
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputRows, search, onlyMissing, requiredInputs, resolvedEloRows, resolvedRatingRows]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PARTICIPANT_PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pageStart = safePage * PARTICIPANT_PAGE_SIZE;
const pageRows = filteredRows.slice(pageStart, pageStart + PARTICIPANT_PAGE_SIZE);
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4">
@ -663,26 +508,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
this Elo they are not blended again per game.
</p>
</div>
{projectionEloOption && (
<div className="space-y-2 md:col-span-5">
<Label htmlFor="baseEloPriority">Base Elo Source</Label>
<select
id="baseEloPriority"
name="baseEloPriority"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={projectionsOutrankElo ? "projectionsFirst" : "eloFirst"}
>
<option value="eloFirst">Entered Elo first, then {projectionEloOption.label}</option>
<option value="projectionsFirst">{projectionEloOption.label} first, then entered Elo</option>
</select>
<p className="text-xs text-muted-foreground">
Raw Elo and projections are substitutes the first one a participant has wins, and
the other is ignored (futures odds are separate and blend on top via the weight above).
Pick <strong>{projectionEloOption.label} first</strong> when projections are
the source of truth for this season and a previously entered Elo should not override them.
</p>
</div>
)}
{config.profile.requiredInputs.includes("sourceElo") && (
<>
<div className="space-y-2 md:col-span-2">
@ -811,153 +636,29 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</Button>
</Form>
{externalInputsSection && (
<div className="rounded-md border border-sky-500/30 bg-sky-500/10 p-3 text-sm flex items-center justify-between gap-4">
<span>
This simulator's participant inputs are managed on the{" "}
<strong>{externalInputsSection.label}</strong> page the list below is a roster only.
</span>
<Button variant="outline" size="sm" asChild>
<Link to={externalInputsSection.to}>Go to {externalInputsSection.label}</Link>
</Button>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<Input
type="search"
placeholder="Search participants…"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(0);
}}
className="max-w-xs"
/>
{requiredInputs.length > 0 && (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={onlyMissing}
onChange={(event) => {
setOnlyMissing(event.target.checked);
setPage(0);
}}
/>
Only show participants missing a required input
</label>
)}
</div>
<div className="rounded-md border">
<div
className="grid gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground"
style={{ gridTemplateColumns: gridTemplate }}
>
<div>Participant</div>
{inputColumns.length > 0 ? (
inputColumns.map((column) => (
<div key={column.key} className="capitalize">
{column.label}
{column.required && <span className="text-amber-500"> *</span>}
<div className="grid grid-cols-6 gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
<div className="col-span-2">Participant</div>
<div>Elo</div>
<div>Odds</div>
<div>Rank</div>
<div>Rating</div>
</div>
))
) : (
<div></div>
)}
{inputRows.slice(0, 20).map(({ participant, input }) => (
<div key={participant.id} className="grid grid-cols-6 gap-2 border-b last:border-b-0 px-3 py-2 text-sm">
<div className="col-span-2 font-medium">{participant.name}</div>
<div>{input?.sourceElo ?? "—"}</div>
<div>{input?.sourceOdds ?? "—"}</div>
<div>{input?.worldRanking ?? "—"}</div>
<div>{input?.rating ?? "—"}</div>
</div>
{pageRows.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No participants match.
</div>
) : (
pageRows.map(({ participant, input }) => {
const incomplete = isRowIncomplete(participant.id, input);
return (
<div
key={participant.id}
className="grid gap-2 border-b last:border-b-0 px-3 py-2 text-sm"
style={{ gridTemplateColumns: gridTemplate }}
>
<div className="font-medium flex items-center gap-2">
{incomplete && <span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" title="Missing a required input" />}
{participant.name}
</div>
{inputColumns.length > 0 ? (
inputColumns.map((column) => {
if (column.key === "sourceElo") {
const resolved = resolvedEloRows[participant.id];
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
return (
<div key={column.key} className="flex items-center gap-1.5">
{resolved ? resolved.sourceElo : "—"}
{methodLabel && (
<Badge variant="outline" className="text-[10px] font-normal">
{methodLabel}
</Badge>
)}
</div>
);
}
if (column.key === "rating") {
const resolved = resolvedRatingRows[participant.id];
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
return (
<div key={column.key} className="flex items-center gap-1.5">
{resolved ? resolved.rating : "—"}
{methodLabel && (
<Badge variant="outline" className="text-[10px] font-normal">
{methodLabel}
</Badge>
)}
</div>
);
}
const value = input?.[column.key];
return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>;
})
) : (
<div></div>
)}
</div>
);
})
)}
<div className="flex items-center justify-between gap-4 px-3 py-2 text-xs text-muted-foreground">
<span>
{filteredRows.length === 0
? "0 participants"
: `Showing ${pageStart + 1}${pageStart + pageRows.length} of ${filteredRows.length}${
filteredRows.length !== inputRows.length ? ` (${inputRows.length} total)` : ""
}`}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={safePage === 0}
onClick={() => setPage(safePage - 1)}
>
Previous
</Button>
<span>
Page {safePage + 1} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={safePage >= totalPages - 1}
onClick={() => setPage(safePage + 1)}
>
Next
</Button>
))}
{inputRows.length > 20 && (
<div className="px-3 py-2 text-xs text-muted-foreground">
Showing 20 of {inputRows.length} participants
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>

View file

@ -10,8 +10,7 @@ import {
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
import { getSurfaceElosForSeason, batchUpsertSurfaceElos } from '~/models/surface-elo';
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
import { calculateEV } from '~/services/ev-calculator';
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
import { recalculateStandings } from '~/models/scoring-calculator';
import { database } from '~/database/context';
import * as schema from '~/database/schema';
@ -31,6 +30,17 @@ import { useEffect, useRef, useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
const DEFAULT_SCORING_RULES: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 45,
pointsFor4th: 45,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Surface Elo — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}

View file

@ -186,37 +186,6 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
});
it("names a non-eliminated loser for context without @-pinging them", async () => {
// Argentina beats England in the World Cup semifinal. Argentina scored, so it's
// pinged; England advances to the 3rd-place playoff (not eliminated, no points
// change), so its manager is shown by plain username but NOT @-pinged.
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Diablo League 2026",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 160, rank: 7 }],
previousStandings: new Map([["a", 130]]),
scoredMatches: [
{
winnerName: "Argentina",
loserName: "England",
winnerUsername: "philosohraptors",
winnerDiscordUserId: "111",
loserUsername: "elementsoul",
// no loserDiscordUserId — still alive, no ping
},
],
});
const payload = getPayload();
const desc = payload.embeds[0].description as string;
// Winner scored → rendered as an @-mention; loser is named by plain username.
expect(desc).toContain("• **Argentina (<@111>)** def. England (elementsoul)");
// England's manager is named but not mentioned/pinged.
expect(desc).not.toContain("England (<@");
expect(payload.content ?? "").toContain("<@111>");
expect(payload.content ?? "").not.toContain("elementsoul");
});
it("shows winner's manager for context when the match fires due to an owned loser", async () => {
// Brazil beats Japan (R32, non-scoring). Japan's manager is the reason for the
// notification; Brazil's manager is shown for context even though they didn't score.
@ -308,43 +277,6 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).not.toContain("Gamma");
});
it("pings scorers but not rank-only shufflers", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [
// Alpha scored — points changed, moved up.
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1, discordUserId: "111" },
// Beta was displaced down without scoring — rank changed only.
{ teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2, username: "beta_owner", discordUserId: "222" },
],
previousStandings: new Map([
["a", 125],
["b", 125],
]),
previousRanks: new Map([
["a", 2],
["b", 1],
]),
});
const desc = getDescription();
// Beta is still displayed with its rank movement…
expect(desc).toContain("Beta");
expect(desc).toContain("↓1");
// …but by name, not as an @-mention.
expect(desc).not.toContain("<@222>");
// Alpha scored, so it keeps its mention.
expect(desc).toContain("<@111>");
const payload = getPayload();
// Only the scorer is pinged.
expect(payload.content).toContain("111");
expect(payload.content).not.toContain("222");
expect(payload.allowed_mentions.users).toContain("111");
expect(payload.allowed_mentions.users).not.toContain("222");
});
it("omits rank delta when no previousRanks provided", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
@ -778,19 +710,17 @@ describe("sendQualifyingPointsUpdateNotification", () => {
{ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" },
]);
it("sends an embed with gold color, QP title, no footer, and links the title to the standings page", async () => {
it("sends an embed with gold color and QP title", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
standingsUrl: "https://brackt.com/leagues/abc/sports-seasons/ss-1",
});
const payload = getPayload();
expect(payload.embeds[0].title).toBe("🏅 Qualifying Points Update — Slam League 2025");
expect(payload.embeds[0].color).toBe(0x5865f2);
expect(payload.embeds[0].url).toBe("https://brackt.com/leagues/abc/sports-seasons/ss-1");
expect(payload.embeds[0].footer).toBeUndefined();
expect(payload.embeds[0].footer).toEqual({ text: "brackt.com" });
});
it("shows sport and event header", async () => {
@ -815,105 +745,38 @@ describe("sendQualifyingPointsUpdateNotification", () => {
const desc = getDescription();
expect(desc).toContain("**Points Awarded**");
expect(desc).toContain("• **Novak Djokovic (alex)** — 20 QP");
expect(desc).toContain("• **Carlos Alcaraz (chris)** — 14 QP");
expect(desc).toContain("• **Novak Djokovic (alex)** — +20 QP");
expect(desc).toContain("• **Carlos Alcaraz (chris)** — +14 QP");
// Djokovic (20 QP) should appear before Alcaraz (14 QP)
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
});
it("omits zero-QP drafted participants entirely from the Drafted Participants section", async () => {
// Only participants who have actually scored (qpTotal > 0) are listed; a 0-QP drafted
// player no longer appears anywhere in the standings section.
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
],
scoreboard: [
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
{ participantName: "Also Ran", qpEarned: 0, qpTotal: 5, globalRank: 9, globalRankTied: false, ownerUsername: "chris" },
{ participantName: "Winless Wonder", qpEarned: 0, qpTotal: 0, globalRank: 0, globalRankTied: false, ownerUsername: "sam" },
],
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
// Both scorers appear as ranked rows...
expect(desc).toContain("1\\. Champ (alex) — 100 QP");
expect(desc).toContain("9\\. Also Ran (chris) — 5 QP");
// ...with a Points Bubble divider separating the rank-9 scorer.
expect(desc).toContain("**═══ Points Bubble ═══**");
// The 0-QP player is omitted entirely.
expect(desc).not.toContain("Winless Wonder");
// The old Non-scoring section is gone.
expect(desc).not.toContain("Non-scoring Participants");
});
it("shows the Drafted Participants section for scored participants sorted by rank", async () => {
it("shows No Points section for entries with qpEarned == 0", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
scoreboard: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
expect(desc).toContain("**No Points**");
expect(desc).toContain("Rafael Nadal (alex)");
});
it("shows QP Standings for all entries sorted by qpTotal desc", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**QP Standings**");
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP");
expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP");
// Djokovic should rank above Alcaraz
expect(desc.indexOf("1\\. Novak")).toBeLessThan(desc.indexOf("2\\. Carlos"));
// Everyone is rank <= 8, so no divider is emitted.
expect(desc).not.toContain("Points Bubble");
});
it("inserts a Points Bubble divider between the rank-8 and rank-9 scorers", async () => {
const scoreboard = [
{ participantName: "Player Eight", qpEarned: 5, qpTotal: 12, globalRank: 8, globalRankTied: false, ownerUsername: "chris" },
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: scoreboard,
scoreboard,
});
const desc = getDescription();
const eightIdx = desc.indexOf("8\\. Player Eight");
const bubbleIdx = desc.indexOf("**═══ Points Bubble ═══**");
const nineIdx = desc.indexOf("9\\. Player Nine");
expect(eightIdx).toBeGreaterThan(-1);
expect(bubbleIdx).toBeGreaterThan(-1);
expect(nineIdx).toBeGreaterThan(-1);
// Divider sits between the rank-8 and rank-9 rows.
expect(eightIdx).toBeLessThan(bubbleIdx);
expect(bubbleIdx).toBeLessThan(nineIdx);
});
it("omits the Points Bubble divider when every scorer is below the cutoff", async () => {
// globalRank is a season-wide rank but the scoreboard is scoped to one league's drafts,
// so a league can have drafted nobody in the global top 8. The divider must not lead the
// section with nothing above it.
const scoreboard = [
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
{ participantName: "Player Ten", qpEarned: 2, qpTotal: 5, globalRank: 10, globalRankTied: false, ownerUsername: "chris" },
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: scoreboard,
scoreboard,
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
expect(desc).toContain("9\\. Player Nine (alex) — 8 QP");
expect(desc).toContain("10\\. Player Ten (chris) — 5 QP");
// No rank <= 8 row exists, so the divider must not appear.
expect(desc).not.toContain("Points Bubble");
});
it("uses T-prefix for tied QP totals in standings", async () => {
@ -925,11 +788,6 @@ describe("sendQualifyingPointsUpdateNotification", () => {
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
]),
scoreboard: withRanks([
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
]),
});
const desc = getDescription();
@ -959,63 +817,46 @@ describe("sendQualifyingPointsUpdateNotification", () => {
});
const desc = getDescription();
// Mirrors the web UI's formatQP: fractional QP renders with up to 2 decimals and
// trailing zeros trimmed ("1.5"), never rounded to an integer. The Points Awarded
// line no longer carries a "+".
expect(desc).toContain("• **Novak Djokovic (snarkymcgee)** — 1.5 QP");
expect(desc).not.toContain("+");
expect(desc).not.toContain("2 QP");
expect(desc).not.toContain("1.50");
// Mirrors the web UI's formatQP: fractional QP renders with 2 decimals ("1.50"),
// never rounded to an integer.
expect(desc).toContain("+1.50 QP");
expect(desc).not.toContain("+2 QP");
expect(desc).toContain("1.50 QP");
expect(desc).not.toContain("— 2 QP");
});
it("lists a rank-9 scorer below the Points Bubble but omits a 0-QP participant", async () => {
// Rank-9 scorers now appear in the standings section (below the bubble), while a drafted
// participant with no points is dropped entirely.
const both = [
{
participantName: "Player Eight",
qpEarned: 5,
qpTotal: 10,
globalRank: 8,
globalRankTied: false,
ownerUsername: "eighthowner",
},
{
participantName: "Player Nine",
qpEarned: 3,
qpTotal: 8,
globalRank: 9,
globalRankTied: false,
ownerUsername: "ninthowner",
},
{
participantName: "Player Winless",
qpEarned: 0,
qpTotal: 0,
globalRank: 10,
globalRankTied: false,
ownerUsername: "winlessowner",
},
];
it("ranks the standings by caller-provided full-field globalRank, not position among entries", async () => {
// Screenshot scenario: only two players scored this event, but they sit at T9 in
// the full season field (8 players ahead). They must show T9, not T1.
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
sportName: "Tennis - Men",
eventName: "Wimbledon",
entries: both,
scoreboard: both,
entries: [
{
participantName: "Novak Djokovic",
qpEarned: 1.5,
qpTotal: 1.5,
globalRank: 9,
globalRankTied: true,
ownerUsername: "snarkymcgee",
},
{
participantName: "Jannik Sinner",
qpEarned: 1.5,
qpTotal: 1.5,
globalRank: 9,
globalRankTied: true,
ownerUsername: "tanay002",
},
],
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP");
// The rank-9 scorer now appears as a ranked row below the bubble.
expect(desc).toContain("**═══ Points Bubble ═══**");
expect(desc).toContain("9\\. Player Nine (ninthowner) — 8 QP");
// The 0-QP player is omitted from the standings section entirely.
expect(desc).not.toContain("Player Winless");
// ...but both scorers still earned points, so both remain in Points Awarded.
expect(desc).toContain("• **Player Nine (ninthowner)** — 3 QP");
expect(desc).toContain("T9\\. Novak Djokovic (snarkymcgee) — 1.50 QP");
expect(desc).toContain("T9\\. Jannik Sinner (tanay002) — 1.50 QP");
expect(desc).not.toContain("T1\\.");
});
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
@ -1031,24 +872,14 @@ describe("sendQualifyingPointsUpdateNotification", () => {
ownerDiscordUserId: "111222333",
},
]),
scoreboard: withRanks([
{
participantName: "Novak Djokovic",
qpEarned: 20,
qpTotal: 45,
ownerUsername: "alex",
},
]),
});
const desc = getDescription();
// Points Awarded (a pinged section) uses the Discord mention...
expect(desc).toContain("• **Novak Djokovic (<@111222333>)** — 20 QP");
// ...while the (non-pinged) Drafted Participants standings section uses the plain username.
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
expect(desc).toContain("Novak Djokovic (<@111222333>)");
expect(desc).not.toContain("(alex)");
});
it("pings awarded owners but not non-scoring owners", async () => {
it("pings opted-in owners who appear in awarded or no-points sections", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
@ -1060,9 +891,9 @@ describe("sendQualifyingPointsUpdateNotification", () => {
const payload = getPayload();
expect(payload.content).toContain("<@111>");
expect(payload.content).not.toContain("<@222>");
expect(payload.content).toContain("<@222>");
expect(payload.allowed_mentions.users).toContain("111");
expect(payload.allowed_mentions.users).not.toContain("222");
expect(payload.allowed_mentions.users).toContain("222");
});
it("does not send when entries array is empty", async () => {
@ -1088,7 +919,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
expect(desc).toContain("• Jakob Mensik (chris)");
});
it("sends with only a Knocked Out section when there are no QP entries, omitting the standings section", async () => {
it("sends with only a Knocked Out section when there are no QP entries, omitting QP Standings", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
@ -1099,7 +930,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
expect(fetch).toHaveBeenCalledOnce();
const desc = getDescription();
expect(desc).toContain("**Knocked Out**");
expect(desc).not.toContain("**Drafted Participants**");
expect(desc).not.toContain("**QP Standings**");
expect(desc).not.toContain("**Points Awarded**");
});
@ -1144,7 +975,6 @@ describe("sendQualifyingPointsUpdateNotification", () => {
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: longEntries,
scoreboard: longEntries,
});
const desc = getDescription();

View file

@ -3,7 +3,6 @@ import {
convertAmericanOddsToProbability,
convertDecimalOddsToProbability,
normalizeProbabilities,
devigPower,
decompressProbability,
mapToElo,
eloWinProbability,
@ -95,64 +94,6 @@ describe('probability-engine', () => {
});
});
describe('devigPower', () => {
/** 27-driver championship market: one -300 favourite and a long tail. */
const CHAMPIONSHIP_MARKET = [
-300, 450, 700, 1200, 1800, 2500, 4000, 5000, 6000, 8000, 10000, 12000,
15000, 20000, 25000, 30000, 40000, 50000, 50000, 50000, 50000, 50000,
50000, 50000, 50000, 50000, 50000,
].map(convertAmericanOddsToProbability);
it('sums to exactly 1.0', () => {
const devigged = devigPower(CHAMPIONSHIP_MARKET);
expect(devigged.reduce((sum, p) => sum + p, 0)).toBeCloseTo(1.0, 10);
});
it('preserves a heavy favourite that proportional devig would gut', () => {
const proportional = normalizeProbabilities(CHAMPIONSHIP_MARKET);
const devigged = devigPower(CHAMPIONSHIP_MARKET);
// -300 is 75.0% implied. The book sums to ~1.36, so dividing everyone by
// the same overround drops the favourite to ~55%.
expect(CHAMPIONSHIP_MARKET[0]).toBeCloseTo(0.75, 4);
expect(proportional[0]).toBeCloseTo(0.553, 2);
expect(devigged[0]).toBeCloseTo(0.695, 2);
expect(devigged[0]).toBeGreaterThan(proportional[0]);
});
it('keeps a near-lock near-certain', () => {
const market = [-20000, ...Array(26).fill(50000)].map(convertAmericanOddsToProbability);
expect(normalizeProbabilities(market)[0]).toBeCloseTo(0.950, 2);
expect(devigPower(market)[0]).toBeCloseTo(0.993, 2);
});
it('preserves the ordering of the field', () => {
const devigged = devigPower(CHAMPIONSHIP_MARKET);
for (let i = 1; i < devigged.length; i++) {
expect(devigged[i]).toBeLessThanOrEqual(devigged[i - 1]);
}
});
it('normalizes a book that is already vig-free', () => {
const devigged = devigPower([0.5, 0.3, 0.2]);
expect(devigged[0]).toBeCloseTo(0.5, 6);
expect(devigged[1]).toBeCloseTo(0.3, 6);
expect(devigged[2]).toBeCloseTo(0.2, 6);
});
it('scales a single runner to certainty', () => {
expect(devigPower([0.8])).toEqual([1]);
});
it('returns an empty array for an empty market', () => {
expect(devigPower([])).toEqual([]);
});
it('returns a uniform field for an all-zero market', () => {
devigPower([0, 0, 0]).forEach(p => expect(p).toBeCloseTo(1 / 3, 6));
});
});
describe('decompressProbability', () => {
it('decompresses championship probabilities with default exponent', () => {
expect(decompressProbability(0.154)).toBeCloseTo(2.465, 2); // Colorado 15.4%

View file

@ -8,9 +8,6 @@ import * as participantEVModel from "~/models/participant-expected-value";
// Mock the dependencies
vi.mock("~/models/participant-result");
vi.mock("~/models/participant-expected-value");
vi.mock("~/models/simulator");
vi.mock("~/models/sports-season");
vi.mock("~/services/simulations/runner");
vi.mock("~/database/context", () => ({
database: () => ({
query: {
@ -21,9 +18,6 @@ vi.mock("~/database/context", () => ({
}),
}));
// vi.mock above is hoisted over the imports, so this is already the mocked function.
const upsertEV = vi.mocked(participantEVModel.upsertParticipantEV);
describe("probability-updater", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -272,262 +266,5 @@ describe("probability-updater", () => {
expect(callArgs.probabilities.probSeventh).toBe(0);
expect(callArgs.probabilities.probEighth).toBe(0);
});
it("does NOT treat a provisional floor as finished — the team is still playing", async () => {
// An AFL top-4 seed banks a provisional 5th-6th floor at seeding. Pinning them
// to 100% at 5th would erase their championship odds before they have played.
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
{
id: "result-1",
participantId: "participant-1",
sportsSeasonId: "season-1",
finalPosition: 5,
isPartialScore: true,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
participant: null,
},
] as never);
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
const result = await updateProbabilitiesAfterResult("season-1", false);
expect(result.finishedParticipants).toBe(0);
expect(upsertSpy).not.toHaveBeenCalled();
});
it("still finalizes a 0-position elimination — those rows are not partial", async () => {
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
{
id: "result-1",
participantId: "participant-1",
sportsSeasonId: "season-1",
finalPosition: 0,
isPartialScore: false,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
participant: null,
},
] as never);
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
const result = await updateProbabilitiesAfterResult("season-1", false);
expect(result.finishedParticipants).toBe(1);
expect(upsertSpy.mock.calls[0][0].probabilities.probFirst).toBe(0);
});
});
});
// ─── Bracket-aware simulator seasons ──────────────────────────────────────────
//
// The ICM branch re-derives a whole distribution from P(1st) alone and knows nothing about
// who is playing whom or what has already been decided, so it cannot see the placement floors
// an afl_10 seeding or a non-scoring-round win has already banked — it will happily value a
// team below points the league has paid out. Whenever the season has a simulator that reads
// its bracket, that simulator is the better answer and is re-run instead. Only a season whose
// simulator is bracket-blind (or has none) still goes through ICM.
const evRow = (participantId: string, source: string) => ({
id: `ev-${participantId}`,
participantId,
sportsSeasonId: "season-1",
probFirst: "0.1000",
probSecond: "0.1000",
probThird: "0.1000",
probFourth: "0.1000",
probFifth: "0.1000",
probSixth: "0.1000",
probSeventh: "0.1000",
probEighth: "0.1000",
expectedValue: "34.00",
source,
sourceOdds: null,
calculatedAt: new Date(),
updatedAt: new Date(),
});
const finishedResult = (participantId: string, finalPosition: number) => ({
id: `result-${participantId}`,
participantId,
sportsSeasonId: "season-1",
finalPosition,
isPartialScore: false,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
participant: null,
});
describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => {
/** Wire up a season: which teams are done, what wrote the EVs, which simulator it has. */
async function setup(opts: {
evSource: string;
simulatorType: string | null;
results?: ReturnType<typeof finishedResult>[];
seasonStatus?: string;
}) {
const simulatorModel = await import("~/models/simulator");
const sportsSeasonModel = await import("~/models/sports-season");
const runner = await import("~/services/simulations/runner");
vi.mocked(sportsSeasonModel.findSportsSeasonById).mockResolvedValue({
id: "season-1",
status: opts.seasonStatus ?? "active",
} as never);
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue(
opts.results ?? []
);
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([
evRow("alive-1", opts.evSource),
evRow("alive-2", opts.evSource),
] as never);
vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
vi.mocked(simulatorModel.getSportsSeasonSimulatorConfig).mockResolvedValue(
opts.simulatorType ? ({ simulatorType: opts.simulatorType, config: {} } as never) : null
);
const runSim = vi.mocked(runner.runSportsSeasonSimulation);
runSim.mockResolvedValue({} as never);
return { runner, runSim };
}
/** The ICM branch is the only thing that writes unfinished rows with this source. */
const icmWrites = () =>
upsertEV.mock.calls.filter(([arg]) => arg.source === "futures_odds");
beforeEach(() => {
vi.clearAllMocks();
});
it("re-runs a bracket-aware simulator instead of recalculating ICM", async () => {
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
const result = await updateProbabilitiesAfterResult("season-1", true);
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
expect(icmWrites()).toHaveLength(0);
expect(result.errors).toEqual([]);
});
it("asks the run for probabilities only, leaving standings and snapshots to the caller", async () => {
// recalculateAffectedLeagues detects change by diffing teamStandings across its own
// recalculation, and that diff gates the Discord standings post. A recalculation in here
// runs before it takes its "before" snapshot, so the diff comes back empty and the post is
// silently dropped — and previousRank gets rolled forward twice, erasing rank movement.
const { runSim } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
await updateProbabilitiesAfterResult("season-1", true);
expect(runSim).toHaveBeenCalledWith("season-1", {
skipStandingsRecalc: true,
skipSnapshots: true,
});
});
it("falls through to ICM on a completed season rather than failing every time", async () => {
// finalizeQualifyingPoints marks the season completed immediately before calling here, and
// runSportsSeasonSimulation rejects a completed season outright. Treating that as a failure
// would strand anyone still unfinished on stale probabilities forever.
const { runner } = await setup({
evSource: "elo_simulation",
simulatorType: "cs2_major_qualifying_points",
seasonStatus: "completed",
});
const result = await updateProbabilitiesAfterResult("season-1", true);
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
expect(icmWrites().length).toBeGreaterThan(0);
expect(result.errors).toEqual([]);
});
it("still pins finished participants before re-running the simulator", async () => {
const { runner } = await setup({
evSource: "elo_simulation",
simulatorType: "afl_bracket",
results: [finishedResult("done-1", 2)],
});
await updateProbabilitiesAfterResult("season-1", true);
const pinned = upsertEV.mock.calls.find(([arg]) => arg.participantId === "done-1");
expect(pinned?.[0].probabilities.probSecond).toBe(1.0);
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledTimes(1);
});
it("writes a finalized pin after the re-run, so the pin wins over the simulation", async () => {
// runSportsSeasonSimulation rewrites every participant in the season, finalized ones
// included. A finalized placement is a fact, not a projection, so it has to land last.
const { runSim } = await setup({
evSource: "elo_simulation",
simulatorType: "afl_bracket",
results: [finishedResult("done-1", 0)],
});
await updateProbabilitiesAfterResult("season-1", true);
const pinIndex = upsertEV.mock.calls.findIndex(([arg]) => arg.participantId === "done-1");
expect(pinIndex).toBeGreaterThanOrEqual(0);
expect(upsertEV.mock.invocationCallOrder[pinIndex]).toBeGreaterThan(
runSim.mock.invocationCallOrder[0]
);
});
it("leaves probabilities alone, and does not fall back to ICM, when the re-run fails", async () => {
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
vi.mocked(runner.runSportsSeasonSimulation).mockRejectedValue(
new Error("A simulation is already running for this sports season.")
);
const result = await updateProbabilitiesAfterResult("season-1", true);
expect(icmWrites()).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toMatch(/Failed to re-run simulator/);
});
it("re-runs the simulator whatever wrote the EVs originally", async () => {
// The alternative is not leaving them alone — ICM would overwrite them either way — so
// futures-odds EVs are no reason to prefer the bracket-blind overwrite.
const { runner } = await setup({ evSource: "futures_odds", simulatorType: "afl_bracket" });
await updateProbabilitiesAfterResult("season-1", true);
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
expect(icmWrites()).toHaveLength(0);
});
it("keeps the ICM path for a bracket-blind simulator", async () => {
// ncaa_football_bracket declares a "bracket" setup section but never reads playoff_matches,
// so re-running it would re-draw the field and hand equity back to eliminated teams.
const { runner } = await setup({
evSource: "elo_simulation",
simulatorType: "ncaa_football_bracket",
});
await updateProbabilitiesAfterResult("season-1", true);
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
expect(icmWrites().length).toBeGreaterThan(0);
});
it("keeps the ICM path when the season has no simulator configured", async () => {
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: null });
await updateProbabilitiesAfterResult("season-1", true);
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
expect(icmWrites().length).toBeGreaterThan(0);
});
});

View file

@ -27,7 +27,6 @@ import { findDiscordIdsByUserIds } from "~/models/account";
const SPORTS_SEASON_ID = "ss-1";
const SCORING_EVENT_ID = "ev-1";
const SEASON_ID = "season-1";
const LEAGUE_ID = "league-1";
const PARTICIPANT_ID = "p-1";
const OWNER_ID = "u-1";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
@ -69,7 +68,7 @@ function makeDb(overrides: Record<string, unknown> = {}) {
{
id: SEASON_ID,
year: 2025,
league: { id: LEAGUE_ID, name: "Slam League", discordWebhookUrl: WEBHOOK_URL },
league: { name: "Slam League", discordWebhookUrl: WEBHOOK_URL },
},
]),
},
@ -83,7 +82,7 @@ function makeDb(overrides: Record<string, unknown> = {}) {
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }]),
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" }]),
},
users: {
findMany: vi.fn().mockResolvedValue([
@ -177,7 +176,6 @@ describe("notifyQualifyingPointsUpdate", () => {
seasonName: "Slam League 2025",
eventName: "Roland Garros 2025",
sportName: "Tennis",
standingsUrl: `${process.env.APP_URL ?? "https://brackt.com"}/leagues/${LEAGUE_ID}/sports-seasons/${SPORTS_SEASON_ID}`,
entries: [
expect.objectContaining({
participantName: "Carlos Alcaraz",
@ -263,8 +261,8 @@ describe("notifyQualifyingPointsUpdate", () => {
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" },
{ id: "p-2", name: "Rafael Nadal" },
]),
},
});
@ -281,84 +279,6 @@ describe("notifyQualifyingPointsUpdate", () => {
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
});
it("scoreboard includes every drafted participant even when entries are filtered", async () => {
// The scoreboard powers the Drafted Participants section and must reflect the full
// drafted field, not just this sync's changed participants. Here Nadal (p-2) did not
// change this sync (filtered out of entries) but is drafted, so he belongs on the
// scoreboard with his running total and no QP earned this event.
const db = makeDb({
eventResults: {
findMany: vi.fn().mockResolvedValue([
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: "10" },
{ seasonParticipantId: "p-2", qualifyingPointsAwarded: "5" },
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: "p-2", seasonId: SEASON_ID, team: { name: "Beta FC", ownerId: null } },
]),
},
seasonParticipantQualifyingTotals: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, totalQualifyingPoints: "45", sportsSeasonId: SPORTS_SEASON_ID },
{ participantId: "p-2", totalQualifyingPoints: "20", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
});
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
// entries is scoped to the changed participant…
expect(call.entries).toHaveLength(1);
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
// …but the scoreboard carries the whole drafted field.
expect(call.scoreboard).toEqual(
expect.arrayContaining([
expect.objectContaining({ participantName: "Carlos Alcaraz", qpTotal: 45 }),
expect.objectContaining({ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20 }),
])
);
expect(call.scoreboard).toHaveLength(2);
});
it("excludes participants from other sports seasons drafted in the same fantasy season", async () => {
// Draft picks span every sport in a fantasy season, so a golf pick can share the
// league with this tennis event. It must not leak into the tennis scoreboard.
const db = makeDb({
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: "p-golf", seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: "p-golf", name: "Rory McIlroy", sportsSeasonId: "ss-golf" },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.scoreboard).toHaveLength(1);
expect(call.scoreboard?.[0]?.participantName).toBe("Carlos Alcaraz");
});
it("does not call sendQualifyingPointsUpdateNotification when all participants are filtered out", async () => {
const db = makeDb();
@ -393,8 +313,8 @@ describe("notifyQualifyingPointsUpdate", () => {
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: MENSIK_ID, name: "Jakob Mensik", sportsSeasonId: SPORTS_SEASON_ID },
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" },
{ id: MENSIK_ID, name: "Jakob Mensik" },
]),
},
});

View file

@ -1,28 +1,24 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Mock } from "vitest";
import type * as DrizzleOrm from "drizzle-orm";
import type * as ScoringCalculatorModule from "~/models/scoring-calculator";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", async () => {
// Keep the real implementations of the PURE helpers so the tie-count map handed
// to processQualifyingEvent reflects the mock canonical results AND the bracket's
// structural tie span (deriveBracketQualifyingStates / getRoundConfig). Only the
// DB-touching orchestrators are stubbed.
const actual = await vi.importActual<typeof ScoringCalculatorModule>(
"~/models/scoring-calculator"
);
return {
vi.mock("~/models/scoring-calculator", () => ({
processQualifyingEvent: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
buildTieCountByPlacement: actual.buildTieCountByPlacement,
deriveBracketQualifyingStates: actual.deriveBracketQualifyingStates,
getRoundConfig: actual.getRoundConfig,
};
});
// Pure helper — keep the real implementation so the tie-count map handed to
// processQualifyingEvent reflects the mock canonical results.
buildTieCountByPlacement: (results: Array<{ placement: number | null }>) => {
const map = new Map<number, number>();
for (const r of results) {
if (r.placement === null) continue;
map.set(r.placement, (map.get(r.placement) ?? 0) + 1);
}
return map;
},
}));
vi.mock("~/models/scoring-event", () => ({
completeScoringEvent: vi.fn(),
@ -103,21 +99,11 @@ interface FakeEventResult {
// - event_results
// ---------------------------------------------------------------------------
interface FakePlayoffMatch {
scoringEventId: string;
round: string;
winnerId: string | null;
loserId: string | null;
participant1Id: string | null;
participant2Id: string | null;
}
interface FakeDbState {
tournamentResults: FakeTournamentResult[];
scoringEvents: FakeScoringEvent[];
seasonParticipants: FakeSeasonParticipant[];
eventResults: FakeEventResult[];
playoffMatches: FakePlayoffMatch[];
}
/**
@ -165,8 +151,6 @@ function makeFakeDb(state: FakeDbState) {
return state.seasonParticipants;
case "event_results":
return state.eventResults;
case "playoff_matches":
return state.playoffMatches;
default:
throw new Error(`Unknown table in fake db: ${name}`);
}
@ -274,12 +258,6 @@ vi.mock("drizzle-orm", async () => {
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
and: (...preds: any[]) => (row: any) => preds.every((p) => p(row)),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
inArray: (col: any, vals: any[]) => {
const key = colKey(col);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (row: any) => vals.includes(row[key]);
},
};
});
@ -313,7 +291,6 @@ function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
scoringEvents: [],
seasonParticipants: [],
eventResults: [],
playoffMatches: [],
...overrides,
};
}
@ -859,214 +836,6 @@ describe("syncMajorFromPrimaryEvent", () => {
expect(completeScoringEvent).not.toHaveBeenCalled();
});
it("splits mirror QP by the bracket's structural tie span, not the canonical row count (R16 in progress)", async () => {
// Tennis R16 in progress: two R16 matches are decided (losers cp-L1/cp-L2 land
// final at 9th) and two players (cp-F1/cp-F2) won their R32 match and are still
// "floored" at the R16 tier (also placement 9). So only FOUR players sit at
// placement 9 right now — the canonical row-count is 4, which would wrongly
// split 9th16th four ways (→ 2 QP). The R16 tier structurally spans 8 slots,
// so every window must split (2+2+2+2+1+1+1+1)/8 = 1.5. This asserts the map
// handed to each mirror's processQualifyingEvent carries the structural span (8),
// not the live count (4).
const state = seedBasicState({
scoringEvents: [
{
id: "ev-PRIMARY",
sportsSeasonId: "ss-P",
tournamentId: "t-1",
name: "Wimbledon",
},
{
id: "ev-MIRROR",
sportsSeasonId: "ss-M",
tournamentId: "t-1",
name: "Wimbledon",
},
],
seasonParticipants: [
{ id: "sp-ML1", sportsSeasonId: "ss-M", participantId: "cp-L1", name: "L1" },
{ id: "sp-MF1", sportsSeasonId: "ss-M", participantId: "cp-F1", name: "F1" },
],
// Primary bracket state. R32 wins establish the "floored at R16" players;
// decided R16 matches establish the final 9th-place losers and the QF-floored
// winners (placement 5).
playoffMatches: [
{
scoringEventId: "ev-PRIMARY",
round: "Round of 32",
winnerId: "cp-F1",
loserId: "cp-out1",
participant1Id: "cp-F1",
participant2Id: "cp-out1",
},
{
scoringEventId: "ev-PRIMARY",
round: "Round of 32",
winnerId: "cp-F2",
loserId: "cp-out2",
participant1Id: "cp-F2",
participant2Id: "cp-out2",
},
{
scoringEventId: "ev-PRIMARY",
round: "Round of 16",
winnerId: "cp-W1",
loserId: "cp-L1",
participant1Id: "cp-W1",
participant2Id: "cp-L1",
},
{
scoringEventId: "ev-PRIMARY",
round: "Round of 16",
winnerId: "cp-W2",
loserId: "cp-L2",
participant1Id: "cp-W2",
participant2Id: "cp-L2",
},
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
vi.mocked(getScoringEventById).mockResolvedValue({
id: "ev-PRIMARY",
sportsSeasonId: "ss-P",
tournamentId: "t-1",
name: "Wimbledon",
bracketTemplateId: "tennis_128",
} as never);
// Primary window's derived results (what processQualifyingBracketEvent wrote):
// four players at placement 9 (2 final losers + 2 floored), two at placement 5.
vi.mocked(getEventResults).mockResolvedValue(
[
["sp-PL1", "cp-L1", 9],
["sp-PL2", "cp-L2", 9],
["sp-PF1", "cp-F1", 9],
["sp-PF2", "cp-F2", 9],
["sp-PW1", "cp-W1", 5],
["sp-PW2", "cp-W2", 5],
].map(([seasonParticipantId, participantId, placement]) => ({
placement,
rawScore: null,
notParticipating: false,
seasonParticipantId,
seasonParticipant: { participantId },
})) as never
);
vi.mocked(upsertTournamentResult).mockImplementation(
async (data: {
tournamentId: string;
participantId: string;
placement?: number | null;
rawScore?: string | null;
}) => {
state.tournamentResults.push({
tournamentId: data.tournamentId,
participantId: data.participantId,
placement: data.placement ?? null,
rawScore: data.rawScore ?? null,
});
return data as never;
}
);
await syncMajorFromPrimaryEvent("ev-PRIMARY", { markComplete: false });
// Sanity: only four canonical rows sit at placement 9 (the live count is 4).
expect(
state.tournamentResults.filter((r) => r.placement === 9)
).toHaveLength(4);
// The mirror window was scored with the STRUCTURAL span, not the row count.
const mirrorCall = (processQualifyingEvent as Mock).mock.calls.find(
(c) => c[0] === "ev-MIRROR"
);
expect(mirrorCall).toBeDefined();
if (!mirrorCall) return;
const tieMap = mirrorCall[2].canonicalTieCountByPlacement as Map<number, number>;
expect(tieMap.get(9)).toBe(8); // R16 tier spans 8, not the 4 rows currently there
expect(tieMap.get(5)).toBe(4); // QF tier spans 4
});
it("fans a non-scoring-round elimination out to each mirror window (translated to its own season_participant id)", async () => {
// The reported bug: a player knocked out in a non-scoring round earns no QP, so
// canonical promotion skips them (null placement) and the mirror can't detect the
// knockout locally. The primary passes its eliminated season_participant id
// (sp-PX) down; syncMajorFromPrimaryEvent must translate it to the canonical
// participant (cp-X) and each mirror window must re-translate to ITS own
// season_participant (sp-MX) before handing it to processQualifyingEvent.
const state = seedBasicState({
scoringEvents: [
{ id: "ev-PRIMARY", sportsSeasonId: "ss-P", tournamentId: "t-1", name: "Wimbledon" },
{ id: "ev-MIRROR", sportsSeasonId: "ss-M", tournamentId: "t-1", name: "Wimbledon" },
],
seasonParticipants: [
// Placed finalist, present on both windows.
{ id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" },
{ id: "sp-MA", sportsSeasonId: "ss-M", participantId: "cp-A", name: "A" },
// Knocked-out player: different season_participant row per window, same
// canonical participant cp-X.
{ id: "sp-PX", sportsSeasonId: "ss-P", participantId: "cp-X", name: "X" },
{ id: "sp-MX", sportsSeasonId: "ss-M", participantId: "cp-X", name: "X" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
vi.mocked(getScoringEventById).mockResolvedValue({
id: "ev-PRIMARY",
sportsSeasonId: "ss-P",
tournamentId: "t-1",
name: "Wimbledon",
} as never);
// Primary derived results promote only the PLACED player. cp-X (the non-scoring
// loser) has no placement and is not promoted to canonical — exactly why the
// mirror needs the elimination threaded separately.
vi.mocked(getEventResults).mockResolvedValue([
{
placement: 1,
rawScore: "1",
notParticipating: false,
seasonParticipantId: "sp-PA",
seasonParticipant: { participantId: "cp-A" },
},
] as never);
vi.mocked(upsertTournamentResult).mockImplementation(
async (data: { tournamentId: string; participantId: string; placement?: number | null; rawScore?: string | null }) => {
state.tournamentResults.push({
tournamentId: data.tournamentId,
participantId: data.participantId,
placement: data.placement ?? null,
rawScore: data.rawScore ?? null,
});
return data as never;
}
);
await syncMajorFromPrimaryEvent("ev-PRIMARY", {
newlyEliminatedParticipantIds: new Set(["sp-PX"]),
});
const mirrorCall = (processQualifyingEvent as Mock).mock.calls.find(
(c) => c[0] === "ev-MIRROR"
);
expect(mirrorCall).toBeDefined();
if (!mirrorCall) return;
const eliminated = mirrorCall[2].newlyEliminatedParticipantIds as Set<string>;
// cp-X → the MIRROR's own season_participant id, not the primary's.
expect([...eliminated]).toEqual(["sp-MX"]);
});
it("throws when the primary event is not linked to a tournament", async () => {
const db = makeFakeDb(seedBasicState());
vi.mocked(database).mockReturnValue(db as never);

View file

@ -66,12 +66,11 @@ function escapeMarkdown(text: string): string {
/**
* Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser
* earns 1.5 QP from the 916 split), so we must NOT round: show whole numbers plainly
* and fractional values to at most 2 decimals with trailing zeros trimmed (1.501.5).
* Mirrors the web UI's formatQP (app/components/scoring/QualifyingPointsStandings.tsx)
* so Discord and the site agree.
* and fractional values to 2 decimals. Mirrors the web UI's formatQP
* (app/components/scoring/QualifyingPointsStandings.tsx) so Discord and the site agree.
*/
function formatQPValue(n: number): string {
return parseFloat(n.toFixed(2)).toString();
return n % 1 === 0 ? n.toString() : n.toFixed(2);
}
export interface StandingEntry {
@ -176,19 +175,13 @@ export async function sendStandingsUpdateNotification({
const isTied = buildTiedRankChecker(standings.map((s) => s.rank));
const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${rank}`);
// A team's points genuinely changed this event (vs. merely being displaced in
// rank because someone else scored). Only real scorers are pinged; rank-only
// shufflers are still displayed, but by name and without an @-mention.
const pointsChanged = (s: StandingEntry) => {
const prevPoints = previousStandings.get(s.teamId);
return prevPoints !== undefined && prevPoints !== s.totalPoints;
};
// Standings changes section — show teams whose points or rank changed.
const changedTeams = standings.filter((s) => {
const prevPoints = previousStandings.get(s.teamId);
const pointsChanged = prevPoints !== undefined && prevPoints !== s.totalPoints;
const prevRank = previousRanks?.get(s.teamId);
const rankChanged = prevRank !== undefined && prevRank !== s.rank;
return pointsChanged(s) || rankChanged;
return pointsChanged || rankChanged;
});
if (changedTeams.length > 0) {
@ -213,7 +206,7 @@ export async function sendStandingsUpdateNotification({
}
const escapedName = escapeMarkdown(s.teamName);
const managerLabel = s.discordUserId && pointsChanged(s)
const managerLabel = s.discordUserId
? `<@${s.discordUserId}>`
: s.username
? escapeMarkdown(s.username)
@ -232,7 +225,7 @@ export async function sendStandingsUpdateNotification({
// Collect Discord user IDs of all opted-in managers appearing in this notification.
const pingUserIds = new Set<string>();
for (const s of changedTeams) {
if (s.discordUserId && pointsChanged(s)) pingUserIds.add(s.discordUserId);
if (s.discordUserId) pingUserIds.add(s.discordUserId);
}
for (const m of relevantMatches ?? []) {
if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
@ -295,8 +288,6 @@ export async function sendQualifyingPointsUpdateNotification({
eventName,
entries,
eliminated = [],
scoreboard = [],
standingsUrl,
}: {
webhookUrl: string;
seasonName: string;
@ -304,14 +295,6 @@ export async function sendQualifyingPointsUpdateNotification({
eventName?: string;
entries: QPEventEntry[];
eliminated?: QPEliminatedEntry[];
/**
* The full current scoreboard for the league every drafted participant, not just
* those whose QP changed this sync. Drives the "Drafted Participants" standings section.
* `entries`/`eliminated` remain scoped to this sync's changes and drive the
* "Points Awarded"/"Knocked Out" sections and the ping list.
*/
scoreboard?: QPEventEntry[];
standingsUrl?: string;
}): Promise<void> {
if (entries.length === 0 && eliminated.length === 0) return;
@ -337,7 +320,23 @@ export async function sendQualifyingPointsUpdateNotification({
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`• **${label}** — ${formatQPValue(e.qpEarned)} QP`);
sections.push(`• **${label}** — +${formatQPValue(e.qpEarned)} QP`);
}
}
const zeroEntries = entries.filter((e) => e.qpEarned === 0);
if (zeroEntries.length > 0) {
sections.push("\n**No Points**");
for (const e of zeroEntries) {
const managerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`${label}`);
}
}
@ -358,35 +357,22 @@ export async function sendQualifyingPointsUpdateNotification({
}
}
// Drafted Participants: every drafted participant that has actually scored (qpTotal > 0),
// drawn from the FULL drafted field (`scoreboard`) not just this sync's movers, so it reads
// as a live standings snapshot. Rendered as ranked lines ordered by full-season standing. A
// "Points Bubble" divider marks the cutoff between those currently in the points (rank <= 8)
// and those below it (rank >= 9). Participants with 0 QP are omitted entirely. Never pinged,
// so managers are shown by plain username, never as a <@id> mention.
const scored = [...scoreboard]
.filter((e) => e.qpTotal > 0)
.toSorted((a, b) => a.globalRank - b.globalRank);
// Order by the participant's rank in the FULL season standings (globalRank),
// supplied by the caller, so the block reads as a season leaderboard slice.
const sorted = [...entries].toSorted((a, b) => a.globalRank - b.globalRank);
// Skip the section entirely when no drafted participant has scored (e.g. a sync that only
// reported knockouts) so we don't emit an empty header.
if (scored.length > 0) {
sections.push("\n**Drafted Participants**");
// Insert the divider once, before the first below-the-cutoff (rank >= 9) row. `>= 9`
// (not `> 8`) keeps a tie AT rank 8 above the bubble ("top 8 plus ties"). Only emit it
// after at least one above-the-bubble row exists: globalRank is a season-wide rank while
// this list is scoped to one league's drafts, so a league can have drafted nobody in the
// global top 8 — guarding on rowsAbove avoids a leading divider with nothing above it.
let bubbleInserted = false;
let rowsAbove = 0;
for (const e of scored) {
if (!bubbleInserted && rowsAbove > 0 && e.globalRank >= 9) {
sections.push("**═══ Points Bubble ═══**");
bubbleInserted = true;
}
if (e.globalRank <= 8) rowsAbove++;
// The standings block reflects QP earners; skip it entirely when this sync only
// reported knockouts (no QP change) so we don't emit an empty header.
if (sorted.length > 0) {
sections.push("\n**QP Standings**");
for (const e of sorted) {
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : "";
const ownerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const managerLabel = ownerLabel ? ` (${ownerLabel})` : "";
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel}${formatQPValue(e.qpTotal)} QP`);
}
}
@ -397,10 +383,8 @@ export async function sendQualifyingPointsUpdateNotification({
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
}
// Ping only managers who earned points or lost a drafted player this sync —
// never the non-scoring (zeroEntries) managers.
const pingUserIds = new Set<string>();
for (const e of [...awardedEntries, ...eliminated]) {
for (const e of [...awardedEntries, ...zeroEntries, ...eliminated]) {
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
}
const pingIds = [...pingUserIds];
@ -409,9 +393,9 @@ export async function sendQualifyingPointsUpdateNotification({
embeds: [
{
title: `🏅 Qualifying Points Update — ${seasonName}`,
url: standingsUrl,
description,
color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" },
},
],
};

View file

@ -22,7 +22,6 @@ import {
processQualifyingBracketEvent,
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import {
@ -284,11 +283,6 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
eventId: event.id,
eventName: event.name ?? undefined,
matchId: playoffMatch.id,
// The probability refresh is season-wide and idempotent, and for a bracket-aware
// sport it is a full Monte Carlo run — doing it per match would repeat that for
// every match in the sync. It runs once after the loop instead. Standings and the
// per-match Discord post still happen here as before.
skipProbabilities: true,
loserAdvances: event.bracketTemplateId
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
: false,
@ -306,15 +300,6 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
}
}
}
// The refresh skipped inside the loop, run once for the whole sync.
if (playoffUpdated > 0) {
try {
await updateProbabilitiesAfterResult(sportsSeasonId, true);
} catch (err) {
logger.error(`[match-sync] Error updating probabilities after bracket sync:`, err);
}
}
}
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
@ -626,16 +611,9 @@ export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
eventId,
eventName: event.name ?? undefined,
});
// Players knocked out this sync in a non-scoring round (rounds 13 of a Grand
// Slam) earn no QP and so never appear via changedParticipantIds. Computed here
// so it feeds BOTH the primary's own notification below AND the fan-out, which
// propagates it to every mirror window (whose placement-only data can't detect a
// knockout on its own).
const newlyEliminatedIds = new Set(newlyDecidedLoserIds);
await fanOutMajorIfPrimary(
{ id: event.id, isPrimary: event.isPrimary, tournamentId: event.tournamentId },
{ markComplete: false, newlyEliminatedParticipantIds: newlyEliminatedIds },
{ markComplete: false },
);
// Announce QP changes for the primary window. Sibling windows are announced by
@ -644,7 +622,10 @@ export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
// outside the rescore transaction so the webhook HTTP call neither holds the
// transaction open nor rolls back the score if Discord fails.
//
// Also announce players knocked out this sync in a non-scoring round (rounds
// 13 of a Grand Slam), who earn no QP and so never appear via changedParticipantIds.
// Fire even when no QP changed, so an early-round elimination is still surfaced.
const newlyEliminatedIds = new Set(newlyDecidedLoserIds);
if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) {
try {
await notifyQualifyingPointsUpdate(

View file

@ -109,65 +109,6 @@ export function normalizeProbabilities(probabilities: number[]): number[] {
return probabilities.map(p => p / sum);
}
/**
* Remove vig with a power transform instead of proportional division.
*
* `normalizeProbabilities` divides every runner by the same book sum, which
* assumes the overround is spread evenly across the field. In a large futures
* market it is not the juice is concentrated in the longshots, so dividing
* proportionally guts the favourite. In a 27-driver championship market with a
* book sum of 1.36, a 75.0% implied favourite comes out at 55.3%; with a book
* sum of 1.05, a -20000 near-lock comes out at 95.0%.
*
* The power method instead solves for the exponent `k` where `Σ pᵢ^k = 1`. Since
* `p^k` shrinks small probabilities much harder than large ones, the favourite
* keeps its shape: the same two markets give 69.5% and 99.3%.
*
* Solved by bisection `Σ pᵢ^k` is monotonically decreasing in `k` for
* `pᵢ ∈ (0, 1)`, so 60 halvings of `[0.01, 10]` converge well past float
* precision.
*
* @param impliedProbs Raw implied probabilities (as decimals 0-1), vig included
* @returns Vig-free probabilities summing to 1.0
*
* @example
* devigPower([0.75, 0.18, 0.12, 0.09]) // favourite stays ~0.70, not ~0.65
*/
export function devigPower(impliedProbs: number[]): number[] {
if (impliedProbs.length === 0) return [];
// Clamp into the open interval: p^k is only monotonic in k for 0 < p < 1, and
// an exact 0 or 1 pins the bisection regardless of the rest of the field.
// Clamping also means an all-zero market cannot divide by zero: every runner
// ends up at the floor and the field comes back uniform.
const clamped = impliedProbs.map((p) =>
Math.min(1 - 1e-9, Math.max(1e-9, p))
);
const sum = clamped.reduce((acc, p) => acc + p, 0);
// A single runner, or a book with no overround to strip, has no exponent to
// find — fall through to proportional scaling.
if (clamped.length === 1 || sum <= 1) {
return normalizeProbabilities(clamped);
}
let low = 0.01;
let high = 10;
for (let i = 0; i < 60; i++) {
const mid = (low + high) / 2;
const total = clamped.reduce((acc, p) => acc + Math.pow(p, mid), 0);
if (total > 1) {
low = mid;
} else {
high = mid;
}
}
const k = (low + high) / 2;
// Renormalize: bisection lands within float noise of 1.0, not exactly on it.
return normalizeProbabilities(clamped.map((p) => Math.pow(p, k)));
}
/**
* Decompress championship probability to single-game strength
*

View file

@ -20,11 +20,6 @@ import type { ProbabilityDistribution } from "./ev-calculator";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
import { findSportsSeasonById } from "~/models/sports-season";
import { getManifestSimulatorProfile } from "~/services/simulations/manifest";
import { logger } from "~/lib/logger";
/**
* Result of probability update operation
@ -101,40 +96,6 @@ function createFinishedProbabilities(finalPosition: number): number[] {
return probs;
}
/**
* Whether this season's still-alive participants should be refreshed by re-running its
* simulator instead of by the ICM recalculation below.
*
* If the season has a simulator that reads its bracket, that simulator is simply a better
* answer than ICM to "what happens from here": it seeds from the real draw and replays every
* completed match, where ICM re-derives a whole distribution from P(1st) alone and knows
* nothing about who is playing whom or what has already been decided. That blindness is what
* makes ICM report a placement floor the league has already paid out as worth less than its
* awarded points.
*
* Where the EVs originally came from is not consulted, because the alternative here is not
* leaving them alone the ICM branch overwrites them either way. Given the choice between
* two overwrites, the bracket-aware one wins.
*
* The gate is `bracketAware`, not merely "has a simulator": re-running a bracket-blind
* simulator would re-draw the field and hand equity back to teams already knocked out.
*/
async function shouldRerunSimulator(sportsSeasonId: string): Promise<boolean> {
// A completed season cannot be simulated — runSportsSeasonSimulation rejects it outright —
// and finalizeQualifyingPoints marks the season completed immediately before calling here,
// so taking this branch there would fail every single time and leave anyone still in the
// unfinished set on permanently stale probabilities. It is not a failure, it is not this
// branch's case: the season is over, every placement is final, and the floor this branch
// exists to protect can no longer be contradicted. Fall through to ICM as before.
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (sportsSeason?.status === "completed") return false;
const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
if (!simulatorConfig) return false;
return getManifestSimulatorProfile(simulatorConfig.simulatorType)?.bracketAware === true;
}
/**
* Update probabilities for a sports season after results come in
*
@ -142,8 +103,7 @@ async function shouldRerunSimulator(sportsSeasonId: string): Promise<boolean> {
* 1. Get all participant results (finished participants)
* 2. Get all existing participant EVs
* 3. For finished participants: set 100% at their placement
* 4. For unfinished participants: re-run the season's bracket-aware simulator if it has one,
* otherwise recalculate using ICM with remaining participants
* 4. For unfinished participants: recalculate using ICM with remaining participants
*
* @param sportsSeasonId Sports season to update
* @param recalculateUnfinished Whether to recalculate unfinished participants (default true)
@ -163,63 +123,55 @@ export async function updateProbabilitiesAfterResult(
// Get all existing EVs
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
// Create map of participantId -> finalPosition.
//
// Provisional rows (isPartialScore) are NOT finished: they are the guaranteed
// minimum for someone still alive — a bracket entry floor, or the floor banked
// by winning a round. Treating them as finished pins the participant to 100% at
// that floor and drops them from the ICM recalculation below, which would zero
// the championship odds of every team still playing. They belong in the
// unfinished set until a real result lands.
// Create map of participantId -> finalPosition
const finishedMap = new Map(
results
.filter(r => r.finalPosition !== null && !r.isPartialScore)
.filter(r => r.finalPosition !== null)
.map(r => [r.participantId, r.finalPosition ?? 0])
);
// Update finished participants
// Use default scoring rules (we only care about setting probabilities, not EV for finished)
const defaultScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
};
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
// Running these in parallel would race on that shared state.
for (const [participantId, finalPosition] of finishedMap.entries()) {
try {
const probs = createFinishedProbabilities(finalPosition);
const probabilities = arrayToProbabilityDistribution(probs);
await upsertParticipantEV({
participantId,
sportsSeasonId,
probabilities,
scoringRules: defaultScoringRules,
source: 'manual', // Result is from actual outcome
});
updated++;
} catch (error) {
errors.push(`Failed to update participant ${participantId}: ${error}`);
}
}
// Recalculate unfinished participants if requested
if (recalculateUnfinished) {
const unfinishedEVs = existingEVs.filter(
ev => !finishedMap.has(ev.participantId)
);
if (unfinishedEVs.length > 0 && (await shouldRerunSimulator(sportsSeasonId))) {
// The simulator reads the bracket, so it already knows this result: it seeds from the
// real draw and replays every completed match. Re-running it keeps each participant's
// distribution consistent with the games actually played — including the placement
// floors a bracket entry or a non-scoring-round win has already banked, which the ICM
// branch below cannot see and would value below points the league has paid out.
//
// Imported lazily: probability-updater → runner → scoring-calculator →
// probability-updater is a module cycle, and a static import leaves the binding
// undefined at module-init time.
try {
const { runSportsSeasonSimulation } = await import("~/services/simulations/runner");
// Probabilities only. Our callers recalculate standings themselves right after this,
// and recalculateAffectedLeagues detects change by diffing teamStandings across its
// own recalculation — a recalculation slipped in here empties that diff and silently
// suppresses the Discord standings post, and rolls previousRank forward a second time
// so rank movement disappears. The daily EV snapshot is not ours to write either: it
// is keyed by date, so writing it per result overwrites the day with intra-day values.
await runSportsSeasonSimulation(sportsSeasonId, {
skipStandingsRecalc: true,
skipSnapshots: true,
});
updated += unfinishedEVs.length;
} catch (error) {
// A run already in flight, failed readiness, or a bracket the simulator refuses to
// read (afl_10 seeded into only some of its slots). Leave the existing probabilities
// alone rather than falling back to ICM — for these seasons ICM is precisely the
// thing being replaced, and reintroducing it here would reintroduce sub-floor EVs.
// Completed seasons never reach this: shouldRerunSimulator excludes them.
logger.error(
`[ProbabilityUpdater] Failed to re-run simulator for sports season ${sportsSeasonId}; ` +
`leaving existing probabilities in place:`,
error
);
errors.push(`Failed to re-run simulator for sports season ${sportsSeasonId}: ${error}`);
}
} else if (unfinishedEVs.length > 0) {
if (unfinishedEVs.length > 0) {
// Get their current championship probabilities (use existing P(1st) as proxy)
const unfinishedOdds = unfinishedEVs.map(ev => {
const pFirst = parseFloat(ev.probFirst);
@ -257,7 +209,7 @@ export async function updateProbabilitiesAfterResult(
participantId,
sportsSeasonId,
probabilities,
scoringRules: DEFAULT_SCORING_RULES,
scoringRules: defaultScoringRules,
source: 'futures_odds', // Recalculated from remaining odds
});
@ -269,38 +221,6 @@ export async function updateProbabilitiesAfterResult(
}
}
// Update finished participants. The shared default table is used because we only
// care about setting probabilities here, not the EV — each league re-derives its own
// EV from the stored probabilities in calculateTeamProjectedScore.
//
// This runs *after* the recalculation above, not before, because re-running a simulator
// rewrites every participant in the season — the finalized ones included. A finalized
// placement is a fact, not a projection, so it is written last and wins: if a simulator
// ever puts a knocked-out team back in contention (a bracket-aware one whose bracket has
// since been cleared and not re-seeded, say), the pin still zeroes them.
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
// Running these in parallel would race on that shared state.
for (const [participantId, finalPosition] of finishedMap.entries()) {
try {
const probs = createFinishedProbabilities(finalPosition);
const probabilities = arrayToProbabilityDistribution(probs);
await upsertParticipantEV({
participantId,
sportsSeasonId,
probabilities,
scoringRules: DEFAULT_SCORING_RULES,
source: 'manual', // Result is from actual outcome
});
updated++;
} catch (error) {
errors.push(`Failed to update participant ${participantId}: ${error}`);
}
}
return {
finishedParticipants: finishedMap.size,
unfishedParticipants: existingEVs.length - finishedMap.size,

View file

@ -125,22 +125,12 @@ export async function notifyQualifyingPointsUpdate(
});
const seasonMap = new Map(seasons.map((s) => [s.id, s]));
// Batch-fetch participant display names once (same participants across all leagues).
// Includes every drafted participant — the Drafted Participants scoreboard section
// lists the whole scored field, not just this sync's changed participants.
const allParticipantIds = [
...new Set([...qpEarnedById.keys(), ...eliminatedIds, ...draftedParticipantIds]),
];
// Batch-fetch participant display names once (same participants across all leagues)
const allParticipantIds = [...new Set([...qpEarnedById.keys(), ...eliminatedIds])];
const participants = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, allParticipantIds),
});
const participantNameById = new Map(participants.map((p) => [p.id, p.name]));
// A league's draft picks span every sport in that fantasy season, so the scoreboard
// must be scoped to participants belonging to the sports season being announced —
// otherwise a golf pick would surface in a tennis event's standings section.
const sportsSeasonParticipantIds = new Set(
participants.filter((p) => p.sportsSeasonId === sportsSeasonId).map((p) => p.id)
);
// Batch-fetch all team owners and their Discord IDs in two queries (not N per league)
const allOwnerIds = new Set<string>();
@ -167,12 +157,6 @@ export async function notifyQualifyingPointsUpdate(
const seasonName = `${league.name} ${season.year}`;
// Link the embed title to this league's sport-season standings page. The
// sportsSeasonId is exactly what the /leagues/:leagueId/sports-seasons/:sportsSeasonId
// route expects (the page filters seasonSports by leagueId).
const appUrl = process.env.APP_URL ?? "https://brackt.com";
const standingsUrl = `${appUrl}/leagues/${league.id}/sports-seasons/${sportsSeasonId}`;
const picks = picksBySeasonId.get(seasonId) ?? [];
const teamByParticipantId = new Map<
string,
@ -218,25 +202,6 @@ export async function notifyQualifyingPointsUpdate(
};
});
// Full current scoreboard for this league: every drafted participant, regardless of
// whether their QP changed this sync. Drives the Drafted Participants section so
// it reads as a season-standings snapshot rather than only this event's movers.
// Never pinged, so ownerDiscordUserId is intentionally omitted.
const scoreboard: QPEventEntry[] = [...teamByParticipantId.keys()]
.filter((participantId) => sportsSeasonParticipantIds.has(participantId))
.map((participantId) => {
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
const globalRank = globalRankById.get(participantId) ?? 0;
return {
participantName: participantNameById.get(participantId) ?? participantId,
qpEarned: qpEarnedById.get(participantId) ?? 0,
qpTotal: qpTotalById.get(participantId) ?? 0,
globalRank,
globalRankTied: (countByRank.get(globalRank) ?? 0) > 1,
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
};
});
await sendQualifyingPointsUpdateNotification({
webhookUrl,
seasonName,
@ -244,8 +209,6 @@ export async function notifyQualifyingPointsUpdate(
eventName,
entries,
eliminated,
scoreboard,
standingsUrl,
});
}
}

View file

@ -1,15 +1,6 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { normalizeTeamName } from "~/lib/normalize-team-name";
import {
getTeamData,
eloWinProbability,
AFLSimulator,
readAflBracketSeeds,
simAFLFinals,
type BracketMatch,
} from "../afl-simulator";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
import { calculateEV, type ProbabilityDistribution } from "~/services/ev-calculator";
import { getTeamData, eloWinProbability, AFLSimulator } from "../afl-simulator";
// ─── normalizeTeamName ────────────────────────────────────────────────────────
@ -134,82 +125,8 @@ const PARTICIPANT_ROWS = AFL_TEAMS.map((name, i) => ({
const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id);
/**
* Build the playoff_matches rows generateAFL10Bracket writes, seeded with `seedIds` in
* ladder order (index 0 = minor premier). `completed` overrides individual matches with a
* recorded result.
*/
function aflBracketMatches(
seedIds: string[],
completed: Array<{ round: string; matchNumber: number; winnerId: string; loserId: string }> = []
): BracketMatch[] {
const seed = (n: number) => seedIds[n - 1] ?? null;
const rows: BracketMatch[] = [
{ round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
{ round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
{ round: "Qualifying Finals", matchNumber: 1, participant1Id: seed(1), participant2Id: seed(4) },
{ round: "Qualifying Finals", matchNumber: 2, participant1Id: seed(2), participant2Id: seed(3) },
// participant2 is TBD until a Wildcard winner advances into it.
{ round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
{ round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
{ round: "Semi-Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
{ round: "Semi-Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
{ round: "Preliminary Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
{ round: "Preliminary Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
{ round: "Grand Final", matchNumber: 1, participant1Id: null, participant2Id: null },
].map((m) => ({ ...m, winnerId: null, loserId: null, isComplete: false }));
for (const done of completed) {
const row = rows.find((r) => r.round === done.round && r.matchNumber === done.matchNumber);
if (!row) throw new Error(`no such match: ${done.round} #${done.matchNumber}`);
row.isComplete = true;
row.winnerId = done.winnerId;
row.loserId = done.loserId;
// A Wildcard winner is advanced into the Elimination Final it feeds.
if (done.round === "Wildcard Round") {
const ef = rows.find(
(r) => r.round === "Elimination Finals" && r.matchNumber === (done.matchNumber === 1 ? 2 : 1)
);
if (ef) ef.participant2Id = done.winnerId;
}
}
return rows;
}
/** The one bracket row for a round/match, failing loudly if the fixture changes shape. */
function matchIn(matches: BracketMatch[], round: string, matchNumber: number): BracketMatch {
const found = matches.find((m) => m.round === round && m.matchNumber === matchNumber);
if (!found) throw new Error(`no such match: ${round} #${matchNumber}`);
return found;
}
/** Look up one participant's result, failing loudly rather than silently passing on undefined. */
function resultFor<T extends { participantId: string }>(results: T[], participantId: string): T {
const found = results.find((r) => r.participantId === participantId);
if (!found) throw new Error(`no simulation result for ${participantId}`);
return found;
}
/** EV on the reference scale the runner persists with. */
function evOf(result: { probabilities: ProbabilityDistribution }): number {
return calculateEV(result.probabilities, DEFAULT_SCORING_RULES);
}
describe("AFLSimulator.simulate()", () => {
let mockDb: {
select: MockInstance;
query: {
scoringEvents: { findMany: MockInstance };
playoffMatches: { findMany: MockInstance };
};
};
/** Put a seeded afl_10 bracket in front of the simulator. */
function seedBracket(matches: BracketMatch[]) {
mockDb.query.scoringEvents.findMany.mockResolvedValue([{ id: "event-1" }]);
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
}
let mockDb: { select: MockInstance };
beforeEach(async () => {
const { database } = await import("~/database/context");
@ -219,11 +136,6 @@ describe("AFLSimulator.simulate()", () => {
let selectCallCount = 0;
mockDb = {
// Default: no bracket generated yet, so the ladder-projection path runs.
query: {
scoringEvents: { findMany: vi.fn().mockResolvedValue([]) },
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
},
select: vi.fn().mockImplementation(() => {
selectCallCount++;
if (selectCallCount === 1) {
@ -443,259 +355,4 @@ describe("AFLSimulator.simulate()", () => {
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst);
});
// ─── Bracket-aware mode ─────────────────────────────────────────────────────
//
// afl_10 banks points on seeding alone (entryFloor 5 for seeds 1-4, 7 for seeds 5-6) and
// on winning a non-scoring round (nonScoringWinnerFloor 7 for the Wildcard Round, 3 for a
// Qualifying Final). Those floors are paid out as real fantasy points, so a simulator that
// re-draws the ladder every iteration — putting a seeded team back in the Wildcard Round or
// out of the finals, where it scores 0 — reports an EV below points already awarded. Each
// EV assertion below is that floor.
describe("bracket-aware mode", () => {
/**
* Seeds 1-10 in ladder order, drawn from the ten *weakest* clubs by Elo. Seeding the
* strongest ten would let the ladder-projection path produce much the same field by
* accident, so the floor assertions below would pass even with the bracket ignored.
*/
const SEEDS = PARTICIPANT_IDS.slice(8);
it("never values a seed below the entry floor its seeding already banked", async () => {
seedBracket(aflBracketMatches(SEEDS));
const results = await new AFLSimulator().simulate("season-1");
// Seeds 1-4 enter a Qualifying Final: lose it, lose the Semi-Final, still 5th-6th (25).
for (const seed of [1, 2, 3, 4]) {
expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(25);
}
// Seeds 5-6 enter an Elimination Final: lose it and they are 7th-8th (15).
for (const seed of [5, 6]) {
expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(15);
}
});
it("keeps a Qualifying Final entrant out of the 7th-8th tier entirely", async () => {
seedBracket(aflBracketMatches(SEEDS));
const results = await new AFLSimulator().simulate("season-1");
// A seed 1-4 loses the QF into a Semi-Final, so 5th-6th is its worst finish. The
// 7th-8th tier is reachable only by losing an Elimination Final.
for (const seed of [1, 2, 3, 4]) {
expect(resultFor(results, SEEDS[seed - 1]).probabilities.probSeventh, `seed ${seed}`).toBe(0);
}
// Seeds 5-10 all reach an Elimination Final only by playing one, so they can.
expect(resultFor(results, SEEDS[4]).probabilities.probSeventh).toBeGreaterThan(0);
});
it("uses the bracket's draw rather than a re-projected ladder", async () => {
// Deliberately inverted: the weakest club is the minor premier and the strongest
// scrapes in 10th. On the ladder-projection path Elo decides the seeding, so this only
// holds if the bracket's own slots are being read.
const inverted = [
"team-18", "team-17", "team-16", "team-15", "team-14",
"team-13", "team-12", "team-11", "team-10", "team-1",
];
seedBracket(aflBracketMatches(inverted));
const results = await new AFLSimulator().simulate("season-1");
// West Coast (weakest Elo) is seeded 1, so it holds the double chance and can never
// finish 7th-8th, and its EV clears the seed 1-4 floor.
expect(resultFor(results, "team-18").probabilities.probSeventh).toBe(0);
expect(evOf(resultFor(results, "team-18"))).toBeGreaterThanOrEqual(25);
// Western Bulldogs (strongest Elo) is seeded 10, so it starts in the Wildcard Round
// with nothing banked and can be knocked out for 0.
expect(resultFor(results, "team-1").probabilities.probSeventh).toBeGreaterThan(0);
});
it("zeroes every participant outside the bracket", async () => {
seedBracket(aflBracketMatches(SEEDS));
const results = await new AFLSimulator().simulate("season-1");
for (const r of results.filter((x) => !SEEDS.includes(x.participantId))) {
expect(evOf(r), r.participantId).toBe(0);
}
expect(results).toHaveLength(18);
});
it("still normalizes every column to 1.0 and the field to 340 total EV", async () => {
seedBracket(aflBracketMatches(SEEDS));
const results = await new AFLSimulator().simulate("season-1");
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(colSum, `${key} column sum`).toBeCloseTo(1.0, 6);
}
expect(results.reduce((s, r) => s + evOf(r), 0)).toBeCloseTo(340, 4);
});
it("replays a completed Wildcard Round instead of re-simulating it", async () => {
// Seed 10 beat seed 7, which banks seed 10 a 7th-place floor (15 points).
seedBracket(
aflBracketMatches(SEEDS, [
{ round: "Wildcard Round", matchNumber: 1, winnerId: SEEDS[9], loserId: SEEDS[6] },
])
);
const results = await new AFLSimulator().simulate("season-1");
expect(evOf(resultFor(results, SEEDS[9]))).toBeGreaterThanOrEqual(15);
// The loser is out with nothing, in every iteration.
expect(evOf(resultFor(results, SEEDS[6]))).toBe(0);
});
it("replays a completed Qualifying Final, banking the winner's 3rd-4th floor", async () => {
// Seed 1 beat seed 4: the winner byes into a Preliminary Final (floor 3rd, 45 points)
// and the loser drops into a Semi-Final (floor 5th, 25 points).
seedBracket(
aflBracketMatches(SEEDS, [
{ round: "Qualifying Finals", matchNumber: 1, winnerId: SEEDS[0], loserId: SEEDS[3] },
])
);
const results = await new AFLSimulator().simulate("season-1");
const winner = resultFor(results, SEEDS[0]);
expect(evOf(winner)).toBeGreaterThanOrEqual(45);
// Already through to a Preliminary Final, so the 5th-6th tier is behind it.
expect(winner.probabilities.probFifth).toBe(0);
expect(evOf(resultFor(results, SEEDS[3]))).toBeGreaterThanOrEqual(25);
});
it("falls back to the ladder projection when the bracket carries no seeds", async () => {
seedBracket(aflBracketMatches([]));
const results = await new AFLSimulator().simulate("season-1");
// Every club is back in contention, so nobody is structurally zeroed.
expect(results.filter((r) => evOf(r) > 0).length).toBeGreaterThan(10);
});
});
});
// ─── readAflBracketSeeds ──────────────────────────────────────────────────────
describe("readAflBracketSeeds", () => {
const teamsById = new Map(
PARTICIPANT_IDS.map((id) => [id, { id, name: id, elo: 1500, currentWins: 0, remainingGames: 0, winProb: 0.5 }])
);
const SEEDS = PARTICIPANT_IDS.slice(0, 10);
it("returns null when there is no bracket at all", () => {
expect(readAflBracketSeeds([], teamsById as never)).toBeNull();
});
it("returns null for a generated but unseeded bracket", () => {
expect(readAflBracketSeeds(aflBracketMatches([]), teamsById as never)).toBeNull();
});
it("reads the 10 seeds in ladder order", () => {
const bracket = readAflBracketSeeds(aflBracketMatches(SEEDS), teamsById as never);
expect(bracket?.seeds.map((t) => t.id)).toEqual(SEEDS);
});
it("does not treat the TBD Elimination Final slots as missing seeds", () => {
const matches = aflBracketMatches(SEEDS);
for (const m of matches.filter((r) => r.round === "Elimination Finals")) {
expect(m.participant2Id).toBeNull();
}
expect(readAflBracketSeeds(matches, teamsById as never)).not.toBeNull();
});
it("throws on a partially seeded bracket rather than discarding the draw", () => {
const matches = aflBracketMatches(SEEDS);
// ON DELETE SET NULL empties a slot when a participant is removed and re-added.
matchIn(matches, "Qualifying Finals", 1).participant2Id = null;
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/partially seeded.*seed\(s\) 4/s);
});
it("throws when one participant holds two slots", () => {
const matches = aflBracketMatches(SEEDS);
matchIn(matches, "Wildcard Round", 1).participant2Id = SEEDS[0];
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/more than one slot/);
});
it("throws when the bracket references a participant outside the season", () => {
const matches = aflBracketMatches(SEEDS);
matchIn(matches, "Wildcard Round", 1).participant2Id = "ghost";
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/not in this sports season/);
});
});
// ─── simAFLFinals ─────────────────────────────────────────────────────────────
describe("simAFLFinals bracket pathways", () => {
const finalists = Array.from({ length: 10 }, (_, i) => ({
id: `s${i + 1}`,
name: `s${i + 1}`,
elo: 1500,
currentWins: 0,
remainingGames: 0,
winProb: 0.5,
}));
/**
* Play the finals with the Wildcard Round forced to the given winners (every other
* game goes to whoever was routed in first), and report who met whom.
*/
function pairingsWith(wc1Winner: string, wc2Winner: string): Map<string, [string, string]> {
const pairings = new Map<string, [string, string]>();
const play = (
round: string,
matchNumber: number,
t1: { id: string },
t2: { id: string }
) => {
pairings.set(`${round}#${matchNumber}`, [t1.id, t2.id]);
if (round === "Wildcard Round") {
const forced = matchNumber === 1 ? wc1Winner : wc2Winner;
return t1.id === forced ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
}
return { winner: t1, loser: t2 };
};
simAFLFinals(finalists as never, play as never);
return pairings;
}
it("draws the Wildcard Round 7v10 and 8v9", () => {
const pairings = pairingsWith("s7", "s8");
expect(pairings.get("Wildcard Round#1")).toEqual(["s7", "s10"]);
expect(pairings.get("Wildcard Round#2")).toEqual(["s8", "s9"]);
});
it.each([
{ wc1: "s7", wc2: "s8", ef1: "s8", ef2: "s7" },
{ wc1: "s7", wc2: "s9", ef1: "s9", ef2: "s7" },
// 10th beating 7th is where a fixed crossover misfires: it would send 10th to 6th
// and leave 5th with the stronger survivor.
{ wc1: "s10", wc2: "s8", ef1: "s10", ef2: "s8" },
{ wc1: "s10", wc2: "s9", ef1: "s10", ef2: "s9" },
])(
"pairs 5th with $ef1 and 6th with $ef2 when $wc1 and $wc2 win through",
({ wc1, wc2, ef1, ef2 }) => {
const pairings = pairingsWith(wc1, wc2);
expect(pairings.get("Elimination Finals#1")).toEqual(["s5", ef1]);
expect(pairings.get("Elimination Finals#2")).toEqual(["s6", ef2]);
}
);
// The pathway out of the Elimination Finals is fixed (EF n → SF n) — unlike the
// Wildcard Round's re-seed. The crossover lands a round later, at the Prelims, so a
// Qualifying Final loser cannot meet the side that just beat it. `play` here hands
// every non-Wildcard game to participant1, so QF1 sends s1 through and s4 down.
it("feeds each Elimination Final into the Semi-Final of the same number", () => {
const pairings = pairingsWith("s7", "s8");
expect(pairings.get("Semi-Finals#1")).toEqual(["s4", "s5"]);
expect(pairings.get("Semi-Finals#2")).toEqual(["s3", "s6"]);
});
it("crosses the Semi-Final winners over into the Preliminary Finals", () => {
const pairings = pairingsWith("s7", "s8");
expect(pairings.get("Preliminary Finals#1")).toEqual(["s1", "s3"]);
expect(pairings.get("Preliminary Finals#2")).toEqual(["s2", "s4"]);
});
});

View file

@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { AutoRacingSimulator } from "../auto-racing-simulator";
import { F1_RACE_POINTS, INDYCAR_RACE_POINTS } from "../race-points";
vi.mock("~/database/context", () => ({
database: vi.fn(),
@ -14,18 +13,18 @@ vi.mock("~/models/participant-expected-value", () => ({
getAllParticipantEVsForSeason: vi.fn(),
}));
vi.mock("~/models/season-races", () => ({
countSeasonRaces: vi.fn(),
}));
// ─── F1 race points (positions 110) ─────────────────────────────────────────
const F1_RACE_POINTS: Record<number, number> = {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1,
};
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const DRIVERS = ["d1", "d2", "d3", "d4", "d5"].map((id) => ({ id }));
const PROB_KEYS = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
function makeEvent(isComplete: boolean, eventType = "race") {
return { isComplete, eventType };
}
function makeSeasonResult(participantId: string, currentPoints: string) {
return { participant: { id: participantId }, currentPoints };
@ -35,66 +34,53 @@ function makeEv(participantId: string, sourceOdds: number | null) {
return { participantId, sourceOdds };
}
function mockDb(drivers: { id: string }[] = DRIVERS) {
function mockDb(events: ReturnType<typeof makeEvent>[]) {
return {
query: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue(drivers),
findMany: vi.fn().mockResolvedValue(DRIVERS),
},
scoringEvents: {
findMany: vi.fn().mockResolvedValue(events),
},
},
};
}
/** Set the race counts the simulator reads from the calendar. */
async function setRaceCounts(completed: number, remaining: number) {
const { countSeasonRaces } = await import("~/models/season-races");
(countSeasonRaces as unknown as MockInstance).mockResolvedValue({
completed,
remaining,
total: completed + remaining,
});
}
async function setStandings(results: ReturnType<typeof makeSeasonResult>[]) {
const { getSeasonResults } = await import("~/models/participant-season-result");
(getSeasonResults as unknown as MockInstance).mockResolvedValue(results);
}
async function setOdds(evs: ReturnType<typeof makeEv>[]) {
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue(evs);
}
async function useDrivers(drivers: { id: string }[]) {
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue(mockDb(drivers));
}
// ─── Setup ────────────────────────────────────────────────────────────────────
let db: ReturnType<typeof mockDb>;
beforeEach(async () => {
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue(mockDb());
await setStandings([]);
await setOdds([]);
await setRaceCounts(0, 0);
const { getSeasonResults } = await import("~/models/participant-season-result");
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
db = mockDb([]);
(database as unknown as MockInstance).mockReturnValue(db);
(getSeasonResults as unknown as MockInstance).mockResolvedValue([]);
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([]);
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("AutoRacingSimulator", () => {
it("throws when no participants are found", async () => {
await useDrivers([]);
db.query.seasonParticipants.findMany.mockResolvedValue([]);
await expect(
new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1")
).rejects.toThrow(/No participants found/);
});
describe("pre-season path (no races run, none remaining)", () => {
describe("pre-season path (remainingRaces === 0)", () => {
beforeEach(async () => {
await setRaceCounts(0, 0);
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
// No scoring events → remainingRaces = 0
db = mockDb([]);
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue(db);
// Heavy favourite: d1 at 500, all others at +1000
await setOdds([
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
makeEv("d1", -500),
makeEv("d2", 1000),
makeEv("d3", 1000),
@ -110,7 +96,11 @@ describe("AutoRacingSimulator", () => {
it("normalizes each position column to sum to 1.0", async () => {
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
for (const key of PROB_KEYS) {
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
}
@ -127,7 +117,8 @@ describe("AutoRacingSimulator", () => {
});
it("drivers without odds get equal fallback probability", async () => {
await setOdds([]);
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
// With equal weights all 5 drivers should finish 1st roughly equally
for (const r of results) {
@ -135,145 +126,30 @@ describe("AutoRacingSimulator", () => {
expect(r.probabilities.probFirst).toBeLessThan(0.3);
}
});
it("prices an unpriced driver at the longest price in the book", async () => {
// d5 has no odds; d2d4 are +1000 long shots. An unpriced driver used to
// be handed 1/N, which rated them above most of the priced field.
await setOdds([
makeEv("d1", -500),
makeEv("d2", 1000),
makeEv("d3", 1000),
makeEv("d4", 1000),
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const byId = new Map(results.map((r) => [r.participantId, r.probabilities.probFirst]));
const unpriced = byId.get("d5") ?? 0;
const longShot = byId.get("d2") ?? 0;
expect(unpriced).toBeCloseTo(longShot, 1);
expect(byId.get("d1") ?? 0).toBeGreaterThan(longShot * 3);
});
it("does not let a thinly priced book flatten the favourite", async () => {
// Only one driver is priced. Anchoring the rest to "the longest price"
// would make that price the whole book and hand out a uniform field, so
// a single-price book keeps the 1/N fallback for the others.
// (Readiness requires odds for every participant, so this is a fallback
// path rather than a supported configuration.)
await setOdds([makeEv("d1", -500)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const byId = new Map(results.map((r) => [r.participantId, r.probabilities.probFirst]));
expect(byId.get("d1") ?? 0).toBeGreaterThan(0.4);
expect(byId.get("d2") ?? 0).toBeLessThan(0.2);
});
});
describe("season complete (races run, none remaining)", () => {
it("returns the final standings order deterministically", async () => {
await setRaceCounts(17, 0);
// getSeasonResults returns rows already sorted by championship position.
await setStandings([
makeSeasonResult("d3", "601"),
makeSeasonResult("d1", "480"),
makeSeasonResult("d5", "446"),
makeSeasonResult("d2", "420"),
makeSeasonResult("d4", "398"),
]);
// Futures odds disagree entirely — they must be ignored once it is over.
await setOdds([makeEv("d1", -10000), makeEv("d3", 20000)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const byId = new Map(results.map((r) => [r.participantId, r.probabilities]));
expect(byId.get("d3")?.probFirst).toBe(1);
expect(byId.get("d1")?.probFirst).toBe(0);
expect(byId.get("d1")?.probSecond).toBe(1);
expect(byId.get("d5")?.probThird).toBe(1);
expect(byId.get("d2")?.probFourth).toBe(1);
expect(byId.get("d4")?.probFifth).toBe(1);
});
it("ranks the whole field, not just the drivers with standings rows", async () => {
// The settled season still has to fill all eight placement columns. Only
// ranking the drivers who have a standings row leaves the trailing
// columns empty, and the residual normalization then dumps a full 1.0
// onto whichever driver happens to be first in the list.
await setRaceCounts(17, 0);
await useDrivers(Array.from({ length: 10 }, (_, i) => ({ id: `d${i + 1}` })));
await setStandings([
makeSeasonResult("d3", "601"),
makeSeasonResult("d1", "480"),
makeSeasonResult("d5", "446"),
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1", {
iterations: 500,
});
const byId = new Map(results.map((r) => [r.participantId, r.probabilities]));
expect(byId.get("d3")?.probFirst).toBe(1);
expect(byId.get("d1")?.probSecond).toBe(1);
expect(byId.get("d5")?.probThird).toBe(1);
// No driver may hold two placements at once.
for (const probs of byId.values()) {
const held = PROB_KEYS.filter((key) => probs[key] > 0.5);
expect(held.length).toBeLessThanOrEqual(1);
}
for (const key of PROB_KEYS) {
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
}
});
it("falls back to a points-ranked field when there are no standings rows", async () => {
await setRaceCounts(17, 0);
await setStandings([]);
await setOdds([makeEv("d1", -500), makeEv("d2", 1000)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1", {
iterations: 500,
});
// Still produces a usable distribution rather than all zeroes.
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 6);
});
});
describe("no race calendar", () => {
it("warns when the season has championship points but no events", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(0, 0);
await setStandings([makeSeasonResult("d1", "400"), makeSeasonResult("d2", "300")]);
await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("championship points but no race calendar")
);
warnSpy.mockRestore();
});
it("stays quiet for a genuine pre-season with no points yet", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(0, 0);
await setStandings([]);
await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe("in-season path (races remaining)", () => {
describe("in-season path (remainingRaces > 0)", () => {
beforeEach(async () => {
await setRaceCounts(10, 5);
const { database } = await import("~/database/context");
// 10 completed races, 5 remaining
db = mockDb([
...Array.from({ length: 10 }, () => makeEvent(true)),
...Array.from({ length: 5 }, () => makeEvent(false)),
]);
(database as unknown as MockInstance).mockReturnValue(db);
});
it("normalizes each position column to sum to 1.0", async () => {
await setStandings(DRIVERS.map((d, i) => makeSeasonResult(d.id, String((5 - i) * 50))));
const { getSeasonResults } = await import("~/models/participant-season-result");
(getSeasonResults as unknown as MockInstance).mockResolvedValue(
DRIVERS.map((d, i) => makeSeasonResult(d.id, String((5 - i) * 50)))
);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
for (const key of PROB_KEYS) {
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
}
@ -281,9 +157,17 @@ describe("AutoRacingSimulator", () => {
it("standings leader ranks higher than a driver far behind when standings dominate", async () => {
// 20/25 races done → seasonProgress = 0.8 → standings weighted 80%
await setRaceCounts(20, 5);
const { database } = await import("~/database/context");
db = mockDb([
...Array.from({ length: 20 }, () => makeEvent(true)),
...Array.from({ length: 5 }, () => makeEvent(false)),
]);
(database as unknown as MockInstance).mockReturnValue(db);
const { getSeasonResults } = await import("~/models/participant-season-result");
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
// d1 leads with 400 pts; d2 is a distant 2nd with 50 pts
await setStandings([
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
makeSeasonResult("d1", "400"),
makeSeasonResult("d2", "50"),
makeSeasonResult("d3", "40"),
@ -291,7 +175,7 @@ describe("AutoRacingSimulator", () => {
makeSeasonResult("d5", "20"),
]);
// Futures odds heavily favour d2 (pretend markets disagree)
await setOdds([
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
makeEv("d1", 5000), // very long shot per futures
makeEv("d2", -500), // heavy favourite per futures
]);
@ -308,7 +192,11 @@ describe("AutoRacingSimulator", () => {
it("falls back to odds for all drivers when no standings data exists", async () => {
// totalCurrentPoints = 0 → standings signal disabled, odds take over
await setOdds([makeEv("d1", -500), makeEv("d2", 1000)]);
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
makeEv("d1", -500),
makeEv("d2", 1000),
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const fav = results.find((r) => r.participantId === "d1");
const longShot = results.find((r) => r.participantId === "d2");
@ -320,17 +208,27 @@ describe("AutoRacingSimulator", () => {
});
it("a driver with 0 points mid-season is not penalized beyond their odds weight", async () => {
// Early season → standings gap is small
await setRaceCounts(2, 20);
// Use 2 completed / 20 remaining → early season, standings gap is small
const { database } = await import("~/database/context");
db = mockDb([
...Array.from({ length: 2 }, () => makeEvent(true)),
...Array.from({ length: 20 }, () => makeEvent(false)),
]);
(database as unknown as MockInstance).mockReturnValue(db);
const { getSeasonResults } = await import("~/models/participant-season-result");
// d1-d4 have a modest lead; d5 is absent (0 pts, new entry)
await setStandings([
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
makeSeasonResult("d1", "10"),
makeSeasonResult("d2", "8"),
makeSeasonResult("d3", "6"),
makeSeasonResult("d4", "4"),
// d5 intentionally absent → falls back to odds weight
]);
await setOdds([makeEv("d5", -500)]); // strong odds favourite despite 0 pts
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
makeEv("d5", -500), // strong odds favourite despite 0 pts
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
// d5 should win championships at a non-trivial rate given their strong odds weight
@ -342,8 +240,9 @@ describe("AutoRacingSimulator", () => {
it("emits a warning when participants are missing from standings", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { getSeasonResults } = await import("~/models/participant-season-result");
// Only 3 of 5 drivers have standings rows
await setStandings([
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
makeSeasonResult("d1", "100"),
makeSeasonResult("d2", "80"),
makeSeasonResult("d3", "60"),
@ -354,97 +253,19 @@ describe("AutoRacingSimulator", () => {
);
warnSpy.mockRestore();
});
});
describe("IndyCar regression: near-clinched championship leader", () => {
// The reported bug. A 121-point lead with 2 races left is arithmetically
// unassailable (max 100 available, and the leader banks at least 10), but
// the simulator skipped `schedule_event` rows, saw zero remaining races,
// took the pre-season branch and echoed stale futures odds at ~55%.
const POINTS = [
601, 480, 446, 420, 398, 372, 350, 331, 315, 300, 288, 270, 255, 240,
228, 215, 200, 188, 175, 160, 148, 135, 120, 105, 90, 70, 55,
];
const ODDS = [
-300, 450, 700, 1200, 1800, 2500, 4000, 5000, 6000, 8000, 10000, 12000,
15000, 20000, 25000, 30000, 40000, 50000, 50000, 50000, 50000, 50000,
50000, 50000, 50000, 50000, 50000,
];
const FIELD = POINTS.map((_, i) => ({ id: `driver${i}` }));
beforeEach(async () => {
await useDrivers(FIELD);
await setStandings(FIELD.map((d, i) => makeSeasonResult(d.id, String(POINTS[i]))));
await setOdds(FIELD.map((d, i) => makeEv(d.id, ODDS[i])));
});
it("gives the leader ~100% with 2 of 17 races left", async () => {
await setRaceCounts(15, 2);
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
iterations: 2000,
});
const leader = results.find((r) => r.participantId === "driver0");
expect(leader).toBeDefined();
if (!leader) return;
expect(leader.probabilities.probFirst).toBeGreaterThan(0.99);
});
it("without a calendar it can only echo the stale odds — the shape of the bug", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(0, 0);
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
iterations: 2000,
});
const leader = results.find((r) => r.participantId === "driver0");
expect(leader).toBeDefined();
if (!leader) return;
// Nowhere near the truth, which is exactly why the no-calendar warning
// above exists. Power devig keeps the -300 favourite well clear of the
// 55% that proportional devig produced, but odds alone cannot see a
// 121-point lead.
expect(leader.probabilities.probFirst).toBeLessThan(0.9);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("championship points but no race calendar")
);
warnSpy.mockRestore();
});
it("still gives the leader a commanding lead with 5 races left", async () => {
await setRaceCounts(12, 5);
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
iterations: 2000,
});
const leader = results.find((r) => r.participantId === "driver0");
expect(leader).toBeDefined();
if (!leader) return;
expect(leader.probabilities.probFirst).toBeGreaterThan(0.9);
it("schedule_event entries are excluded from race counts", async () => {
const { database } = await import("~/database/context");
// 5 real races + 3 schedule_events (should be ignored)
db = mockDb([
...Array.from({ length: 5 }, () => makeEvent(true)),
...Array.from({ length: 3 }, () => makeEvent(false, "schedule_event")),
makeEvent(false), // 1 real remaining
]);
(database as unknown as MockInstance).mockReturnValue(db);
// Should not throw and should use seasonProgress = 5/6
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(results).toHaveLength(5);
});
});
});
describe("race points tables", () => {
it("IndyCar pays 50 for a win and scores down to P26", () => {
expect(INDYCAR_RACE_POINTS[1]).toBe(50);
expect(INDYCAR_RACE_POINTS[2]).toBe(40);
expect(INDYCAR_RACE_POINTS[25]).toBe(5);
expect(INDYCAR_RACE_POINTS[26]).toBe(5);
expect(INDYCAR_RACE_POINTS[27]).toBeUndefined();
});
it("F1 pays 25 for a win and scores down to P10", () => {
expect(F1_RACE_POINTS[1]).toBe(25);
expect(F1_RACE_POINTS[10]).toBe(1);
expect(F1_RACE_POINTS[11]).toBeUndefined();
});
it("both tables decrease monotonically so the points loop never truncates early", () => {
for (const table of [F1_RACE_POINTS, INDYCAR_RACE_POINTS]) {
const positions = Object.keys(table).map(Number).toSorted((a, b) => a - b);
// Contiguous from P1, no gaps — the award loop breaks at the first 0.
positions.forEach((pos, i) => expect(pos).toBe(i + 1));
for (let i = 1; i < positions.length; i++) {
expect(table[positions[i]]).toBeLessThanOrEqual(table[positions[i - 1]]);
}
}
});
});

View file

@ -26,33 +26,6 @@ describe("simulator input policy", () => {
expect(resolved.get("team-1")).toMatchObject({ sourceElo: 1600, method: "direct" });
});
it("puts projections ahead of a stored Elo when baseEloPriority says so", () => {
// The season-level escape hatch for "projections are the source of truth here":
// without it a stale hand-entered Elo silently beats a fresh projection.
const resolved = resolveSourceElos(
[{ participantId: "team-1", sourceElo: 1600, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],
profile,
{ seasonGames: 82, parityFactor: 400, inputPolicy: { baseEloPriority: ["projectedWins", "sourceElo"] } }
);
expect(resolved.get("team-1")?.method).toBe("projectedWins");
expect(resolved.get("team-1")?.sourceElo).not.toBe(1600);
});
it("still falls back to the stored Elo for participants without a projection", () => {
const resolved = resolveSourceElos(
[
{ participantId: "projected", sourceElo: 1600, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null },
{ participantId: "elo-only", sourceElo: 1600, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
],
profile,
{ seasonGames: 82, parityFactor: 400, inputPolicy: { baseEloPriority: ["projectedWins", "sourceElo"] } }
);
expect(resolved.get("projected")?.method).toBe("projectedWins");
expect(resolved.get("elo-only")).toMatchObject({ sourceElo: 1600, method: "direct" });
});
it("derives Elo from projected wins when Elo is missing", () => {
const resolved = resolveSourceElos(
[{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],

View file

@ -1,12 +1,5 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import {
LLWSSimulator,
makePlayGame,
playCrossoverGame,
readBracketSlots,
} from "../llws-simulator";
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
import type { SimulationResult } from "../types";
import { LLWSSimulator } from "../llws-simulator";
vi.mock("~/database/context", () => ({
database: vi.fn(),
@ -34,168 +27,28 @@ function makeEvRows(ids: string[], opts: { includeOdds?: boolean } = {}) {
}));
}
// ─── Bracket fixtures ─────────────────────────────────────────────────────────
/** The subset of playoff_matches columns the simulator reads. */
type PlayoffMatchRow = {
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
winnerId: string | null;
loserId: string | null;
isComplete: boolean;
};
const EMPTY_MATCH: PlayoffMatchRow = {
round: "",
matchNumber: 0,
participant1Id: null,
participant2Id: null,
winnerId: null,
loserId: null,
isComplete: false,
};
/**
* A freshly generated, fully seeded llws_20 bracket with no results recorded.
*
* Mirrors generateLLWS20Bracket: U.S. matches take the low match numbers
* (Opening Round 14, Winners Round 2 12), International the high ones
* (Opening Round 58, Winners Round 2 34). Byes sit at participant1 of
* Winners Round 2. Slot order per side is ids[0..7] opening, ids[8..9] byes.
*/
function seededBracket(): PlayoffMatchRow[] {
const matches: PlayoffMatchRow[] = [];
const sides = [
{ ids: US_IDS, openingOffset: 0, wr2Offset: 0 },
{ ids: INTL_IDS, openingOffset: 4, wr2Offset: 2 },
];
for (const { ids, openingOffset, wr2Offset } of sides) {
for (let local = 1; local <= 4; local++) {
matches.push({
...EMPTY_MATCH,
round: "Opening Round",
matchNumber: local + openingOffset,
participant1Id: ids[(local - 1) * 2],
participant2Id: ids[(local - 1) * 2 + 1],
});
}
for (let local = 1; local <= 2; local++) {
matches.push({
...EMPTY_MATCH,
round: "Winners Round 2",
matchNumber: local + wr2Offset,
participant1Id: ids[8 + (local - 1)],
});
}
}
return matches;
}
/**
* Mark a bracket match complete, the way the scoring flow would once the game is
* played. `loserId` is passed explicitly for matches whose second slot is filled by
* advancement rather than by the initial seeding.
*/
function completeMatch(
matches: PlayoffMatchRow[],
round: string,
matchNumber: number,
winnerId: string,
loserId: string
): PlayoffMatchRow[] {
const existing = matches.find((m) => m.round === round && m.matchNumber === matchNumber);
const filled: PlayoffMatchRow = {
...(existing ?? { ...EMPTY_MATCH, round, matchNumber }),
participant1Id: existing?.participant1Id ?? winnerId,
participant2Id: existing?.participant2Id ?? loserId,
winnerId,
loserId,
isComplete: true,
};
return [...matches.filter((m) => m !== existing), filled];
}
/** Normalized (vig-removed) market probability for each team in an odds board. */
function marketProbabilities(odds: number[]): number[] {
const raw = odds.map(convertAmericanOddsToProbability);
const sum = raw.reduce((a, b) => a + b, 0);
return raw.map((p) => p / sum);
}
/** Look up one participant's simulated probabilities, failing loudly if absent. */
function probsFor(results: SimulationResult[], participantId: string) {
const match = results.find((r) => r.participantId === participantId);
if (!match) throw new Error(`No simulation result for ${participantId}`);
return match.probabilities;
}
/** Equal-strength Team records for direct (non-Monte-Carlo) helper tests. */
const TEST_TEAMS = new Map(
ALL_IDS.map((id) => [
id,
{
participantId: id,
side: id.startsWith("us") ? ("US" as const) : ("Intl" as const),
elo: 1500,
},
])
);
function team(participantId: string) {
const found = TEST_TEAMS.get(participantId);
if (!found) throw new Error(`No test team for ${participantId}`);
return found;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("LLWSSimulator", () => {
let mockDb: {
select: MockInstance;
query: {
scoringEvents: { findMany: MockInstance };
playoffMatches: { findMany: MockInstance };
};
};
let mockDb: { select: MockInstance };
let selectCallCount: number;
beforeEach(async () => {
selectCallCount = 0;
const { database } = await import("~/database/context");
mockDb = {
select: vi.fn(),
query: {
scoringEvents: { findMany: vi.fn().mockResolvedValue([]) },
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
},
};
mockDb = { select: vi.fn() };
(database as unknown as MockInstance).mockReturnValue(mockDb);
});
function setupMockDb(
participants: { id: string; name?: string; externalId: string | null }[],
evRows: { participantId: string; sourceOdds: number | null }[],
bracketMatches?: Partial<PlayoffMatchRow>[]
evRows: { participantId: string; sourceOdds: number | null }[]
) {
selectCallCount = 0;
mockDb.select.mockImplementation(() => {
const callIndex = selectCallCount++;
const data = callIndex === 0 ? participants : evRows;
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
if (bracketMatches) {
mockDb.query.scoringEvents.findMany.mockResolvedValue([
{ id: "event-1", createdAt: new Date("2026-08-01") },
]);
mockDb.query.playoffMatches.findMany.mockResolvedValue(
bracketMatches.map((m) => ({ ...EMPTY_MATCH, ...m }))
);
}
}
function defaultParticipants(mode: "randomized" | "fixed" = "randomized") {
@ -273,49 +126,23 @@ describe("LLWSSimulator", () => {
expect(total).toBeCloseTo(1.0, 1);
});
it("probFifth sums to ~1.0 (2 Elimination Final losers per sim, split over 5th/6th)", async () => {
it("sum of probFifth across participants equals ~1.0 (4 bracket losers, split evenly)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator(1_000).simulate("season-1");
// 4 bracket losers per sim, each assigned bracketLoser/(4*N) → sum = 1.0
const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probSeventh sums to ~1.0 (2 Elimination Round 4 losers per sim, split over 7th/8th)", async () => {
it("probFifth through probEighth are equal for every participant (even bracket-loser split)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator(1_000).simulate("season-1");
const total = results.reduce((s, r) => s + r.probabilities.probSeventh, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("ties 5th with 6th and 7th with 8th, but keeps the two tiers separate", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS, { includeOdds: true }));
const results = await new LLWSSimulator(2_000).simulate("season-1");
for (const r of results) {
const p = r.probabilities;
// Within a tier the two positions are tied.
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10);
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
}
// The tiers are distinct outcomes (losing the Elimination Final vs losing
// Elimination Round 4), so they must not be forced equal across the field.
const differs = results.some(
(r) => Math.abs(r.probabilities.probFifth - r.probabilities.probSeventh) > 1e-9
);
expect(differs).toBe(true);
});
it("gives every team a total placement probability of at most 1", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator(1_000).simulate("season-1");
for (const r of results) {
const p = r.probabilities;
// Each sim assigns a team at most one placement, so summing the distinct
// tiers (5th/6th and 7th/8th each count once) cannot exceed 1.
const total =
p.probFirst + p.probSecond + p.probThird + p.probFourth +
p.probFifth * 2 + p.probSeventh * 2;
expect(total).toBeLessThanOrEqual(1 + 1e-9);
}
});
});
@ -352,13 +179,10 @@ describe("LLWSSimulator", () => {
});
});
// ── Legacy externalId formats ─────────────────────────────────────────────
//
// The tournament no longer has pool play, but seasons configured for the old
// format still carry pool suffixes. Those must keep loading, read as the side alone.
// ── Pool assignment modes ─────────────────────────────────────────────────
describe("legacy pool-suffix externalIds", () => {
it("accepts US:A / US:B / Intl:A / Intl:B, ignoring the pool part", async () => {
describe("pool assignment modes", () => {
it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => {
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator(1_000).simulate("season-1");
expect(results).toHaveLength(20);
@ -366,10 +190,10 @@ describe("LLWSSimulator", () => {
expect(total).toBeCloseTo(1.0, 1);
});
it("accepts a mix of suffixed and bare side ids", async () => {
it("mixed mode: US fixed pools, Intl randomized", async () => {
const participants = [
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
@ -378,17 +202,6 @@ describe("LLWSSimulator", () => {
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("accepts an uneven suffix split (pools no longer constrain anything)", async () => {
const participants = [
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
...US_IDS.slice(6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
const results = await new LLWSSimulator(1_000).simulate("season-1");
expect(results).toHaveLength(20);
});
});
// ── Error cases ───────────────────────────────────────────────────────────
@ -449,502 +262,26 @@ describe("LLWSSimulator", () => {
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
});
it("throws when International team count is not 10", async () => {
it("throws when fixed pools have unequal A/B split", async () => {
const participants = [
...Array.from({ length: 9 }, (_, i) => ({ id: `us-${i + 1}`, name: `US Team ${i + 1}`, externalId: "US" })),
...Array.from({ length: 11 }, (_, i) => ({ id: `intl-${i + 1}`, name: `Team ${i + 1}`, externalId: "Intl" })),
// 6 in Pool A, 4 in Pool B
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
...US_IDS.slice(6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
});
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/exactly 5 teams each/);
});
// ── 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 12501750
// Elo span, which discarded how spread out the board actually was and pulled a
// nearly flat field apart into contenders and no-hopers the market never implied.
const TIGHT_BOARD = Array.from({ length: 20 }, (_, i) => 1500 + i * 35);
it("does not inflate the favorite on a tightly priced board", async () => {
setupMockDb(
defaultParticipants(),
ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: TIGHT_BOARD[i] }))
);
const results = await new LLWSSimulator(20_000).simulate("season-1");
const market = marketProbabilities(TIGHT_BOARD);
const simulated = probsFor(results, "us-1").probFirst;
// The favorite prices near 6%. A fixed-span mapping simulated it around 13%,
// so the band is wide enough for Monte Carlo noise but nowhere near that.
expect(Math.abs(simulated - market[0])).toBeLessThan(0.015);
});
it("keeps a tightly priced field tight", async () => {
setupMockDb(
defaultParticipants(),
ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: TIGHT_BOARD[i] }))
);
const results = await new LLWSSimulator(20_000).simulate("season-1");
const probs = ALL_IDS.map((id) => probsFor(results, id).probFirst);
// Every team prices between roughly 4% and 6%, so nobody should run away with
// it and nobody should be written off.
expect(Math.max(...probs)).toBeLessThan(0.09);
expect(Math.min(...probs)).toBeGreaterThan(0.02);
});
it("rates a team with no odds entered around the middle of the field", async () => {
// us-5 is priced mid-board; blanking its odds should not move it far. The old
// 1500 fallback was the centre of the Elo scale rather than of the field, which
// promoted an unpriced team to roughly 6th of 20.
const priced = ALL_IDS.map((participantId, i) => ({ participantId, sourceOdds: BOARD[i] }));
setupMockDb(defaultParticipants(), priced);
const withOdds = probsFor(
await new LLWSSimulator(20_000).simulate("season-1"), "us-5"
).probFirst;
const blanked = priced.map((row) =>
row.participantId === "us-5" ? { ...row, sourceOdds: null } : row
);
setupMockDb(defaultParticipants(), blanked);
const withoutOdds = probsFor(
await new LLWSSimulator(20_000).simulate("season-1"), "us-5"
).probFirst;
// Priced 5th of 20, so the median rating should land it in the same territory.
expect(withoutOdds).toBeGreaterThan(withOdds / 2);
expect(withoutOdds).toBeLessThan(withOdds * 2);
});
it("does not starve longshots of championship probability", async () => {
setupMockDb(defaultParticipants(), boardEvRows());
const results = await new LLWSSimulator(20_000).simulate("season-1");
// The longest shot on the board prices near 0.8%. Compounding raw futures drove
// teams like this to essentially zero.
const longshot = probsFor(results, "intl-10").probFirst;
expect(longshot).toBeGreaterThan(0.002);
});
});
// ── Bracket-aware mode ────────────────────────────────────────────────────
describe("bracket-aware mode", () => {
it("uses the real draw rather than shuffling when a bracket is seeded", async () => {
// With no odds every team is equally strong, so the only edge is structural:
// the two bye teams skip the Opening Round. Under a randomized draw every team
// gets a bye equally often and this difference disappears.
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), seededBracket());
const results = await new LLWSSimulator(20_000).simulate("season-1");
const byeTeam = probsFor(results, "us-9").probFirst;
const openingTeam = probsFor(results, "us-1").probFirst;
expect(byeTeam).toBeGreaterThan(openingTeam);
});
it("still returns a full, normalized distribution in bracket mode", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), seededBracket());
const results = await new LLWSSimulator(5_000).simulate("season-1");
expect(results).toHaveLength(20);
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
expect(results.reduce((s, r) => s + r.probabilities.probThird, 0)).toBeCloseTo(1.0, 1);
});
it("falls back to a randomized draw when the bracket has no participants seeded", async () => {
const unseeded = seededBracket().map((m) => ({
...m,
participant1Id: null,
participant2Id: null,
}));
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), unseeded);
const results = await new LLWSSimulator(5_000).simulate("season-1");
expect(results).toHaveLength(20);
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
});
it("uses the most recent bracket event when several exist", async () => {
// A stale event's matches would carry no draw, silently reverting to a
// randomized one and discarding every recorded result.
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), seededBracket());
mockDb.query.scoringEvents.findMany.mockResolvedValue([
{ id: "stale-event", createdAt: new Date("2026-07-01") },
{ id: "event-1", createdAt: new Date("2026-08-01") },
]);
const results = await new LLWSSimulator(20_000).simulate("season-1");
// Bracket mode is in force, so the fixed bye slots still show their advantage.
expect(probsFor(results, "us-9").probFirst).toBeGreaterThan(
probsFor(results, "us-1").probFirst
);
});
it("throws when the bracket is only partially seeded", async () => {
// participant1Id/participant2Id are ON DELETE SET NULL, so removing and
// re-adding one participant mid-tournament empties a single slot. Falling back
// to a randomized draw there would put eliminated teams back in contention.
const holed = seededBracket().map((m) =>
m.round === "Opening Round" && m.matchNumber === 3
? { ...m, participant2Id: null }
: m
);
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), holed);
await expect(new LLWSSimulator(100).simulate("season-1")).rejects.toThrow(
/partially seeded \(19 of 20/
);
});
it("throws when the bracket seeds the same team into two slots", async () => {
const duplicated = seededBracket().map((m) =>
m.round === "Opening Round" && m.matchNumber === 2
? { ...m, participant1Id: "us-1" } // us-1 already opens match 1
: m
);
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), duplicated);
await expect(new LLWSSimulator(100).simulate("season-1")).rejects.toThrow(
/more than one slot/
);
});
it("takes sides from the bracket, not externalId, once a bracket is seeded", async () => {
// The bracket is authoritative about the draw, so an externalId the pre-bracket
// path would reject must not block a season that already has a real bracket.
it("throws when US externalIds mix pool suffixes and bare side", async () => {
const participants = [
...US_IDS.map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
...INTL_IDS.slice(0, 9).map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
{ id: "intl-10", name: "Team intl-10", externalId: "CANADA" },
// Some US:A, some "US" (no pool suffix) → mixed
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })), // no pool
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS), seededBracket());
const results = await new LLWSSimulator(5_000).simulate("season-1");
expect(results).toHaveLength(20);
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
});
it("throws when the bracket is seeded with a participant outside the season", async () => {
const foreign = seededBracket().map((m) =>
m.round === "Opening Round" && m.matchNumber === 1
? { ...m, participant1Id: "stranger-1" }
: m
);
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS), foreign);
await expect(new LLWSSimulator(100).simulate("season-1")).rejects.toThrow(
/not in this sports season/
);
});
});
// ── Completed results ─────────────────────────────────────────────────────
//
// The core of the fix: games already played must stick across every iteration
// instead of being re-simulated from scratch.
describe("completed results", () => {
// us-1 is a strong favorite, so a recorded loss should visibly move its number.
const favouredEvRows = ALL_IDS.map((participantId, i) => ({
participantId,
sourceOdds: participantId === "us-1" ? 200 : 1000 + i * 200,
}));
async function probFirstFor(id: string, matches: PlayoffMatchRow[]): Promise<number> {
setupMockDb(defaultParticipants(), favouredEvRows, matches);
const results = await new LLWSSimulator(20_000).simulate("season-1");
return probsFor(results, id).probFirst;
}
it("drops a favorite's championship probability after a recorded loss", async () => {
const before = await probFirstFor("us-1", seededBracket());
// us-1 loses its Opening Round game. In double elimination that is not an
// elimination — it drops to the elimination bracket — but it now needs a much
// longer path, so its title probability must fall.
const afterLoss = completeMatch(seededBracket(), "Opening Round", 1, "us-2", "us-1");
const after = await probFirstFor("us-1", afterLoss);
expect(after).toBeLessThan(before);
// Not merely noise: a first-round loss is a real blow to a favorite.
expect(after).toBeLessThan(before * 0.8);
// But not elimination either — the elimination bracket still reaches the final.
expect(after).toBeGreaterThan(0);
});
it("raises the opponent's championship probability after that same win", async () => {
const before = await probFirstFor("us-2", seededBracket());
const afterWin = completeMatch(seededBracket(), "Opening Round", 1, "us-2", "us-1");
const after = await probFirstFor("us-2", afterWin);
expect(after).toBeGreaterThan(before);
});
it("zeroes out a team that has been eliminated (two recorded losses)", async () => {
// Fill the elimination-bracket game the way advancement would: the Opening
// Round 1 and Opening Round 4 losers meet in Elimination Round 1 match 2.
let matches = seededBracket();
matches = completeMatch(matches, "Opening Round", 1, "us-2", "us-1");
matches = completeMatch(matches, "Opening Round", 4, "us-7", "us-8");
matches = completeMatch(matches, "Elimination Round 1", 2, "us-8", "us-1");
setupMockDb(defaultParticipants(), favouredEvRows, matches);
const results = await new LLWSSimulator(5_000).simulate("season-1");
const eliminated = probsFor(results, "us-1");
// A second loss is final — every placement tier must be exactly zero.
for (const value of Object.values(eliminated)) {
expect(value).toBe(0);
}
});
it("keeps the distribution normalized once results have been recorded", async () => {
let matches = seededBracket();
matches = completeMatch(matches, "Opening Round", 1, "us-2", "us-1");
matches = completeMatch(matches, "Opening Round", 5, "intl-2", "intl-1");
setupMockDb(defaultParticipants(), favouredEvRows, matches);
const results = await new LLWSSimulator(5_000).simulate("season-1");
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
expect(results.reduce((s, r) => s + r.probabilities.probSecond, 0)).toBeCloseTo(1.0, 1);
expect(results.reduce((s, r) => s + r.probabilities.probFifth, 0)).toBeCloseTo(1.0, 1);
});
it("ignores a completed result whose participants never reach that game", async () => {
// A corrupt row: Elimination Round 1 match 2 takes the Opening Round 1 and 4
// losers, so a team from Opening Round 3 can never appear there. The game must
// be simulated instead of desynchronising the rest of the bracket.
const matches = completeMatch(
seededBracket(), "Elimination Round 1", 2, "us-5", "us-6"
);
setupMockDb(defaultParticipants(), favouredEvRows, matches);
const results = await new LLWSSimulator(5_000).simulate("season-1");
expect(results).toHaveLength(20);
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
});
/**
* Play out the entire U.S. side, so two of its teams are locked into a scoring tier:
* us-7 loses Elimination Round 4 (the 7th-8th tier) and us-9 loses the Elimination
* Final (the 5th-6th tier). Every game feeding those two is recorded, which is what
* makes the results honorable makePlayGame only replays a result when the teams
* the simulation routed into the game are the pair the result was recorded between.
*
* Slot order per side is ids[0..7] into the four Opening Round games and ids[8..9]
* as the byes, so the U.S. draw is us-1 v us-2, us-3 v us-4, us-5 v us-6,
* us-7 v us-8, with us-9 and us-10 entering at Winners Round 2.
*/
function usSidePlayedOut(): PlayoffMatchRow[] {
let matches = seededBracket();
const play = (round: string, matchNumber: number, winnerId: string, loserId: string) => {
matches = completeMatch(matches, round, matchNumber, winnerId, loserId);
};
// Winners bracket
play("Opening Round", 1, "us-1", "us-2");
play("Opening Round", 2, "us-3", "us-4");
play("Opening Round", 3, "us-5", "us-6");
play("Opening Round", 4, "us-7", "us-8");
play("Winners Round 2", 1, "us-9", "us-1"); // bye us-9 v OP1 winner
play("Winners Round 2", 2, "us-10", "us-3"); // bye us-10 v OP2 winner
play("Winners Semifinals", 1, "us-5", "us-9");
play("Winners Semifinals", 2, "us-10", "us-7");
play("Winners Final", 1, "us-5", "us-10");
// Elimination bracket, including the deliberate cross-overs
play("Elimination Round 1", 1, "us-4", "us-6"); // OP2 loser v OP3 loser
play("Elimination Round 1", 2, "us-2", "us-8"); // OP1 loser v OP4 loser
play("Elimination Round 2", 1, "us-1", "us-4");
play("Elimination Round 2", 2, "us-3", "us-2");
play("Elimination Round 3", 1, "us-9", "us-3");
play("Elimination Round 3", 2, "us-7", "us-1");
play("Elimination Round 4", 1, "us-9", "us-7"); // us-7 out: 7th-8th tier
play("Elimination Final", 1, "us-10", "us-9"); // us-9 out: 5th-6th tier
return matches;
}
it("puts a team locked into the 5th-6th tier at exactly 50/50 across those two spots", async () => {
setupMockDb(defaultParticipants(), favouredEvRows, usSidePlayedOut());
const results = await new LLWSSimulator(2_000).simulate("season-1");
const locked = probsFor(results, "us-9");
// The tier is two tied positions, so its probability splits evenly across them.
// Under DEFAULT_SCORING_RULES that is 0.5 x 25 + 0.5 x 25 = 25 points of EV —
// the 5th-6th tier value, not the flat 5th-8th average of 20.
expect(locked.probFifth).toBe(0.5);
expect(locked.probSixth).toBe(0.5);
expect(locked.probSeventh).toBe(0);
expect(locked.probEighth).toBe(0);
expect(locked.probFirst + locked.probSecond + locked.probThird + locked.probFourth).toBe(0);
});
it("puts a team locked into the 7th-8th tier at exactly 50/50 across those two spots", async () => {
setupMockDb(defaultParticipants(), favouredEvRows, usSidePlayedOut());
const results = await new LLWSSimulator(2_000).simulate("season-1");
const locked = probsFor(results, "us-7");
// 0.5 x 15 + 0.5 x 15 = 15 points of EV, again distinct from the flat 20.
expect(locked.probSeventh).toBe(0.5);
expect(locked.probEighth).toBe(0.5);
expect(locked.probFifth).toBe(0);
expect(locked.probSixth).toBe(0);
expect(locked.probFirst + locked.probSecond + locked.probThird + locked.probFourth).toBe(0);
});
});
// ── Result-honoring rules ─────────────────────────────────────────────────
//
// Tested directly rather than through the Monte Carlo output: the aggregate only
// shows these effects diluted by how often a given pairing occurs, which is too
// noisy to assert on.
describe("result-honoring rules", () => {
function bracketOf(matches: PlayoffMatchRow[]) {
const bracket = readBracketSlots(matches, TEST_TEAMS);
if (!bracket) throw new Error("Expected the seeded bracket to be readable");
return bracket;
}
const us1 = team("us-1");
const us2 = team("us-2");
const us5 = team("us-5");
it("replays a completed game from its recorded result", () => {
const bracket = bracketOf(
completeMatch(seededBracket(), "Opening Round", 1, "us-2", "us-1")
);
const play = makePlayGame(0, bracket, 1_000);
// Deterministic across repeats — no coin flip is involved any more.
for (let i = 0; i < 25; i++) {
const result = play("Opening Round", 1, us1, us2);
expect(result.winner.participantId).toBe("us-2");
expect(result.loser.participantId).toBe("us-1");
}
});
it("returns the recorded winner regardless of which slot it arrives in", () => {
const bracket = bracketOf(
completeMatch(seededBracket(), "Opening Round", 1, "us-1", "us-2")
);
const play = makePlayGame(0, bracket, 1_000);
// Same game, arguments swapped.
expect(play("Opening Round", 1, us2, us1).winner.participantId).toBe("us-1");
});
it("simulates a game that has not been played yet", () => {
const play = makePlayGame(0, bracketOf(seededBracket()), 1_000);
const winners = new Set(
Array.from({ length: 200 }, () => play("Opening Round", 1, us1, us2).winner.participantId)
);
// Equal Elo, so both outcomes must show up.
expect(winners).toEqual(new Set(["us-1", "us-2"]));
});
it("ignores a recorded result between teams that did not arrive at the game", () => {
const bracket = bracketOf(
completeMatch(seededBracket(), "Opening Round", 1, "us-5", "us-2")
);
const play = makePlayGame(0, bracket, 1_000);
// us-5 belongs to a different Opening Round game, so this row cannot apply to
// the us-1 v us-2 pairing — it must be simulated instead.
const winners = new Set(
Array.from({ length: 200 }, () => play("Opening Round", 1, us1, us2).winner.participantId)
);
expect(winners).toEqual(new Set(["us-1", "us-2"]));
});
it("reads U.S. and International games from their own match numbers", () => {
// The same side-local game number maps to different global matches per side:
// U.S. Opening Round 1 is match 1, International Opening Round 1 is match 5.
const bracket = bracketOf(
completeMatch(seededBracket(), "Opening Round", 5, "intl-2", "intl-1")
);
const intl1 = team("intl-1");
const intl2 = team("intl-2");
expect(makePlayGame(1, bracket, 1_000)("Opening Round", 1, intl1, intl2).winner.participantId)
.toBe("intl-2");
// The U.S. side's Opening Round 1 is untouched by that result.
const usWinners = new Set(
Array.from({ length: 200 }, () =>
makePlayGame(0, bracket, 1_000)("Opening Round", 1, us1, us2).winner.participantId
)
);
expect(usWinners).toEqual(new Set(["us-1", "us-2"]));
});
it("honors a completed World Championship", () => {
// The two crossover games are single shared matches, numbered 1.
const bracket = bracketOf(
completeMatch(seededBracket(), "World Championship", 1, "us-3", "intl-4")
);
const us3 = team("us-3");
const intl4 = team("intl-4");
const result = playCrossoverGame("World Championship", bracket, 1_000, us3, intl4);
expect(result.winner.participantId).toBe("us-3");
expect(result.loser.participantId).toBe("intl-4");
});
it("simulates the crossover game when different finalists arrive", () => {
const bracket = bracketOf(
completeMatch(seededBracket(), "World Championship", 1, "us-3", "intl-4")
);
const intl5 = team("intl-5");
const winners = new Set(
Array.from({ length: 200 }, () =>
playCrossoverGame("World Championship", bracket, 1_000, us5, intl5).winner.participantId
)
);
expect(winners).toEqual(new Set(["us-5", "intl-5"]));
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/mixed externalId formats/);
});
});
});

View file

@ -30,33 +30,6 @@ describe("simulator manifest", () => {
}
});
// updateProbabilitiesAfterResult sends a season down the re-run path or the ICM path purely
// on this flag, and getting it wrong is silent in both directions: set it on a simulator
// that re-plays decided games and eliminated teams come back to life; leave it off a
// bracket-aware one and ICM keeps reporting placement floors as worth less than the points
// already awarded. Pinning the set makes a new simulator an explicit decision rather than a
// default. To add one, confirm it reads playoff_matches AND honors isComplete/winnerId.
it("pins which simulators are bracket-aware", () => {
const bracketAware = SIMULATOR_TYPES.filter((t) => SIMULATOR_MANIFEST[t].bracketAware);
expect(bracketAware.toSorted()).toEqual(
[
"afl_bracket",
"college_hockey_bracket",
"cs2_major_qualifying_points",
"darts_bracket",
"llws_bracket",
"nba_bracket",
"ncaam_bracket",
"ncaaw_bracket",
"nhl_bracket",
"nll_bracket",
"snooker_bracket",
"ucl_bracket",
"world_cup",
].toSorted()
);
});
it("only derives inputs from declared optional inputs", () => {
for (const simulatorType of SIMULATOR_TYPES) {
const profile = SIMULATOR_MANIFEST[simulatorType];

View file

@ -7,8 +7,6 @@ import {
rawWinRateFromElo,
rdifWinProbability,
eloToRDif,
projectionForSeeding,
seedingWinRateFor,
sampleBinomial,
simBo3,
simBo5,
@ -283,8 +281,8 @@ describe("sampleBinomial", () => {
// ─── Series simulators ────────────────────────────────────────────────────────
const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0, projectedWins: null };
const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0, projectedWins: null };
const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0 };
const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0 };
const alwaysA = () => 1.0; // team A always wins each game
const alwaysB = () => 0.0; // team B always wins each game
const coinFlip = () => 0.5;
@ -348,159 +346,9 @@ describe("eloToRDif", () => {
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
});
it("lands on the same run-differential scale as the hardcoded TEAMS_DATA rdif", () => {
// 95 projected wins out of 162 → Elo ≈ 1561. On the TEAMS_DATA scale that is a
// ~+140 run differential, right alongside the Dodgers' hardcoded +137 — not the
// ~+686 the old RDIF_DIVISOR scaling produced.
const winRate = 95 / 162;
const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate);
expect(eloToRDif(elo)).toBeGreaterThan(120);
expect(eloToRDif(elo)).toBeLessThan(160);
});
it("is compressed by winRateFromRDif for playoff matchups, like a hardcoded rdif", () => {
// The whole point of RDIF_DIVISOR: playoff series are near coin-flips between
// playoff-calibre teams. An Elo-rated team must not skip that compression.
const winRate = 95 / 162;
const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate);
const playoffRate = winRateFromRDif(eloToRDif(elo));
expect(playoffRate).toBeCloseTo(0.517, 2);
// Strictly compressed relative to the team's raw season win rate.
expect(playoffRate).toBeLessThan(rawWinRateFromElo(elo));
});
it("agrees with the hardcoded rdif path for a team of equivalent strength", () => {
// Dodgers: hardcoded +137. An Elo carrying the same seeding win rate should
// produce a comparable playoff win rate rather than a wildly more dominant one.
const dodgers = getTeamData("Los Angeles Dodgers");
const eloEquivalent = 1500 + 400 * Math.log10(
rawWinRateFromRDif(dodgers?.rdif ?? 0) / (1 - rawWinRateFromRDif(dodgers?.rdif ?? 0))
);
expect(winRateFromRDif(eloToRDif(eloEquivalent))).toBeCloseTo(
winRateFromRDif(dodgers?.rdif ?? 0),
3
);
});
});
// ─── seedingWinRateFor ────────────────────────────────────────────────────────
describe("seedingWinRateFor", () => {
const eloRate = 95 / 162; // ≈ 0.5864 — the rate a 95-win projection implies
it("is a no-op pre-season: the target equals the Elo-implied rate", () => {
expect(seedingWinRateFor(eloRate, 95, 0, 162)).toBeCloseTo(eloRate, 6);
});
it("spreads the shortfall over the remaining games mid-season", () => {
// 60-50 and projected for 95: 35 wins needed in 52 games ≈ .673, well above the
// .586 the season-long Elo implies. Without this the sim finishes around 90.5.
expect(seedingWinRateFor(eloRate, 95, 60, 52)).toBeCloseTo(35 / 52, 6);
});
it("reaches the projection in expectation", () => {
const currentWins = 60;
const remaining = 52;
const rate = seedingWinRateFor(eloRate, 95, currentWins, remaining);
expect(currentWins + rate * remaining).toBeCloseTo(95, 6);
});
it("falls back to the Elo rate once a team has passed its projection", () => {
// Clamping to a floor instead would simulate a 96-40 team to go 0-26 for the
// rest of the season and drop out of the field. The projection is stale, so it
// is dropped rather than obeyed.
expect(seedingWinRateFor(eloRate, 95, 96, 26)).toBe(eloRate);
});
it("falls back to the Elo rate when a team has exactly met its projection", () => {
expect(seedingWinRateFor(eloRate, 95, 95, 26)).toBe(eloRate);
});
it("falls back to the Elo rate when the projection is unreachable", () => {
// 40-70 projected for 95 needs better than 1.000 — the mirror image of the
// case above, and dropped for the same reason.
expect(seedingWinRateFor(eloRate, 95, 40, 52)).toBe(eloRate);
});
it("falls back to the Elo rate when the target is exactly 1.000", () => {
expect(seedingWinRateFor(eloRate, 95, 43, 52)).toBe(eloRate);
});
it("keeps a target just inside the reachable range", () => {
expect(seedingWinRateFor(eloRate, 95, 94, 26)).toBeCloseTo(1 / 26, 6);
});
it("clamps a weight above 1 rather than extrapolating past the target", () => {
const target = 35 / 52;
expect(seedingWinRateFor(eloRate, 95, 60, 52, 3)).toBeCloseTo(target, 6);
expect(seedingWinRateFor(eloRate, 95, 60, 52, 3)).toBe(
seedingWinRateFor(eloRate, 95, 60, 52, 1)
);
});
it("never returns a rate outside (0, 1) for any weight", () => {
for (const weight of [0.25, 0.5, 0.75, 1, 5]) {
for (const [current, remaining] of [[0, 162], [60, 52], [94, 26], [10, 152]]) {
const rate = seedingWinRateFor(eloRate, 95, current, remaining, weight);
expect(rate).toBeGreaterThan(0);
expect(rate).toBeLessThan(1);
}
}
});
it("falls back to the Elo rate with no projection", () => {
expect(seedingWinRateFor(eloRate, null, 60, 52)).toBe(eloRate);
});
it("falls back to the Elo rate when the season is over", () => {
expect(seedingWinRateFor(eloRate, 95, 95, 0)).toBe(eloRate);
});
it("falls back to the Elo rate at weight 0", () => {
expect(seedingWinRateFor(eloRate, 95, 60, 52, 0)).toBe(eloRate);
});
it("blends target and Elo rate at an intermediate weight", () => {
const target = 35 / 52;
expect(seedingWinRateFor(eloRate, 95, 60, 52, 0.5)).toBeCloseTo(
0.5 * target + 0.5 * eloRate,
6
);
});
});
// ─── projectionForSeeding ─────────────────────────────────────────────────────
describe("projectionForSeeding", () => {
it("uses the projection when it alone produced the resolved Elo", () => {
expect(projectionForSeeding(95, { sourceEloMethod: "projectedWins" })).toBe(95);
});
it("ignores a projection that lost the baseEloPriority race", () => {
// The season resolved its Elo from a hand-entered value. Seeding off the
// projection anyway would ignore it as the Elo source while still letting it
// dictate the standings.
expect(projectionForSeeding(95, { sourceEloMethod: "direct" })).toBeNull();
});
it("ignores a projection that was blended with futures odds", () => {
// The blend lives in the Elo; seeding off the raw projection would discard it
// and run seeding and playoff matchups on different strength scales.
expect(projectionForSeeding(95, { sourceEloMethod: "blend" })).toBeNull();
expect(projectionForSeeding(95, { sourceEloMethod: "sourceOdds" })).toBeNull();
});
it("ignores a projection on a participant resolved by a fallback", () => {
expect(projectionForSeeding(95, { sourceEloMethod: "averageKnown" })).toBeNull();
});
it("ignores a projection with no method recorded", () => {
expect(projectionForSeeding(95, null)).toBeNull();
expect(projectionForSeeding(95, undefined)).toBeNull();
expect(projectionForSeeding(95, {})).toBeNull();
});
it("passes a null projection through", () => {
expect(projectionForSeeding(null, { sourceEloMethod: "projectedWins" })).toBeNull();
it("round-trips through winRateFromRDif: winRate(eloToRDif(elo)) ≈ eloWinProb(elo, 1500)", () => {
const elo = 1620;
const expectedWinRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
expect(winRateFromRDif(eloToRDif(elo))).toBeCloseTo(expectedWinRate, 4);
});
});

View file

@ -50,8 +50,6 @@ import {
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
import { recalculateStandings } from "~/models/scoring-calculator";
import { database } from "~/database/context";
import { getSimulator } from "~/services/simulations/registry";
import { normalizeSimulationResultColumns } from "~/services/simulations/simulation-probabilities";
@ -128,42 +126,6 @@ describe("runSportsSeasonSimulation", () => {
expect(vi.mocked(updateSportsSeason).mock.calls[1]).toEqual(["season-1", { simulationStatus: "idle" }]);
});
/** The default mock has no linked leagues, so nothing to recalculate. Give it one. */
function withLinkedLeague() {
vi.mocked(database).mockReturnValue({
query: {
seasonSports: { findMany: vi.fn().mockResolvedValue([{ seasonId: "fantasy-1" }]) },
seasons: { findFirst: vi.fn() },
},
} as never);
}
it("recalculates standings and writes the daily snapshot by default", async () => {
withLinkedLeague();
await runSportsSeasonSimulation("season-1");
expect(recalculateStandings).toHaveBeenCalledWith("fantasy-1");
expect(batchUpsertParticipantEvSnapshots).toHaveBeenCalled();
});
it("skips standings and snapshots when the caller owns them", async () => {
withLinkedLeague();
// updateProbabilitiesAfterResult runs inside the result path, where the caller
// recalculates standings straight afterwards. A recalculation here lands before
// recalculateAffectedLeagues takes its "before" snapshot, emptying the diff that gates the
// Discord standings post and rolling previousRank forward twice. EVs are still written.
await runSportsSeasonSimulation("season-1", {
skipStandingsRecalc: true,
skipSnapshots: true,
});
expect(recalculateStandings).not.toHaveBeenCalled();
expect(batchUpsertParticipantEvSnapshots).not.toHaveBeenCalled();
expect(batchUpsertParticipantEVs).toHaveBeenCalled();
});
it("throws when the sports season is not found", async () => {
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);

View file

@ -3,48 +3,29 @@
*
* Monte Carlo simulation of the AFL regular season and finals for 2026.
*
* Two modes:
* 1. Pre-bracket mode: no afl_10 bracket exists yet, or it carries no seeds. The ladder is
* re-projected from Elo every iteration and its top 10 are seeded 1-10, so the draw is
* modelled as still uncertain.
* 2. Bracket-aware mode: a seeded afl_10 bracket exists. Its slots are the seeding, fixed
* across every iteration, and games already played are replayed from their recorded
* result instead of being re-simulated.
*
* Bracket-aware mode is what makes a banked floor hold. afl_10 is the only template that
* awards points on seeding alone (entryFloor: seeds 1-4 bank 5th, seeds 5-6 bank 7th), and a
* simulator that re-draws the ladder every iteration puts those teams back in the Wildcard
* Round or out of the finals entirely where they score 0, pulling EV below points the
* league has already paid out. Reading the real draw removes that by construction: a team
* seeded into an Elimination Final is in that game in 100% of iterations, so its worst
* outcome is the 7th-8th tier.
*
* Algorithm:
* 1. Load all participants for the sports season from DB
* 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
* Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
* 3. Load current regular season standings (wins, gamesPlayed) if available
* 4. Load the afl_10 bracket, if one has been generated, for its draw and results so far
* 5. For each simulation:
* a. Pre-bracket mode only: for each team, simulate remaining regular season games
* (TOTAL_GAMES - gamesPlayed) using Elo win probability vs. an average opponent
* (Elo 1500) projectedPoints = currentWins*4 + simulatedRemainingWins*4
* b. Pre-bracket mode only: sort all 18 teams by projected points desc + random
* tiebreaker final ladder top 10 advance to the AFL Finals Series.
* In bracket-aware mode the bracket's own 10 seeds are used as-is.
* c. Simulate the AFL Finals Series (AFL_10 bracket), replaying any completed match:
* 4. For each simulation:
* a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed)
* using Elo win probability vs. an average opponent (Elo 1500)
* projectedPoints = currentWins*4 + simulatedRemainingWins*4
* b. Sort all 18 teams by projected points desc + random tiebreaker final ladder
* Top 10 advance to the AFL Finals Series
* c. Simulate AFL Finals Series (AFL_10 bracket):
*
* Wildcard Round: #7 vs #10, #8 vs #9 losers exit (0 pts)
* Qualifying Finals: #1 vs #4, #2 vs #3 winners Prelim Finals (bye)
* losers Semi-Finals (2nd chance)
* Elimination Finals: #5 vs lower WC winner, losers exit (7th/8th)
* #6 vs higher WC winner
* Semi-Finals: QF1L vs EF1w, QF2L vs EF2w losers exit (5th/6th)
* Elimination Finals: #5 vs WC2w, #6 vs WC1w losers exit (7th/8th)
* Semi-Finals: QF1L vs EF2w, QF2L vs EF1w losers exit (5th/6th)
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w losers exit (3rd/4th)
* Grand Final: PF1w vs PF2w winner 1st, loser 2nd
*
* 6. Track placement counts per scoring tier
* 7. Convert counts to probability distributions
* 5. Track placement counts per scoring tier
* 6. Convert counts to probability distributions
*
* Win probability (Elo, PARITY_FACTOR = 450):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 450))
@ -72,7 +53,7 @@
* probFifth/Sixth = Semi-Finals losers (2 per sim split evenly)
* probSeventh/Eighth = Elimination Finals losers (2 per sim split evenly)
* Wildcard losers all 0 (score 0 points, same as 9th/10th)
* Missed finals all 0 (in bracket-aware mode, every team outside the bracket)
* Missed finals all 0
*
* NOTE: AFL uses the AFL_10 bracket template which splits the 58 tier into two
* separate pairs (5/6 and 7/8). This is already handled by scoring-rules.ts
@ -81,7 +62,7 @@
*/
import { database } from "~/database/context";
import { and, desc, eq } from "drizzle-orm";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name";
@ -94,9 +75,6 @@ import { positiveConfigNumber } from "./config-access";
const DEFAULT_NUM_SIMULATIONS = 10_000;
/** The bracket template the AFL finals are scored against. */
const AFL_TEMPLATE_ID = "afl_10";
/**
* Elo parity factor for AFL single-game win probability.
* 450 reflects moderate variance lower than NHL (1000) to account for
@ -214,232 +192,6 @@ function simulateProjectedWins(entry: TeamEntry): number {
return entry.currentWins + extra;
}
/** The playoff_matches columns the simulator actually reads. */
export type BracketMatch = Pick<
typeof schema.playoffMatches.$inferSelect,
"round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete"
>;
interface LoadedBracket {
/** The 10 finalists in seed order — index 0 is the minor premier. */
seeds: TeamEntry[];
/** Every bracket match, keyed by `${round}#${matchNumber}`. */
matches: Map<string, BracketMatch>;
}
/**
* Plays one finals game. `round`/`matchNumber` identify it within the bracket so an
* already-played result can be looked up; `t1`/`t2` are the teams routed into it.
*/
type PlayGame = (
round: string,
matchNumber: number,
t1: TeamEntry,
t2: TeamEntry
) => { winner: TeamEntry; loser: TeamEntry };
function matchKey(round: string, matchNumber: number): string {
return `${round}#${matchNumber}`;
}
function simGame(t1: TeamEntry, t2: TeamEntry, parityFactor: number): { winner: TeamEntry; loser: TeamEntry } {
return Math.random() < eloWinProbability(t1.elo, t2.elo, parityFactor)
? { winner: t1, loser: t2 }
: { winner: t2, loser: t1 };
}
/**
* Where generateAFL10Bracket (models/playoff-match.ts) writes each seed.
*
* The two Elimination Final participant2 slots are deliberately absent: they are TBD by
* design until a Wildcard winner advances into them, so they are never a missing seed.
* That leaves exactly 10 named slots for the 10 finalists.
*/
const SEED_SLOTS: ReadonlyArray<{ round: string; matchNumber: number; slot: 1 | 2; seed: number }> = [
{ round: "Qualifying Finals", matchNumber: 1, slot: 1, seed: 1 },
{ round: "Qualifying Finals", matchNumber: 2, slot: 1, seed: 2 },
{ round: "Qualifying Finals", matchNumber: 2, slot: 2, seed: 3 },
{ round: "Qualifying Finals", matchNumber: 1, slot: 2, seed: 4 },
{ round: "Elimination Finals", matchNumber: 1, slot: 1, seed: 5 },
{ round: "Elimination Finals", matchNumber: 2, slot: 1, seed: 6 },
{ round: "Wildcard Round", matchNumber: 1, slot: 1, seed: 7 },
{ round: "Wildcard Round", matchNumber: 2, slot: 1, seed: 8 },
{ round: "Wildcard Round", matchNumber: 2, slot: 2, seed: 9 },
{ round: "Wildcard Round", matchNumber: 1, slot: 2, seed: 10 },
];
/**
* Read the seeded afl_10 bracket for this season, if there is one.
*
* Returns null only when the bracket carries no draw at all no matches, or a freshly
* generated bracket with every slot still empty in which case the caller falls back to
* projecting the ladder.
*
* A *partially* seeded bracket is an error rather than a fallback. Falling back there would
* throw away the real draw and every recorded result with it, putting eliminated teams back
* in contention; and it is reachable in practice, because playoff_matches.participant1Id /
* participant2Id are ON DELETE SET NULL, so removing and re-adding one participant
* mid-finals empties a slot. A duplicated or unknown participant fails loudly for the same
* reason.
*/
export function readAflBracketSeeds(
matches: BracketMatch[],
teamsById: Map<string, TeamEntry>
): LoadedBracket | null {
if (matches.length === 0) return null;
const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m]));
const drawn = SEED_SLOTS.map(({ round, matchNumber, slot }) => {
const match = byKey.get(matchKey(round, matchNumber));
if (!match) return null;
return (slot === 1 ? match.participant1Id : match.participant2Id) ?? null;
});
const seededCount = drawn.filter((id) => id !== null).length;
// Generated but not yet filled in — no draw to honor.
if (seededCount === 0) return null;
if (seededCount < drawn.length) {
const missing = SEED_SLOTS.filter((_, i) => drawn[i] === null)
.map((s) => s.seed)
.toSorted((a, b) => a - b)
.join(", ");
throw new Error(
`AFL bracket is only partially seeded (${seededCount} of ${drawn.length} slots filled; ` +
`missing seed(s) ${missing}). Re-seed the bracket in Admin → Bracket before simulating; ` +
`simulating around the gap would discard the draw and every recorded result.`
);
}
// Filled by seed number below; SEED_SLOTS covers seeds 1-10 exactly once each.
const seeds: TeamEntry[] = [];
const seen = new Set<string>();
for (let i = 0; i < SEED_SLOTS.length; i++) {
const participantId = drawn[i] as string;
if (seen.has(participantId)) {
throw new Error(`AFL bracket seeds participant ${participantId} into more than one slot.`);
}
seen.add(participantId);
const team = teamsById.get(participantId);
if (!team) {
throw new Error(
`AFL bracket references participant ${participantId}, which is not in this sports season.`
);
}
seeds[SEED_SLOTS[i].seed - 1] = team;
}
return { seeds, matches: byKey };
}
/**
* The recorded loser of a completed match. loserId is written by the scoring flow, but fall
* back to "whichever slot isn't the winner" for older rows.
*/
function completedLoser(match: BracketMatch): string | null {
if (match.loserId) return match.loserId;
if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id;
if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id;
return null;
}
/**
* Build the game-playing function for a bracket.
*
* When the bracket has a completed result for a game AND that result is between the two teams
* the simulation routed into it, the recorded winner is used verbatim that is what makes an
* already-played result stick across all iterations, and what stops a banked floor from being
* re-litigated at 50/50. Anything else is simulated. The pair check keeps a corrupt or
* out-of-order row from desynchronising the rest of the bracket.
*/
export function makePlayGame(bracket: LoadedBracket | null, parityFactor: number): PlayGame {
if (!bracket) {
return (_round, _matchNumber, t1, t2) => simGame(t1, t2, parityFactor);
}
return (round, matchNumber, t1, t2) => {
const match = bracket.matches.get(matchKey(round, matchNumber));
if (match?.isComplete && match.winnerId) {
const loserId = completedLoser(match);
const arrived = [t1.id, t2.id];
if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) {
return match.winnerId === t1.id ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
}
}
return simGame(t1, t2, parityFactor);
};
}
/**
* Simulate the AFL Finals Series from a seeded list of 10 teams.
*
* Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a
* recorded result is looked up against the game it was actually played in:
* SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner,
* PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner.
*
* Returns the placement for each team:
* "gf_winner" 1st
* "gf_loser" 2nd
* "pf_loser" 3rd/4th (two teams per sim)
* "sf_loser" 5th/6th (two teams per sim)
* "ef_loser" 7th/8th (two teams per sim)
* "wc_loser" 9th/10th (zero scoring points)
*/
export function simAFLFinals(
finalists: TeamEntry[],
play: PlayGame
): {
gfWinner: TeamEntry;
gfLoser: TeamEntry;
pfLosers: [TeamEntry, TeamEntry];
sfLosers: [TeamEntry, TeamEntry];
efLosers: [TeamEntry, TeamEntry];
} {
const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists;
// Wildcard Round: #7 vs #10, #8 vs #9
const wc1 = play("Wildcard Round", 1, s7, s10);
const wc2 = play("Wildcard Round", 2, s8, s9);
// Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get a bye to a PF)
const qf1 = play("Qualifying Finals", 1, s1, s4);
const qf2 = play("Qualifying Finals", 2, s2, s3);
// Elimination Finals: the Wildcard winners are re-seeded by ladder position, so #5
// hosts whichever finished lower and #6 the other — not a fixed crossover.
const wc1Seed = wc1.winner === s7 ? 7 : 10;
const wc2Seed = wc2.winner === s8 ? 8 : 9;
const [betterWc, worseWc] =
wc1Seed < wc2Seed ? [wc1.winner, wc2.winner] : [wc2.winner, wc1.winner];
const ef1 = play("Elimination Finals", 1, s5, worseWc);
const ef2 = play("Elimination Finals", 2, s6, betterWc);
// Semi-Finals: QF losers (second chance) vs EF winners. Elimination Final n feeds
// Semi-Final n — a fixed pathway; the crossover is a round later, at the Prelims.
const sf1 = play("Semi-Finals", 1, qf1.loser, ef1.winner);
const sf2 = play("Semi-Finals", 2, qf2.loser, ef2.winner);
// Preliminary Finals: QF winners vs SF winners
const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);
const pf2 = play("Preliminary Finals", 2, qf2.winner, sf1.winner);
// Grand Final
const gf = play("Grand Final", 1, pf1.winner, pf2.winner);
return {
gfWinner: gf.winner,
gfLoser: gf.loser,
pfLosers: [pf1.loser, pf2.loser],
sfLosers: [sf1.loser, sf2.loser],
efLosers: [ef1.loser, ef2.loser],
};
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class AFLSimulator implements Simulator {
@ -518,35 +270,12 @@ export class AFLSimulator implements Simulator {
};
});
const teamsById = new Map(teams.map((t) => [t.id, t]));
// 4. Load the real bracket (draw + results so far), if one has been generated.
// Events are filtered on bracketTemplateId rather than eventType and taken most
// recent first, matching getBracketTemplateIdsForSportsSeasons: a season can own
// several events, and landing on a stale or template-less row would silently
// discard the real draw and every recorded result. createdAt can tie when a bracket
// is generated alongside a sibling event, so id breaks the tie.
const playoffEvents = await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.bracketTemplateId, AFL_TEMPLATE_ID)
),
columns: { id: true },
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
});
const bracketEvent = playoffEvents[0];
const bracketMatches = bracketEvent
? await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
})
: [];
const bracket = readAflBracketSeeds(bracketMatches, teamsById);
const play = makePlayGame(bracket, parityFactor);
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
/** Simulate a single AFL game. Returns the winner. */
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b;
/**
* Project end-of-season ladder and return the top 10 finalists seeded 110.
*
@ -564,7 +293,70 @@ export class AFLSimulator implements Simulator {
return projected.slice(0, 10).map((x) => x.team);
};
// 5. Integer placement count maps — initialized to 0 for all participants.
/**
* Simulate the AFL Finals Series from a seeded list of 10 teams.
*
* Returns the placement for each team:
* "gf_winner" 1st
* "gf_loser" 2nd
* "pf_loser" 3rd/4th (two teams per sim)
* "sf_loser" 5th/6th (two teams per sim)
* "ef_loser" 7th/8th (two teams per sim)
* "wc_loser" 9th/10th (zero scoring points)
*/
const simAFLFinals = (
finalists: TeamEntry[]
): {
gfWinner: TeamEntry;
gfLoser: TeamEntry;
pfLosers: [TeamEntry, TeamEntry];
sfLosers: [TeamEntry, TeamEntry];
efLosers: [TeamEntry, TeamEntry];
} => {
const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists;
// Wildcard Round: #7 vs #10, #8 vs #9
const wc1Winner = simGame(s7, s10);
const wc2Winner = simGame(s8, s9);
// Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get bye to PF)
const qf1Winner = simGame(s1, s4);
const qf1Loser = qf1Winner === s1 ? s4 : s1;
const qf2Winner = simGame(s2, s3);
const qf2Loser = qf2Winner === s2 ? s3 : s2;
// Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner
const ef1Winner = simGame(s5, wc2Winner);
const ef1Loser = ef1Winner === s5 ? wc2Winner : s5;
const ef2Winner = simGame(s6, wc1Winner);
const ef2Loser = ef2Winner === s6 ? wc1Winner : s6;
// Semi-Finals: QF losers (2nd chance) vs EF winners
const sf1Winner = simGame(qf1Loser, ef2Winner);
const sf1Loser = sf1Winner === qf1Loser ? ef2Winner : qf1Loser;
const sf2Winner = simGame(qf2Loser, ef1Winner);
const sf2Loser = sf2Winner === qf2Loser ? ef1Winner : qf2Loser;
// Preliminary Finals: QF winners vs SF winners
const pf1Winner = simGame(qf1Winner, sf2Winner);
const pf1Loser = pf1Winner === qf1Winner ? sf2Winner : qf1Winner;
const pf2Winner = simGame(qf2Winner, sf1Winner);
const pf2Loser = pf2Winner === qf2Winner ? sf1Winner : qf2Winner;
// Grand Final
const gfWinner = simGame(pf1Winner, pf2Winner);
const gfLoser = gfWinner === pf1Winner ? pf2Winner : pf1Winner;
return {
gfWinner,
gfLoser,
pfLosers: [pf1Loser, pf2Loser ],
sfLosers: [sf1Loser, sf2Loser ],
efLosers: [ef1Loser, ef2Loser ],
};
};
// 3. Integer placement count maps — initialized to 0 for all participants.
//
// AFL scoring uses the AFL_10 bracket template which splits 58 into two
// separate pairs: Semi-Finals losers share 5th/6th (higher value), and
@ -576,12 +368,10 @@ export class AFLSimulator implements Simulator {
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 6. Monte Carlo simulation loop.
// 4. Monte Carlo simulation loop.
for (let s = 0; s < numSimulations; s++) {
// With a real bracket the draw is fixed and its played games are replayed from their
// recorded result; without one the ladder is re-projected every iteration.
const finalists = bracket ? bracket.seeds : buildFinalsList();
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists, play);
const finalists = buildFinalsList();
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists);
championCounts.set(gfWinner.id, (championCounts.get(gfWinner.id) ?? 0) + 1);
finalistCounts.set(gfLoser.id, (finalistCounts.get(gfLoser.id) ?? 0) + 1);
@ -598,7 +388,7 @@ export class AFLSimulator implements Simulator {
// Wildcard losers and non-finalists are not counted (0 points per scoring rules).
}
// 7. Convert integer counts to probability distributions.
// 5. Convert integer counts to probability distributions.
//
// Exact denominators guarantee column sums of 1.0 by construction:
// probFirst/Second → / NUM_SIMULATIONS (1 per sim)
@ -631,8 +421,8 @@ export class AFLSimulator implements Simulator {
};
});
// 8. Per-position normalization — belt-and-suspenders guard against floating-point
// division residuals. Columns are already near-exactly 1.0 after step 7.
// 6. Per-position normalization — belt-and-suspenders guard against floating-point
// division residuals. Columns are already near-exactly 1.0 after step 5.
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",

View file

@ -7,17 +7,16 @@
*
* Algorithm:
* 1. Load participants + current championship points from DB
* 2. Count completed/remaining races (see `countSeasonRaces`)
* 2. Count remaining races (incomplete non-schedule scoring events)
* 3. Convert sourceOdds vig-removed probability weights
* 4. Two paths:
* a. pre-season (no races run yet): pure weighted draws from odds
* b. otherwise: simulate each remaining race, starting from real standings,
* awarding series-specific points per finish. With zero races left this
* awards nothing and simply ranks the final standings.
* 4. Two simulation paths:
* a. remainingRaces === 0 (pre-season): pure weighted draws from odds
* b. remainingRaces > 0 (in-season): simulate each remaining race,
* starting from real standings, awarding series-specific points per finish
* 5. Convert finish counts probability distributions + normalize columns
*
* Notes:
* - Drivers without odds are priced at the longest price in the book
* - Drivers without odds fall back to uniform probability (1/N)
* - PARTICIPANT_VOLATILITY and RACE_NOISE only apply to the in-season path
*/
@ -26,8 +25,6 @@ import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
import { getSeasonResults } from "~/models/participant-season-result";
import { countSeasonRaces } from "~/models/season-races";
import { devigPower } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
@ -129,24 +126,20 @@ export class AutoRacingSimulator implements Simulator {
const currentPointsMap = new Map<string, number>(
seasonResults.map((r) => [r.participant.id, parseFloat(r.currentPoints ?? "0")])
);
const totalCurrentPoints = [...currentPointsMap.values()].reduce((a, b) => a + b, 0);
// 3. Count remaining and completed races
const { completed: completedRaces, remaining: remainingRaces, total: totalRaces } =
await countSeasonRaces(sportsSeasonId);
// A season with championship points but no calendar cannot be simulated
// forward — it silently degrades into "whatever the futures odds said",
// which ignores a runaway leader's points lead entirely.
if (totalRaces === 0 && totalCurrentPoints > 0) {
// eslint-disable-next-line no-console
console.warn(
`[AutoRacingSimulator] Season ${sportsSeasonId} has championship points but no race calendar — ` +
`add the schedule on the admin events page. Falling back to futures odds, which ignores the standings.`
);
// 3. Count remaining and completed races in a single pass (exclude schedule_event entries)
const allEvents = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
});
let remainingRaces = 0;
let completedRaces = 0;
for (const e of allEvents) {
if (e.eventType === "schedule_event") continue;
if (e.isComplete) completedRaces++;
else remainingRaces++;
}
// 0.0 = pre-season, 1.0 = all races done
const totalRaces = completedRaces + remainingRaces;
const seasonProgress = totalRaces > 0 ? completedRaces / totalRaces : 0;
// 4. Load EV data for championship win probabilities
@ -156,33 +149,22 @@ export class AutoRacingSimulator implements Simulator {
const ids = participants.map((p) => p.id);
// 5. Build raw implied championship win probabilities from odds.
// americanToImpliedProb includes vig (the field sums well over 1.0), so the
// field is devigged with a power transform rather than proportional division
// — see devigPower.
//
// Unpriced drivers are priced at the longest price in the book before the
// devig, not at 1/N: the market left them out because it did not rate them,
// and in a 27-car field 1/N (3.7%) rates them above most of the real
// longshots (+50000 is 0.2%). A book with a single price has no tail to
// anchor to, so that case keeps the 1/N fallback.
// americanToImpliedProb includes vig (sum > 1.0), so we normalize to sum = 1.0
// before using as weights. This is standard "vig removal" and ensures a driver
// with -200 odds (~66.7% implied) gets ~55% weight when the total vig is ~1.2.
const fallbackProb = 1 / participants.length;
const pricedImplied = new Map<string, number>();
const rawProbs = new Map<string, number>();
for (const p of participants) {
const odds = evMap.get(p.id)?.sourceOdds;
if (odds !== null && odds !== undefined) {
pricedImplied.set(p.id, americanToImpliedProb(odds));
}
const ev = evMap.get(p.id);
rawProbs.set(p.id, ev !== undefined && ev.sourceOdds !== null && ev.sourceOdds !== undefined ? americanToImpliedProb(ev.sourceOdds) : fallbackProb);
}
const unpricedImplied =
pricedImplied.size > 1 ? Math.min(...pricedImplied.values()) : fallbackProb;
const devigged = devigPower(
participants.map((p) => pricedImplied.get(p.id) ?? unpricedImplied)
);
const rawProbs = new Map<string, number>(
participants.map((p, i) => [p.id, devigged[i]])
);
// Normalize to remove vig
const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0);
for (const [id, prob] of rawProbs) {
rawProbs.set(id, prob / rawSum);
}
// 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0)
const baseProbs = new Map<string, number>();
@ -202,12 +184,9 @@ export class AutoRacingSimulator implements Simulator {
rankCounts.set(id, Array.from({ length: 8 }, () => 0));
}
// Pre-season only: no races run *and* none left, so there are no standings
// to build on and the odds are all there is. When races have already been
// run the in-season path below handles it — with zero races left it awards
// no points, so it just ranks the current standings, which is exactly the
// right answer for a finished season.
if (totalRaces === 0 || completedRaces === 0) {
if (remainingRaces === 0) {
// Pre-season: no races to simulate, derive placement probabilities
// from sourceOdds via pure weighted draws.
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
for (let sim = 0; sim < numSimulations; sim++) {
const finishOrder = weightedDrawWithoutReplacement(ids, weights);
@ -235,6 +214,7 @@ export class AutoRacingSimulator implements Simulator {
// - Mid/late season: standings dominate, reducing the distortion from
// championship futures (which penalize 2nd-place drivers whose odds of
// *winning* the title are weak, even though they'll likely finish top 3)
const totalCurrentPoints = [...currentPointsMap.values()].reduce((a, b) => a + b, 0);
const blendedProbs = new Map<string, number>();
for (const id of ids) {
const oddsW = baseProbs.get(id) ?? fallbackProb;

View file

@ -1,85 +1,65 @@
/**
* Little League World Series (LLWS) Bracket Simulator
*
* Monte Carlo simulation of the LLWS (20-team double-elimination format, 2025+).
*
* The tournament is two independent 10-team double-elimination brackets United
* States and International each producing a side champion, then a World
* Championship game and a Consolation game between the side runners-up. There is no
* pool play. This mirrors the llws_20 bracket template so simulated placements line
* up with the bracket admins actually score.
*
* Two modes:
* 1. Pre-bracket mode: no llws_20 bracket exists yet (or it has no participants
* seeded). Each side is shuffled into the 10 bracket slots every iteration, so
* the draw is modelled as random.
* 2. Bracket-aware mode: a seeded llws_20 bracket exists. Teams sit in their real
* slots and completed match results are honored rather than re-simulated, so a
* team that has already lost carries that loss into every iteration.
* Monte Carlo simulation of the LLWS (20-team format, 2022present).
*
* Algorithm:
* 1. Load all 20 participants for the sports season from DB
* 2. Load the llws_20 playoff bracket, if one exists, to get the real draw and
* whatever results have been recorded so far
* 3. Load championship futures odds from participantExpectedValues.sourceOdds
* (must be exactly 10 US + 10 International, identified by externalId)
* 2. Load championship futures odds from participantExpectedValues.sourceOdds
* (entered via Admin Futures Odds; American format)
* 4. Convert those futures to Elo via the shared probability engine, then drive
* each game with the Elo win probability (see "Why Elo" below)
* 3. Convert odds to normalized championship probabilities (vig removed).
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50.
* 4. Determine pool assignment mode from externalId:
* - Fixed pools: externalId is "US:A", "US:B", "Intl:A", or "Intl:B"
* use these exact pool assignments every simulation.
* - Randomized pools: externalId is "US" or "Intl" only
* randomly shuffle each side into Pool A / Pool B each simulation.
* 5. Per simulation:
* a. Place each side's 10 teams into the bracket slots (real draw when known,
* otherwise shuffled)
* b. Simulate the 10-team double-elimination bracket for each side, replaying
* completed games from their recorded result (see simulateSideBracket)
* c. Consolation game: US side loser vs Intl side loser 3rd / 4th
* d. World Championship: US champion vs Intl champion 1st / 2nd
* 6. Track placement counts across all simulations
* 7. Convert counts to probability distributions
* a. Assign pools (fixed or random)
* b. Simulate pool play round-robin within each pool (10 games/pool)
* Top 2 by W-L record advance. Ties broken randomly.
* c. Simulate 4-team double-elimination bracket per side:
* G1: A1 vs B2 (WB)
* G2: B1 vs A2 (WB)
* G3: G1W vs G2W (WB Final)
* G4: G1L vs G2L (LB R1 loser eliminated)
* G5: G3L vs G4W (LB Final loser eliminated)
* G6: G3W vs G5W (Side Championship loser eliminated)
* d. Consolation game: US loser vs Intl loser 3rd / 4th
* e. World Series: US champion vs Intl champion 1st / 2nd
* 6. Track placement counts across all simulations.
* 7. Convert counts to probability distributions.
*
* Why Elo rather than raw futures:
* A championship future already bakes in the ~6 wins needed to lift the trophy, so
* using it directly as a single-game strength (p1 / (p1 + p2)) makes every
* individual game as lopsided as the whole tournament and compounds the favorite's
* edge over and over. buildLLWSElos undoes that compression first (the empirically
* calibrated cube-root step in decompressProbability) before mapping to an Elo
* scale, and LLWS_PARITY_FACTOR then widens the Elo curve to reflect how much
* single-game variance there is in six-inning Little League baseball. Unlike the
* shared convertFuturesToElo helper, the mapping preserves how spread out the board
* actually is see buildLLWSElos for why that matters.
* Pool assignment (externalId format):
* "US:A" / "US:B" / "Intl:A" / "Intl:B" fixed pools (post-draw mode)
* "US" / "Intl" randomized pools (pre-draw mode)
* Mixed: if ANY US or Intl team has a pool suffix, ALL teams on that side must
* have one (throws otherwise). Sides can differ US fixed while Intl randomized.
*
* Side assignment (externalId): "US" or "Intl". The legacy pool suffixes
* ("US:A", "US:B", "Intl:A", "Intl:B") are still accepted and read as the side
* alone, so seasons configured for the old pool-play format keep working pools
* no longer exist, so the suffix has no effect. When a seeded bracket exists the
* bracket's own slots decide the sides and externalId is not consulted.
*
* Placement tiers SimulationProbabilities mapping (matches llws_20's scoring):
* probFirst = World Championship winner (1 per sim)
* probSecond = World Championship loser (1 per sim)
* probThird = Consolation winner (1 per sim)
* probFourth = Consolation loser (1 per sim)
* probFifth/probSixth = Elimination Final losers (2 per sim 1 per side)
* probSeventh/probEighth = Elimination Round 4 losers (2 per sim 1 per side)
* Everyone else all 0 (12 teams out in Elimination Rounds 13)
* Placement tiers SimulationProbabilities mapping:
* probFirst = World Series Champion (1 per sim)
* probSecond = World Series Runner-up (1 per sim)
* probThird = Consolation game winner / 3rd place (1 per sim)
* probFourth = Consolation game loser / 4th place (1 per sim)
* probFifthprobEighth = Double-elim bracket losers before side championships
* (4 per sim split evenly: 2 US + 2 Intl)
* Pool play losers all 0 (12 teams, did not advance from pool play)
*
* Admin setup:
* 1. Create a Sport with simulatorType = "llws_bracket"
* 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International)
* 3. Set externalId on each participant via Admin Manage Participants to "US" or
* "Intl" (optional names starting with "US " infer US, all others infer Intl).
* Once the bracket is generated and seeded this is no longer used.
* 3. Set externalId on each participant via Admin Manage Participants (optional if names follow the convention):
* Pre-draw: "US" or "Intl" (or leave null names starting with "US " infer US, all others infer Intl)
* Post-draw: "US:A", "US:B", "Intl:A", or "Intl:B"
* 4. Enter championship futures odds via Admin Futures Odds (sourceOdds)
* 5. Run simulation via Admin Simulate
*/
import { database } from "~/database/context";
import { and, eq } from "drizzle-orm";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import {
convertAmericanOddsToProbability,
decompressProbability,
eloWinProbabilityWithParity,
} from "~/services/probability-engine";
import { llwsMatchNumber } from "~/lib/bracket-templates";
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
@ -88,61 +68,19 @@ import { positiveConfigNumber } from "./config-access";
const NUM_SIMULATIONS = 50_000;
const US_TEAM_COUNT = 10;
const INTL_TEAM_COUNT = 10;
const DEFAULT_ELO = 1500;
const LLWS_TEMPLATE_ID = "llws_20";
/**
* Elo scaling for a single LLWS game.
*
* Higher than the 400-point standard because a six-inning Little League game between
* 12-year-olds is far closer to a coin flip than a pro game: one pitcher, one big
* inning, and the mercy rule all compress the gap.
*
* Calibrated by sweeping this value until a randomized-draw simulation reproduces the
* championship futures it was fed, across boards of different shape (see
* LLWS_ELO_SPREAD for why the shape matters). Total RMSE over a wide board, a
* top-heavy board, and a nearly flat one:
* parity 450 0.028
* parity 550 0.016 chosen
* parity 750 0.040
* parity 1000 0.061
* Overridable per season via the `parityFactor` simulator config.
*/
const LLWS_PARITY_FACTOR = 550;
/**
* Elo points per natural-log unit of relative team strength.
*
* Only the ratio LLWS_ELO_SPREAD / parityFactor affects the simulation, so this fixes
* the readable scale of the ratings and LLWS_PARITY_FACTOR does the calibrating. 300
* puts a typical 20-team board in the familiar ~13501700 range.
*/
const LLWS_ELO_SPREAD = 300;
/**
* Power transform undoing the compounding baked into a championship future.
* Matches DEFAULT_CALIBRATION.exponent in the probability engine.
*/
const LLWS_DECOMPRESSION_EXPONENT = 0.33;
const POOL_SIZE = 5; // teams per pool within each side
// ─── Types ────────────────────────────────────────────────────────────────────
type Side = "US" | "Intl";
/** Bracket-template side index: U.S. matches take the low match numbers. */
const SIDE_INDEX: Record<Side, 0 | 1> = { US: 0, Intl: 1 };
/** The playoff_matches columns the simulator actually reads. */
export type BracketMatch = Pick<
typeof schema.playoffMatches.$inferSelect,
"round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete"
>;
interface Team {
participantId: string;
side: Side;
/** Single-game strength on an Elo scale, decompressed from championship futures. */
elo: number;
/** Explicit pool ("A" or "B") if set in externalId; null if randomized. */
fixedPool: "A" | "B" | null;
/** Normalized championship win probability (01, vig removed). */
oddsProb: number;
}
interface PlacementCounts {
@ -150,46 +88,21 @@ interface PlacementCounts {
finalist: number;
thirdPlace: number;
fourthPlace: number;
/** Lost the Elimination Final — the 5th6th tier (1 per side per sim). */
elimFinalLoser: number;
/** Lost Elimination Round 4 — the 7th8th tier (1 per side per sim). */
elimRound4Loser: number;
}
/**
* Plays one bracket game. `round`/`localMatch` identify the game within its side so a
* completed result can be looked up; `t1`/`t2` are the teams the simulation has
* routed into it.
*/
type PlayGame = (
round: string,
localMatch: number,
t1: Team,
t2: Team
) => { winner: Team; loser: Team };
interface LoadedBracket {
/** Each side's 10 teams in bracket slot order (8 opening-round, then 2 byes). */
slots: Record<Side, Team[]>;
/** All bracket matches, keyed by `${round}#${globalMatchNumber}`. */
matches: Map<string, BracketMatch>;
bracketLoser: number;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function zeroCounts(): PlacementCounts {
return {
champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0,
elimFinalLoser: 0, elimRound4Loser: 0,
};
}
function matchKey(round: string, matchNumber: number): string {
return `${round}#${matchNumber}`;
}
function simGame(t1: Team, t2: Team, parityFactor: number): { winner: Team; loser: Team } {
const p1Win = eloWinProbabilityWithParity(t1.elo, t2.elo, parityFactor);
function simGame(t1: Team, t2: Team): { winner: Team; loser: Team } {
// If either team has no odds entered, treat the game as a coin flip.
// The 50/50 fallback must cover the one-sided case (one team known, one not)
// because oddsProb=0 would otherwise give the unknown team a 0% win rate.
let p1Win: number;
if (t1.oddsProb === 0 || t2.oddsProb === 0) {
p1Win = 0.5;
} else {
p1Win = t1.oddsProb / (t1.oddsProb + t2.oddsProb);
}
return Math.random() < p1Win ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
}
@ -205,163 +118,101 @@ function shuffle<T>(arr: T[]): T[] {
}
/**
* The recorded loser of a completed match. loserId is written by the scoring flow,
* but fall back to "whichever slot isn't the winner" for older rows.
* Assign teams to Pool A / Pool B for one side.
* In fixed mode, respects the pre-set pool. In randomized mode, shuffles then splits.
*/
function completedLoser(match: BracketMatch): string | null {
if (match.loserId) return match.loserId;
if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id;
if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id;
return null;
function assignPools(teams: Team[], randomized: boolean): [Team[], Team[]] {
if (!randomized) {
return [teams.filter((t) => t.fixedPool === "A"), teams.filter((t) => t.fixedPool === "B")];
}
const shuffled = shuffle([...teams]);
return [shuffled.slice(0, 5), shuffled.slice(5)];
}
/**
* 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.
* Simulate round-robin pool play among 5 teams.
* Returns the top 2 teams by win count (ties broken randomly).
*/
export function makePlayGame(
sideIndex: 0 | 1,
bracket: LoadedBracket | null,
parityFactor: number
): PlayGame {
if (!bracket) {
return (_round, _localMatch, t1, t2) => simGame(t1, t2, parityFactor);
function simulatePoolPlay(pool: Team[]): [Team, Team] {
const wins = new Map<string, number>(pool.map((t) => [t.participantId, 0]));
// Each pair plays once.
for (let i = 0; i < pool.length; i++) {
for (let j = i + 1; j < pool.length; j++) {
const { winner } = simGame(pool[i], pool[j]);
wins.set(winner.participantId, (wins.get(winner.participantId) ?? 0) + 1);
}
}
return (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);
};
// Sort by wins descending; pre-generate a stable random tiebreaker per team so
// the comparator is consistent (Math.random() inside a comparator is a bug — the
// engine may call it multiple times per pair and get contradictory results).
const tiebreaker = new Map(pool.map((t) => [t.participantId, Math.random()]));
const ranked = pool.toSorted((a, b) => {
const diff = (wins.get(b.participantId) ?? 0) - (wins.get(a.participantId) ?? 0);
return diff !== 0 ? diff : (tiebreaker.get(a.participantId) ?? 0) - (tiebreaker.get(b.participantId) ?? 0);
});
return [ranked[0], ranked[1]];
}
/**
* 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 a 4-team double-elimination bracket for one side.
*
* `slots` holds the side's teams in bracket order, matching the llws_20 participant
* layout: slots[0..7] are the four opening-round games (two teams each) and
* slots[8], slots[9] are the two bye teams entering Winners Round 2.
* Seeds (pool results):
* poolA1 = Pool A winner, poolA2 = Pool A runner-up
* poolB1 = Pool B winner, poolB2 = Pool B runner-up
*
* Structure (side-local, mirroring LLWS_ADVANCEMENT in lib/llws-bracket):
* Winners bracket
* OP1 s0 v s1 OP2 s2 v s3 OP3 s4 v s5 OP4 s6 v s7
* WR2-1 s8 v OP1w WR2-2 s9 v OP2w
* WSF1 OP3w v WR2-1w WSF2 WR2-2w v OP4w
* WF WSF1w v WSF2w winner to the side championship
* Elimination bracket (a loss here is final)
* ER1-1 OP2l v OP3l ER1-2 OP1l v OP4l
* ER2-1 WR2-1l v ER1-1w ER2-2 WR2-2l v ER1-2w
* ER3-1 WSF1l v ER2-2w ER3-2 WSF2l v ER2-1w (cross-over)
* ER4 ER3-2w v ER3-1w loser is the 7th8th tier
* EF WFl v ER4w loser is the 5th6th tier
* Side championship: WFw v EFw loser drops to the consolation game
* Bracket:
* G1 (WB): A1 vs B2
* G2 (WB): B1 vs A2
* G3 (WB Final): G1W vs G2W
* G4 (LB R1): G1L vs G2L loser eliminated (bracketLoser)
* G5 (LB Final): G3L vs G4W loser eliminated (bracketLoser)
* G6 (Side Championship): G3W vs G5W loser eliminated (sideLoser)
*
* Note the double-chance path: the Winners Final loser is NOT out, it drops to the
* Elimination Final. There is no "if necessary" game, so the side championship is
* decided in one game.
*
* The team order passed to `play` matches each match's participant1 / participant2
* slots in the generated bracket, so recorded results line up game for game.
*
* Returns { sideChampion, sideLoser }; the two scoring elimination losers are
* bumped into the counts directly.
* Returns: { sideChampion, sideLoser }
* bracketLosers (2) are bumped into counts directly.
*/
function simulateSideBracket(
slots: Team[],
bump: (id: string, key: keyof PlacementCounts) => void,
play: PlayGame
poolA1: Team,
poolA2: Team,
poolB1: Team,
poolB2: Team,
bump: (id: string, key: keyof PlacementCounts) => void
): { sideChampion: Team; sideLoser: Team } {
// ── Winners bracket ────────────────────────────────────────────────────────
const op1 = play("Opening Round", 1, slots[0], slots[1]);
const op2 = play("Opening Round", 2, slots[2], slots[3]);
const op3 = play("Opening Round", 3, slots[4], slots[5]);
const op4 = play("Opening Round", 4, slots[6], slots[7]);
const wr21 = play("Winners Round 2", 1, slots[8], op1.winner);
const wr22 = play("Winners Round 2", 2, slots[9], op2.winner);
// Winners bracket
const g1 = simGame(poolA1, poolB2);
const g2 = simGame(poolB1, poolA2);
const g3 = simGame(g1.winner, g2.winner); // WB Final
const wsf1 = play("Winners Semifinals", 1, op3.winner, wr21.winner);
const wsf2 = play("Winners Semifinals", 2, wr22.winner, op4.winner);
// Losers bracket
const g4 = simGame(g1.loser, g2.loser); // LB R1 — g4.loser eliminated
bump(g4.loser.participantId, "bracketLoser");
const wf = play("Winners Final", 1, wsf1.winner, wsf2.winner);
const g5 = simGame(g3.loser, g4.winner); // LB Final — g5.loser eliminated
bump(g5.loser.participantId, "bracketLoser");
// ── Elimination bracket ────────────────────────────────────────────────────
const er11 = play("Elimination Round 1", 1, op2.loser, op3.loser);
const er12 = play("Elimination Round 1", 2, op1.loser, op4.loser);
// Side championship
const g6 = simGame(g3.winner, g5.winner);
const er21 = play("Elimination Round 2", 1, wr21.loser, er11.winner);
const er22 = play("Elimination Round 2", 2, wr22.loser, er12.winner);
// Cross-over: each semifinal loser meets the winner from the opposite half.
const er31 = play("Elimination Round 3", 1, wsf1.loser, er22.winner);
const er32 = play("Elimination Round 3", 2, wsf2.loser, er21.winner);
const er4 = play("Elimination Round 4", 1, er32.winner, er31.winner);
bump(er4.loser.participantId, "elimRound4Loser"); // 7th8th tier
// The Winners Final loser gets its second chance here.
const ef = play("Elimination Final", 1, wf.loser, er4.winner);
bump(ef.loser.participantId, "elimFinalLoser"); // 5th6th tier
// ── Side championship ──────────────────────────────────────────────────────
const sideChampionship = play("Bracket Championship", 1, wf.winner, ef.winner);
return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser };
return { sideChampion: g6.winner, sideLoser: g6.loser };
}
// ─── Validation helpers ───────────────────────────────────────────────────────
/**
* Parse a participant's externalId into a side.
*
* The legacy pool-play suffixes ("US:A", "Intl:B", ) are still accepted so seasons
* configured before the format change keep loading; the pool part is ignored because
* the tournament no longer has pools.
*/
function parseExternalId(raw: string | null): { side: Side } | null {
type PoolSuffix = "A" | "B" | null;
function parseExternalId(raw: string | null): { side: Side; pool: PoolSuffix } | null {
if (!raw) return null;
const side = raw.toUpperCase().split(":")[0];
if (side === "US") return { side: "US" };
if (side === "INTL") return { side: "Intl" };
const upper = raw.toUpperCase();
if (upper === "US") return { side: "US", pool: null };
if (upper === "INTL") return { side: "Intl", pool: null };
if (upper === "US:A") return { side: "US", pool: "A" };
if (upper === "US:B") return { side: "US", pool: "B" };
if (upper === "INTL:A") return { side: "Intl", pool: "A" };
if (upper === "INTL:B") return { side: "Intl", pool: "B" };
return null;
}
@ -369,155 +220,41 @@ function parseExternalId(raw: string | null): { side: Side } | null {
* Infer an externalId from a participant name when none is stored.
* Teams whose name is exactly "US" or starts with "US " (case-insensitive)
* are assigned to the US side; all others are assigned to Intl.
* The inferred value never has a pool suffix, so pools will be randomized.
*/
function inferExternalIdFromName(name: string): string {
const upper = name.trim().toUpperCase();
return upper === "US" || upper.startsWith("US ") ? "US" : "Intl";
}
// ─── Elo construction ─────────────────────────────────────────────────────────
/**
* Map participants to single-game Elo ratings from their championship futures.
*
* Deliberately NOT convertFuturesToElo. That helper finishes by rescaling the field
* onto a fixed 12501750 span (mapToElo), which throws away how spread out the board
* actually is: a board whose favorite is priced at 22% and one whose favorite is
* priced at 6% both come out 500 Elo wide, so the tight board's field gets pulled
* apart into contenders and no-hopers that the market never implied. On such a board
* that inflated the favorite from 6% to 13%.
*
* Instead the decompressed strengths are mapped by their log-ratio to the field's
* geometric mean, which preserves dispersion: a tight board yields a narrow Elo span
* and a top-heavy one a wide span, both centred on DEFAULT_ELO.
*
* Returns the ratings alongside the rating to use for a team with no odds entered
* the median of the priced field, so leaving odds blank neither promotes nor buries a
* team. (DEFAULT_ELO is the centre of the scale, but futures fields are skewed, so on
* a typical board it would rank a team around 6th of 20.)
* Determine whether pool assignments should be randomized for one side.
* - If ALL teams on the side have a pool suffix fixed pools (returns false).
* - If NO teams have a pool suffix randomized (returns true).
* - Mixed throws.
* Also validates that fixed pools are split exactly POOL_SIZE / POOL_SIZE.
*/
export function buildLLWSElos(
evRows: Array<{ participantId: string; sourceOdds: number | null }>
): { elos: Map<string, number>; unpricedElo: number } {
const priced = evRows.filter((row) => row.sourceOdds !== null);
// A single priced team carries no information about the rest of the field, so
// there is nothing to normalise against — treat the season as unpriced.
if (priced.length < 2) return { elos: new Map(), unpricedElo: DEFAULT_ELO };
const rawProbs = priced.map((row) => convertAmericanOddsToProbability(row.sourceOdds ?? 0));
const rawSum = rawProbs.reduce((a, b) => a + b, 0);
if (rawSum <= 0) return { elos: new Map(), unpricedElo: DEFAULT_ELO };
// Vig-removed championship probability → single-game strength.
const logStrengths = rawProbs.map((prob) =>
Math.log(
Math.max(decompressProbability(prob / rawSum, LLWS_DECOMPRESSION_EXPONENT), Number.MIN_VALUE)
)
);
const meanLog = logStrengths.reduce((a, b) => a + b, 0) / logStrengths.length;
const elos = new Map<string, number>(
priced.map((row, i) => [
row.participantId,
DEFAULT_ELO + LLWS_ELO_SPREAD * (logStrengths[i] - meanLog),
])
);
return { elos, unpricedElo: median([...elos.values()]) };
}
function median(values: number[]): number {
if (values.length === 0) return DEFAULT_ELO;
const sorted = values.toSorted((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}
// ─── Bracket loading ──────────────────────────────────────────────────────────
/**
* Read the seeded llws_20 bracket for this season, if there is one.
*
* Returns null only when the bracket carries no draw at all no matches, or a
* freshly generated bracket with every slot still empty in which case the caller
* falls back to a randomized draw.
*
* A *partially* seeded bracket is an error rather than a fallback. Silently falling
* back there would throw away the real draw and every recorded result along with it,
* putting eliminated teams back in contention; and it is reachable in practice,
* because playoff_matches.participant1Id/participant2Id are ON DELETE SET NULL, so
* removing and re-adding a single participant mid-tournament empties a slot.
* Likewise, a bracket seeded with unknown or duplicated participants fails loudly.
*/
export function readBracketSlots(
matches: BracketMatch[],
teamsById: Map<string, Team>
): LoadedBracket | null {
if (matches.length === 0) return null;
const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m]));
// Collect both sides' draws before deciding, so "nothing seeded" is judged over the
// whole bracket rather than one side at a time.
const draw: Record<Side, (string | null)[]> = { US: [], Intl: [] };
for (const side of ["US", "Intl"] as const) {
const sideIndex = SIDE_INDEX[side];
for (let local = 1; local <= 4; local++) {
const match = byKey.get(
matchKey("Opening Round", llwsMatchNumber("Opening Round", sideIndex, local))
);
draw[side].push(match?.participant1Id ?? null, match?.participant2Id ?? null);
}
for (let local = 1; local <= 2; local++) {
const match = byKey.get(
matchKey("Winners Round 2", llwsMatchNumber("Winners Round 2", sideIndex, local))
);
draw[side].push(match?.participant1Id ?? null);
}
}
const allSlots = [...draw.US, ...draw.Intl];
const seededCount = allSlots.filter((id) => id !== null).length;
// Generated but not yet filled in — no draw to honor.
if (seededCount === 0) return null;
if (seededCount < allSlots.length) {
function determineRandomized(sideTeams: Team[], sideName: string): boolean {
const withPool = sideTeams.filter((t) => t.fixedPool !== null);
const withoutPool = sideTeams.filter((t) => t.fixedPool === null);
if (withPool.length > 0 && withoutPool.length > 0) {
throw new Error(
`LLWS bracket is only partially seeded (${seededCount} of ${allSlots.length} slots ` +
`filled). Re-seed the bracket in Admin → Bracket before simulating; simulating ` +
`around the gap would discard the draw and every recorded result.`
`${sideName} teams have mixed externalId formats: some have pool suffixes (e.g. "US:A") ` +
`and some don't. Either all ${sideName} teams must have pool suffixes or none should.`
);
}
const slots: Record<Side, Team[]> = { US: [], Intl: [] };
const seen = new Set<string>();
for (const side of ["US", "Intl"] as const) {
for (const id of draw[side]) {
const participantId = id as string;
if (seen.has(participantId)) {
if (withPool.length === sideTeams.length) {
const poolA = sideTeams.filter((t) => t.fixedPool === "A");
const poolB = sideTeams.filter((t) => t.fixedPool === "B");
if (poolA.length !== POOL_SIZE || poolB.length !== POOL_SIZE) {
throw new Error(
`LLWS bracket seeds participant ${participantId} into more than one slot.`
`${sideName} fixed pools must have exactly ${POOL_SIZE} teams each. ` +
`Found Pool A: ${poolA.length}, Pool B: ${poolB.length}.`
);
}
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.`
);
return false; // fixed pools
}
// The bracket is authoritative about which side a team is on.
slots[side].push({ ...team, side });
}
}
return { slots, matches: byKey };
return true; // randomized
}
// ─── Simulator ────────────────────────────────────────────────────────────────
@ -527,7 +264,6 @@ export class LLWSSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
const parityFactor = positiveConfigNumber(config, "parityFactor", LLWS_PARITY_FACTOR);
const db = database();
// 1. Load all participants.
@ -552,62 +288,42 @@ export class LLWSSimulator implements Simulator {
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
// 3. Decompress the futures into single-game Elo ratings.
const { elos, unpricedElo } = buildLLWSElos(evRows);
const rawOddsMap = new Map<string, number>();
for (const row of evRows) {
if (row.sourceOdds !== null) {
rawOddsMap.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
}
}
// 4. Parse externalId for each participant to determine which side they're on.
// A seeded bracket overrides this below, but the field still has to be a legal
// 10/10 split before we know whether a bracket exists.
// 3. Normalize odds (remove vig) to get championship probability per team.
const normalizedOddsMap = new Map<string, number>();
if (rawOddsMap.size > 0) {
const rawSum = [...rawOddsMap.values()].reduce((a, b) => a + b, 0);
for (const [id, prob] of rawOddsMap) {
normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0);
}
}
// 4. Parse externalId for each participant to determine side and fixed pool.
const teams: Team[] = [];
const unparseableSides: Array<{ id: string; externalId: string | null }> = [];
for (const p of participants) {
const raw = p.externalId ?? inferExternalIdFromName(p.name);
const parsed = parseExternalId(raw);
if (!parsed) unparseableSides.push({ id: p.id, externalId: p.externalId });
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) {
if (!parsed) {
throw new Error(
`Participant ${firstBad.id} has invalid externalId "${firstBad.externalId}". ` +
`Expected: "US" or "Intl".`
`Participant ${p.id} has invalid externalId "${p.externalId}". ` +
`Expected: "US", "Intl", "US:A", "US:B", "Intl:A", or "Intl:B".`
);
}
teams.push({
participantId: p.id,
side: parsed.side,
fixedPool: parsed.pool,
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
});
}
// Validate team counts per side.
const usTeams = teams.filter((t) => t.side === "US");
const intlTeams = teams.filter((t) => t.side === "Intl");
@ -617,59 +333,59 @@ export class LLWSSimulator implements Simulator {
if (intlTeams.length !== INTL_TEAM_COUNT) {
throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`);
}
}
const usPool = bracket ? bracket.slots.US : teams.filter((t) => t.side === "US");
const intlPool = bracket ? bracket.slots.Intl : teams.filter((t) => t.side === "Intl");
// Determine pool assignment mode for each side.
const usRandomized = determineRandomized(usTeams, "US");
const intlRandomized = determineRandomized(intlTeams, "International");
const playUS = makePlayGame(SIDE_INDEX.US, bracket, parityFactor);
const playIntl = makePlayGame(SIDE_INDEX.Intl, bracket, parityFactor);
// 6. Initialise placement count accumulators for all participants.
// 5. Initialise placement count accumulators for all participants.
const allIds = participants.map((p) => p.id);
const counts = new Map<string, PlacementCounts>(allIds.map((id) => [id, zeroCounts()]));
const counts = new Map<string, PlacementCounts>(
allIds.map((id) => [id, { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }])
);
const bump = (id: string, key: keyof PlacementCounts) => {
const entry = counts.get(id);
if (entry) entry[key]++;
};
// 7. Run Monte Carlo simulations.
// 6. Run Monte Carlo simulations.
for (let s = 0; s < numSimulations; s++) {
// With a real bracket the draw is fixed; without one it is modelled as random.
const usSlots = bracket ? usPool : shuffle([...usPool]);
const intlSlots = bracket ? intlPool : shuffle([...intlPool]);
// Assign pools for this simulation.
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
// Pool play: top 2 from each pool advance.
const [usA1, usA2] = simulatePoolPlay(usPoolA);
const [usB1, usB2] = simulatePoolPlay(usPoolB);
const [intlA1, intlA2] = simulatePoolPlay(intlPoolA);
const [intlB1, intlB2] = simulatePoolPlay(intlPoolB);
// Double-elimination bracket per side.
const { sideChampion: usChamp, sideLoser: usLose } =
simulateSideBracket(usSlots, bump, playUS);
simulateSideBracket(usA1, usA2, usB1, usB2, bump);
const { sideChampion: intlChamp, sideLoser: intlLose } =
simulateSideBracket(intlSlots, bump, playIntl);
simulateSideBracket(intlA1, intlA2, intlB1, intlB2, bump);
// Consolation game: 3rd / 4th place.
const consolation = playCrossoverGame(
"Consolation Third Place", bracket, parityFactor, usLose, intlLose
);
const consolation = simGame(usLose, intlLose);
bump(consolation.winner.participantId, "thirdPlace");
bump(consolation.loser.participantId, "fourthPlace");
// World Championship: 1st / 2nd place.
const ws = playCrossoverGame(
"World Championship", bracket, parityFactor, usChamp, intlChamp
);
// World Series: 1st / 2nd place.
const ws = simGame(usChamp, intlChamp);
bump(ws.winner.participantId, "champion");
bump(ws.loser.participantId, "finalist");
}
// 8. Convert counts to probability distributions.
// Each of the two 58 tiers takes exactly 2 teams per sim (one per side), and
// the teams within a tier are tied, so the tier probability is split across
// its two positions.
const tierDivisor = 2 * numSimulations;
// 7. Convert counts to probability distributions.
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
const bracketLosersPerSim = 4;
const bracketDivisor = bracketLosersPerSim * numSimulations;
const empty = zeroCounts();
const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 };
return allIds.map((id) => {
const c = counts.get(id) ?? empty;
const upperTier = c.elimFinalLoser / tierDivisor; // 5th6th
const lowerTier = c.elimRound4Loser / tierDivisor; // 7th8th
const c = counts.get(id) ?? zeroCounts;
const bracketProb = c.bracketLoser / bracketDivisor;
return {
participantId: id,
probabilities: {
@ -677,10 +393,10 @@ export class LLWSSimulator implements Simulator {
probSecond: c.finalist / numSimulations,
probThird: c.thirdPlace / numSimulations,
probFourth: c.fourthPlace / numSimulations,
probFifth: upperTier,
probSixth: upperTier,
probSeventh: lowerTier,
probEighth: lowerTier,
probFifth: bracketProb,
probSixth: bracketProb,
probSeventh: bracketProb,
probEighth: bracketProb,
},
source: "llws_monte_carlo",
};

View file

@ -34,25 +34,6 @@ export interface SimulatorManifestProfile {
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
setupSections: SimulatorSetupSection[];
minParticipantInputs?: number;
/**
* The simulator reads the season's generated bracket: it seeds from the real draw and
* replays completed matches from their recorded result, rather than re-drawing the field
* and re-playing decided games every iteration.
*
* updateProbabilitiesAfterResult reads this to decide whether a result should be absorbed
* by re-running the simulator or by the generic ICM recalculation. Re-running is both more
* accurate and the only option that respects a banked placement floor, but it is only safe
* here: re-running a bracket-blind simulator would re-draw the field and hand equity back
* to teams already knocked out.
*
* Both halves are required. A simulator that reads the draw but re-simulates games already
* played is NOT bracket-aware for this purpose it resurrects eliminated teams just the
* same. Check for an `isComplete`/`winnerId` replay before setting this on a new simulator.
*
* This is deliberately separate from `setupSections: ["bracket"]`, which only drives admin
* links and a readiness warning and does not track this accurately in either direction.
*/
bracketAware?: boolean;
}
const BASE_CONFIG = {
@ -90,7 +71,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "futuresOdds", "bracket"],
bracketAware: true,
},
ncaam_bracket: {
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
@ -98,7 +78,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
bracketAware: true,
},
ncaaw_bracket: {
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
@ -106,7 +85,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
bracketAware: true,
},
nba_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
@ -114,7 +92,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
},
nhl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
@ -122,7 +99,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
},
nfl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
@ -136,10 +112,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
requiredInputs: ["sourceElo"],
optionalInputs: ["projectedWins"],
derivableInputs: { sourceElo: ["projectedWins"] },
// The bracket is optional — before one exists the ladder is projected from Elo — but once
// it is drawn the simulator seeds from it and honors completed results.
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
setupSections: ["participants", "eloRatings", "regularStandings"],
},
epl_standings: {
defaultConfig: {
@ -162,7 +135,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
requiredInputs: ["sourceElo"],
optionalInputs: ["worldRanking", "seed"],
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
bracketAware: true,
},
tennis_qualifying_points: {
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
@ -171,7 +143,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "surfaceElo", "events"],
},
mlb_bracket: {
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, projectedWinsWeight: 1, inputPolicy: { oddsWeight: 0.3 } },
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
@ -190,21 +162,18 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
bracketAware: true,
},
darts_bracket: {
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
requiredInputs: ["sourceElo", "worldRanking"],
optionalInputs: ["seed"],
setupSections: ["participants", "eloRatings", "rankings"],
bracketAware: true,
},
cs2_major_qualifying_points: {
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
requiredInputs: ["sourceElo"],
optionalInputs: ["worldRanking", "metadata"],
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
bracketAware: true,
},
ncaa_football_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
@ -214,13 +183,10 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
},
llws_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 550, usTeamCount: 10, internationalTeamCount: 10 },
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10, poolSize: 5 },
requiredInputs: ["sourceOdds"],
optionalInputs: ["metadata"],
// The bracket is optional — without one the draw is randomized — but once it
// exists the simulator reads the real draw and honors completed results from it.
setupSections: ["participants", "futuresOdds", "bracket"],
bracketAware: true,
setupSections: ["participants", "futuresOdds"],
},
college_hockey_bracket: {
// College hockey blends odds into Elo internally (and also uses NPI rank,
@ -233,7 +199,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
bracketAware: true,
},
brackt: {
defaultConfig: { iterations: 20_000 },
@ -257,7 +222,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
},
mls_bracket: {
defaultConfig: {

View file

@ -8,9 +8,8 @@
* 1. Load all participants for the sports season from DB
* 2. Load current standings (wins, gamesPlayed) from regularSeasonStandings
* 3. Load sourceElo ratings from seasonParticipantExpectedValues
* 4. Load raw projected win totals from seasonParticipantSimulatorInputs
* 5. Match participant names to hardcoded team data (RDif + league/division)
* 6. For each simulation:
* 4. Match participant names to hardcoded team data (RDif + league/division)
* 5. For each simulation:
* a. For each league (AL/NL), simulate remaining regular season games for
* every team using Binomial sampling, giving final projected wins.
* b. Division winner = best record in each division (3 per league).
@ -22,8 +21,8 @@
* - Division Series (best-of-5): 1 vs lowest WC survivor, 2 vs other
* - League Championship Series (best-of-7)
* e. World Series (best-of-7): AL champ vs NL champ
* 7. Track placement counts per scoring tier
* 8. Convert counts to probability distributions
* 6. Track placement counts per scoring tier
* 7. Convert counts to probability distributions
*
* Win probability (log5 formula):
* Step 1 convert projected RDif to win rate for playoff matchups:
@ -33,24 +32,17 @@
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
*
* Regular season simulation (seeding):
* Each team's base per-game win rate is derived from sourceElo (if set) or
* Each team's raw per-game win rate is derived from sourceElo (if set) or
* from the hardcoded RDif using SEEDING_RDIF_SCALE 10 runs/win × 162 games.
* When the resolved Elo came from a projected win total and nothing else, that
* base rate is replaced by the rest-of-season rate that reaches the projection:
* target = (projectedWins currentWins) / remainingGames
* (see seedingWinRateFor; config `projectedWinsWeight` blends it back toward the
* base rate). Pre-season the two rates coincide, so this is a no-op then. A
* projection that lost the baseEloPriority race, or that was blended with futures
* odds, is left to the resolved Elo see projectionForSeeding.
* Remaining games = TOTAL_SEASON_GAMES gamesPlayed are drawn from a
* Binomial distribution. This makes playoff seeding respond to both current
* standings and user-entered projected wins.
*
* Input resolution:
* sourceElo is the single Elo produced by the shared input policy already a
* blend of any raw Elo / projections / futures odds, written by
* prepareSimulatorInputsForRun before the run. This simulator does not blend
* futures odds itself.
* Futures blending:
* If sourceOdds are stored in participantExpectedValues for this season,
* the per-game win probability for playoff series is blended:
* P(game) = RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb
* RDIF_WEIGHT = 0.7, ODDS_WEIGHT = 0.3.
*
* Placement tiers SimulationProbabilities mapping:
* probFirst = World Series champion (1 per sim)
@ -82,10 +74,9 @@ import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { configNumber, positiveConfigNumber } from "./config-access";
import { positiveConfigNumber } from "./config-access";
import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getParticipantSimulatorInputs } from "~/models/simulator";
// ─── Simulation parameters ────────────────────────────────────────────────────
@ -109,13 +100,6 @@ const RDIF_DIVISOR = 8000;
*/
const SEEDING_RDIF_SCALE = 1620;
/**
* Default weight given to a user-entered projected win total when deriving the
* rest-of-season win rate. 1 = the projection is authoritative; 0 = ignore it and
* use the Elo-implied rate. Overridable per season via config `projectedWinsWeight`.
*/
const DEFAULT_PROJECTED_WINS_WEIGHT = 1;
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
//
// rdif: Projected run differential from FanGraphs Depth Charts.
@ -222,93 +206,13 @@ export function rawWinRateFromElo(elo: number): number {
}
/**
* Convert an Elo rating to an equivalent projected run differential, on the same
* scale as the hardcoded TEAMS_DATA.rdif values.
*
* Uses the standard Elo win probability formula (parity factor 400, average Elo
* 1500) and inverts rawWinRateFromRDif: rdif = (winRate 0.5) × SEEDING_RDIF_SCALE.
*
* SEEDING_RDIF_SCALE not RDIF_DIVISOR is deliberate. Scaling by RDIF_DIVISOR
* would make this the exact algebraic inverse of winRateFromRDif, so a team with
* an Elo would skip the playoff-parity compression that every hardcoded-rdif team
* gets: a 95-win projection (Elo 1561) mapped to RDif +686 and played playoff
* games at .586 instead of the ~.517 documented on RDIF_DIVISOR. On this scale it
* maps to +140 right alongside the Dodgers' hardcoded +137 and
* winRateFromRDif then compresses it to .5175 like any other team.
*
* Convert an Elo rating to an equivalent projected run differential.
* Uses the standard Elo win probability formula (parity factor 400, average Elo 1500),
* then inverts the winRateFromRDif formula: rdif = (winRate 0.5) × RDIF_DIVISOR.
* Exported for unit testing.
*/
export function eloToRDif(elo: number): number {
return (rawWinRateFromElo(elo) - 0.5) * SEEDING_RDIF_SCALE;
}
/**
* The projected win total seeding should use, or null to leave seeding on the Elo.
*
* `prepareSimulatorInputsForRun` records which source won the base-Elo race in
* `metadata.sourceEloMethod`, and only `"projectedWins"` means the resolved Elo is
* the projection and nothing else. Every other method has to be left alone:
*
* - `"direct"` the season's `baseEloPriority` put a hand-entered Elo ahead of
* the projection. Honouring the projection here anyway would ignore it as the
* Elo source while still letting it dictate seeding.
* - `"blend"` / `"sourceOdds"` futures odds are folded into the Elo at
* `oddsWeight` (0.3 for MLB). Seeding off the raw projection would discard that
* blend and run seeding and playoff matchups on two different strength scales.
* - a fallback the participant had no usable input of its own.
*
* Exported for unit testing.
*/
export function projectionForSeeding(
projectedWins: number | null,
metadata: Record<string, unknown> | null | undefined
): number | null {
return metadata?.sourceEloMethod === "projectedWins" ? projectedWins : null;
}
/**
* Per-game win rate to use for a team's remaining regular-season games.
*
* A user-entered `projectedWins` is a projected *final* season win total, so the
* rate that reproduces it is spread over the games still to play:
*
* target = (projectedWins currentWins) / remainingGames
*
* Pre-season this is a no-op with currentWins 0 and remainingGames 162 the
* target equals projectedWins / 162, which is exactly the rate the Elo derived
* from that projection already encodes. Mid-season it is what makes the
* simulation actually land on the projection: a team at 60-50 projected for 95
* needs .673 over its last 52 games, not the .586 its season-long Elo implies.
*
* A target outside (0, 1) is proof the projection has gone stale rather than a
* reason to bet everything on it: a 96-40 team projected for 95 would need a
* negative rate, and a 40-70 team projected for 95 would need better than 1.000.
* Both fall back to the Elo rate clamping them instead would simulate a team to
* stop winning entirely, or to win out. NLL takes the same escape hatch
* (`nll-simulator.ts` clamps its prior at 0 and then uses the Elo rate outright).
*
* `weight` (config `projectedWinsWeight`, default 1) blends the target back toward
* the Elo-implied rate. At 1 the projection is authoritative wherever it is still
* reachable; lower values hedge it; 0 or less ignores it. Values above 1 are
* clamped this is a blend weight, like inputPolicy.oddsWeight, and above 1 it
* would extrapolate past the target rather than blending toward it.
*
* Exported for unit testing.
*/
export function seedingWinRateFor(
eloRate: number,
projectedWins: number | null,
currentWins: number,
remainingGames: number,
weight: number = DEFAULT_PROJECTED_WINS_WEIGHT
): number {
if (projectedWins === null || remainingGames <= 0 || weight <= 0) return eloRate;
const target = (projectedWins - currentWins) / remainingGames;
if (target <= 0 || target >= 1) return eloRate;
// Clamped here rather than at the call site so the blend cannot be turned into an
// extrapolation by a stray config value, whichever caller supplies it.
const blend = Math.min(1, weight);
return blend * target + (1 - blend) * eloRate;
return (rawWinRateFromElo(elo) - 0.5) * RDIF_DIVISOR;
}
/**
@ -366,8 +270,6 @@ interface TeamEntry {
originalSeed?: number;
currentWins: number; // from regularSeasonStandings (0 pre-season)
remainingGames: number; // TOTAL_SEASON_GAMES - gamesPlayed
/** User-entered projected *final* season win total, or null when not set. */
projectedWins: number | null;
}
/** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */
@ -541,10 +443,6 @@ function simLeagueBracket(
export class MLBSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
// configNumber (not positiveConfigNumber) so an explicit 0 — ignore projections,
// use the Elo-implied rate — is honored rather than falling back to the default.
// seedingWinRateFor clamps the upper end; the knob is free-form on the Engine card.
const projectedWinsWeight = configNumber(config, "projectedWinsWeight", DEFAULT_PROJECTED_WINS_WEIGHT);
const db = database();
// 1. Load all participants for this sports season.
@ -567,18 +465,6 @@ export class MLBSimulator implements Simulator {
const standings = await getRegularSeasonStandings(sportsSeasonId);
const standingsByParticipantId = new Map(standings.map((s) => [s.participantId, s]));
// 3. Load the raw projected win totals, keeping only those that actually
// produced the resolved Elo. The Elo encodes the projection as a season-long
// rate; the raw total is what lets seeding spread the *remaining* wins
// correctly once games have been played — see projectionForSeeding.
const simInputs = await getParticipantSimulatorInputs(sportsSeasonId);
const projectedWinsMap = new Map(
simInputs.map((input) => [
input.participantId,
projectionForSeeding(input.projectedWins, input.metadata),
])
);
const teams: TeamEntry[] = participantRows.map((r) => {
const standing = standingsByParticipantId.get(r.id);
const gamesPlayed = standing?.gamesPlayed ?? 0;
@ -588,7 +474,6 @@ export class MLBSimulator implements Simulator {
data: getTeamData(r.name),
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, TOTAL_SEASON_GAMES - gamesPlayed),
projectedWins: projectedWinsMap.get(r.id) ?? null,
};
});
@ -661,28 +546,11 @@ export class MLBSimulator implements Simulator {
/**
* Raw per-game win rate for regular-season seeding simulation.
*
* The base rate comes from sourceElo when available, else from the hardcoded
* rdif via SEEDING_RDIF_SCALE (Pythagorean approximation). A user-entered
* projected win total then re-expresses that as a rest-of-season target so the
* projection is actually reached mid-season see seedingWinRateFor.
*
* The result depends only on fixed per-team inputs, so it is resolved once here
* rather than on every one of the ~1.5M calls the seeding loop makes.
* Uses sourceElo-derived rate if available; falls back to hardcoded rdif
* with SEEDING_RDIF_SCALE (Pythagorean approximation).
*/
const seedingWinRateMap = new Map(
teams.map((team) => [
team.id,
seedingWinRateFor(
rawWinRateMap.get(team.id) ?? rawWinRateFromRDif(getEntryRDif(team)),
team.projectedWins,
team.currentWins,
team.remainingGames,
projectedWinsWeight
),
])
);
const seedingWinRate = (entry: TeamEntry): number => seedingWinRateMap.get(entry.id) ?? 0.5;
const seedingWinRate = (entry: TeamEntry): number =>
rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry));
/**
* Per-game win probability for team A over team B in a playoff series, from

View file

@ -1,23 +0,0 @@
/**
* Championship points tables for auto racing series.
*
* Stable series rules, not refreshable season data, so they live in code (see
* the hardcoding rules in docs/agents/simulators.md). Kept out of `registry.ts`
* so tests and callers can read a table without pulling in every simulator
* importing the registry first also trips the manifest/registry import cycle.
*
* Each table must be contiguous from P1 and monotonically decreasing: the
* simulator's award loop stops at the first unscored position.
*/
/** F1 points: positions 110. */
export const F1_RACE_POINTS: Record<number, number> = {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1,
};
/** IndyCar standard race points: positions 126. */
export const INDYCAR_RACE_POINTS: Record<number, number> = {
1: 50, 2: 40, 3: 35, 4: 32, 5: 30, 6: 28, 7: 26, 8: 24, 9: 22, 10: 20,
11: 19, 12: 18, 13: 17, 14: 16, 15: 15, 16: 14, 17: 13, 18: 12, 19: 11, 20: 10,
21: 9, 22: 8, 23: 7, 24: 6, 25: 5, 26: 5,
};

View file

@ -9,7 +9,6 @@
import type { Simulator } from "./types";
import { BracketSimulator } from "./bracket-simulator";
import { AutoRacingSimulator } from "./auto-racing-simulator";
import { F1_RACE_POINTS, INDYCAR_RACE_POINTS } from "./race-points";
import { GolfSimulator } from "./golf-simulator";
import { UCLSimulator } from "./ucl-simulator";
import { NCAAMSimulator } from "./ncaam-simulator";
@ -63,6 +62,20 @@ export const SIMULATOR_TYPES = [
export type SimulatorType = typeof SIMULATOR_TYPES[number];
// ─── Race points tables ───────────────────────────────────────────────────────
/** F1 points: positions 110. */
const F1_RACE_POINTS: Record<number, number> = {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1,
};
/** IndyCar standard race points: positions 126. */
const INDYCAR_RACE_POINTS: Record<number, number> = {
1: 50, 2: 40, 3: 35, 4: 32, 5: 30, 6: 28, 7: 26, 8: 24, 9: 22, 10: 20,
11: 19, 12: 18, 13: 17, 14: 16, 15: 15, 16: 14, 17: 13, 18: 12, 19: 11, 20: 10,
21: 9, 22: 8, 23: 7, 24: 6, 25: 5, 26: 5,
};
export interface SimulatorInfo {
name: string;
description: string;
@ -155,7 +168,7 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
llws_bracket: {
info: {
name: "LLWS Bracket Monte Carlo",
description: "Simulates the 20-team Little League World Series: a 10-team double-elimination bracket per side (US & International), each producing a side champion, then the consolation game (3rd/4th) and the World Championship (1st/2nd). Championship futures odds are decompressed to single-game Elo. When an llws_20 bracket exists it simulates the real draw and honors completed results; otherwise the draw is randomized and externalId ('US' or 'Intl') sets the sides.",
description: "Simulates the 20-team Little League World Series: pool play round-robin (5 teams/pool, top 2 advance) → 4-team double-elimination bracket per side (US & International) → consolation game (3rd/4th) → World Series. Uses championship futures odds for all win probabilities. Set externalId to 'US'/'Intl' (randomized pools) or 'US:A'/'US:B'/'Intl:A'/'Intl:B' (fixed pools).",
},
create: () => new LLWSSimulator(),
},

View file

@ -62,29 +62,6 @@ async function getPersistenceContext(
};
}
/**
* Side effects a caller can opt out of.
*
* A simulation run does three jobs recompute probabilities, recalculate standings, and record
* the day's EV snapshot. `updateProbabilitiesAfterResult` wants only the first: it runs inside
* the result path, where the caller recalculates standings itself immediately afterwards.
*
* Letting the run recalculate there is not merely redundant, it is wrong.
* recalculateAffectedLeagues detects change by snapshotting teamStandings, recalculating, then
* diffing, and that diff gates the Discord standings post; a recalculation slipped in
* beforehand makes the diff empty and silently suppresses the notification. recalculateStandings
* also rolls previousRank forward on every call, so an extra one erases rank movement.
*/
export interface RunSportsSeasonSimulationOptions {
/** Leave standings to the caller. */
skipStandingsRecalc?: boolean;
/**
* Skip the daily EV snapshot. The snapshot is a per-day series keyed by snapshotDate, so
* writing it on every match result just overwrites the day's row with intra-day values.
*/
skipSnapshots?: boolean;
}
export interface RunSportsSeasonSimulationResult {
sportsSeasonId: string;
simulatorType: SimulatorType;
@ -94,8 +71,7 @@ export interface RunSportsSeasonSimulationResult {
}
export async function runSportsSeasonSimulation(
sportsSeasonId: string,
options: RunSportsSeasonSimulationOptions = {}
sportsSeasonId: string
): Promise<RunSportsSeasonSimulationResult> {
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
@ -159,15 +135,12 @@ export async function runSportsSeasonSimulation(
})),
]);
if (!options.skipStandingsRecalc) {
const seasonSports = await database().query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
});
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
}
const snapshotDate = new Date().toISOString().slice(0, 10);
if (!options.skipSnapshots) {
await batchUpsertParticipantEvSnapshots(
results.map((r) => ({
participantId: r.participantId,
@ -185,7 +158,6 @@ export async function runSportsSeasonSimulation(
source: r.source,
}))
);
}
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });

View file

@ -1,7 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { describe, it, expect } from "vitest";
import { findMatchingTeamName } from "~/lib/normalize-team-name";
import { OcBlacktopIndyCarStandingsAdapter } from "../f1";
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "../types";
// Pure unit tests for the name-matching logic used by syncStandings.
// The full orchestrator requires DB context; those are covered by integration tests.
@ -48,143 +46,3 @@ describe("findMatchingTeamName (sync name-matching logic)", () => {
expect(findMatchingTeamName(" Boston Celtics ", participants)).toBe("Boston Celtics");
});
});
describe("OcBlacktopIndyCarStandingsAdapter", () => {
afterEach(() => {
vi.restoreAllMocks();
});
// A fallback that records whether it was invoked and returns a sentinel result.
function makeFallback(): StandingsSyncAdapter & { called: boolean } {
const stub = {
called: false,
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
stub.called = true;
return [
{
teamName: "ESPN Fallback Driver",
externalTeamId: "espn-1",
wins: 0,
losses: 0,
winPct: 0,
gamesPlayed: 0,
leagueRank: 1,
currentPoints: 1,
},
];
},
};
return stub;
}
it("maps OC Blacktop driver standings to FetchedStandingsRecord", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify([
{ id: "ocb-palou", position: 1, points: "409.00", firstName: "Alex", lastName: "Palou" },
{ id: "ocb-kirkwood", position: 2, points: "348.00", firstName: "Kyle", lastName: "Kirkwood" },
]),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
const fallback = makeFallback();
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
const records = await adapter.fetchStandings();
expect(fallback.called).toBe(false);
expect(records).toEqual([
expect.objectContaining({
teamName: "Alex Palou",
externalTeamId: "ocb-palou",
leagueRank: 1,
currentPoints: 409,
}),
expect.objectContaining({
teamName: "Kyle Kirkwood",
externalTeamId: "ocb-kirkwood",
leagueRank: 2,
currentPoints: 348,
}),
]);
});
it("falls back to ESPN when the API responds non-OK", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("nope", { status: 500, statusText: "Internal Server Error" })
);
const fallback = makeFallback();
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
const records = await adapter.fetchStandings();
expect(fallback.called).toBe(true);
expect(records[0].teamName).toBe("ESPN Fallback Driver");
});
it("falls back to ESPN when the payload is empty", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } })
);
const fallback = makeFallback();
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
await adapter.fetchStandings();
expect(fallback.called).toBe(true);
});
it("falls back to ESPN when the payload is missing expected fields (schema drift)", async () => {
// Non-empty array, but `points` was renamed upstream — must fail over, not
// silently sync zeros over real standings.
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify([
{ id: "ocb-palou", position: 1, championshipPoints: "409.00", firstName: "Alex", lastName: "Palou" },
]),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
const fallback = makeFallback();
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
const records = await adapter.fetchStandings();
expect(fallback.called).toBe(true);
expect(records[0].teamName).toBe("ESPN Fallback Driver");
});
it("keeps legitimate zero-point drivers (0 points is valid, not a failure)", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify([
{ id: "ocb-1", position: 1, points: "409.00", firstName: "Alex", lastName: "Palou" },
{ id: "ocb-2", position: 33, points: "0.00", firstName: "Rookie", lastName: "Driver" },
]),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
const fallback = makeFallback();
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
const records = await adapter.fetchStandings();
expect(fallback.called).toBe(false);
expect(records[1]).toEqual(
expect.objectContaining({ teamName: "Rookie Driver", leagueRank: 33, currentPoints: 0 })
);
});
it("falls back to ESPN when no API key is configured", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const fallback = makeFallback();
// Empty string is falsy but (unlike undefined) does not trigger the env default.
const adapter = new OcBlacktopIndyCarStandingsAdapter("", fallback);
const records = await adapter.fetchStandings();
expect(fetchSpy).not.toHaveBeenCalled();
expect(fallback.called).toBe(true);
expect(records[0].teamName).toBe("ESPN Fallback Driver");
});
});

View file

@ -1,6 +1,5 @@
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
import { statsMap, type EspnStat } from "./espn";
import { logger } from "~/lib/logger";
// Jolpica is the maintained successor to the deprecated Ergast API — same JSON shape, no key needed.
const JOLPICA_DRIVER_STANDINGS_URL = "https://api.jolpi.ca/ergast/f1/current/driverStandings.json";
@ -9,11 +8,6 @@ const JOLPICA_DRIVER_STANDINGS_URL = "https://api.jolpi.ca/ergast/f1/current/dri
const ESPN_INDYCAR_STANDINGS_URL =
"https://site.api.espn.com/apis/v2/sports/racing/irl/standings";
// OC Blacktop IndyCar driver standings — updates within hours of a race finishing,
// unlike ESPN's aggregate which can lag most of a day. Requires an API key.
const OCBLACKTOP_INDYCAR_STANDINGS_URL =
"https://api.ocblacktop.com/v1/indycar/standings/drivers";
interface JolpicaDriverStanding {
position: string;
points: string;
@ -143,92 +137,3 @@ export class IndyCarStandingsAdapter implements StandingsSyncAdapter {
});
}
}
// OC Blacktop returns a flat array of driver standings. Fields are typed as
// optional because this is untrusted external JSON — fetchFromOcBlacktop validates
// them at runtime before use.
interface OcBlacktopDriverStanding {
id?: string;
position?: number;
points?: string; // e.g. "409.00"
firstName?: string;
lastName?: string;
}
/**
* IndyCar standings from OC Blacktop, which posts updated championship points within
* hours of a race far fresher than ESPN's aggregate, which can stay stale most of a
* day. Falls back to ESPN on any failure (missing key, network error, empty response)
* so a flaky third party never breaks the sync.
*/
export class OcBlacktopIndyCarStandingsAdapter implements StandingsSyncAdapter {
constructor(
private readonly apiKey = process.env.OCBLACKTOP_API_KEY,
private readonly fallback: StandingsSyncAdapter = new IndyCarStandingsAdapter()
) {}
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
try {
return await this.fetchFromOcBlacktop();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(
`OC Blacktop IndyCar standings unavailable (${message}); falling back to ESPN.`
);
return this.fallback.fetchStandings();
}
}
private async fetchFromOcBlacktop(): Promise<FetchedStandingsRecord[]> {
if (!this.apiKey) {
throw new Error(
"OCBLACKTOP_API_KEY is required to sync IndyCar standings from OC Blacktop."
);
}
const res = await fetch(OCBLACKTOP_INDYCAR_STANDINGS_URL, {
headers: { "x-api-key": this.apiKey },
});
if (!res.ok) {
throw new Error(
`OC Blacktop IndyCar standings API returned ${res.status}: ${res.statusText}`
);
}
const drivers = (await res.json()) as OcBlacktopDriverStanding[];
if (!Array.isArray(drivers) || drivers.length === 0) {
throw new Error(
"OC Blacktop IndyCar standings API returned no entries — season may not have started or response shape changed"
);
}
return drivers.map((d): FetchedStandingsRecord => {
// Validate the fields we depend on rather than coercing missing data into
// plausible-looking values. A field going missing/renamed upstream means the
// response shape drifted — throwing routes us to the ESPN fallback instead of
// silently overwriting real standings with zeros. (A 0-point backmarker is
// legitimate; an *unparseable* points value is not.)
const points = Number.parseFloat(d.points ?? "");
const position = Number(d.position);
const name = `${d.firstName ?? ""} ${d.lastName ?? ""}`.trim();
if (!d.id || !name || !Number.isFinite(position) || !Number.isFinite(points)) {
throw new Error(
`OC Blacktop IndyCar standings entry is missing expected fields (${JSON.stringify(d)}) — response shape may have changed`
);
}
return {
teamName: name,
externalTeamId: d.id,
leagueRank: position,
wins: 0,
losses: 0,
gamesPlayed: 0,
winPct: 0,
currentPoints: points,
};
});
}
}

View file

@ -13,7 +13,7 @@ import { MlbStandingsAdapter } from "./mlb";
import { WnbaStandingsAdapter } from "./wnba";
import { EplStandingsAdapter } from "./epl";
import { MlsStandingsAdapter } from "./mls";
import { F1StandingsAdapter, OcBlacktopIndyCarStandingsAdapter } from "./f1";
import { F1StandingsAdapter, IndyCarStandingsAdapter } from "./f1";
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
function getAdapter(simulatorType: string): StandingsSyncAdapter {
@ -35,7 +35,7 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
case "f1_standings":
return new F1StandingsAdapter();
case "indycar_standings":
return new OcBlacktopIndyCarStandingsAdapter();
return new IndyCarStandingsAdapter();
default:
throw new Error(
`No standings sync adapter available for simulator type "${simulatorType}". ` +

View file

@ -5,13 +5,10 @@ import {
processQualifyingEvent,
recalculateAffectedLeagues,
buildTieCountByPlacement,
deriveBracketQualifyingStates,
getRoundConfig,
} from "~/models/scoring-calculator";
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
import { getEventResults } from "~/models/event-result";
import { upsertTournamentResult } from "~/models/tournament-result";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import { logger } from "~/lib/logger";
export interface SyncReport {
@ -46,30 +43,6 @@ export interface SyncOptions {
* (e.g. a mis-split 2 correct 1.5), which would otherwise re-ping every league.
*/
skipNotifications?: boolean;
/**
* Pre-computed tie span (placement tieCount) to split QP across each tied
* group, overriding the row-count derived from canonical tournament_results.
*
* For bracket majors (tennis, CS2) the tie span is a STRUCTURAL property of the
* round R16 spans 8 slots (9th16th), QF 4, SF 2, Final 1 not the live count
* of players currently sitting at a placement. Mid-tournament, players "floored"
* at a tier make the canonical row-count diverge from the structural span, so a
* mirror window would split differently than the primary (e.g. R16 losers at
* 2 QP instead of 1.5). syncMajorFromPrimaryEvent derives this map from the
* primary bracket so every window splits identically to the primary at every
* stage. Omitted for golf-style majors (no bracket) canonical row-count is used.
*/
tieCountByPlacement?: Map<number, number>;
/**
* Canonical participant ids (participants.id) knocked out this sync in a
* non-scoring round on the primary bracket. Threaded through so each mirror
* window can translate them to its own season_participant ids and announce the
* "Knocked Out" section these players earn no QP and so are otherwise invisible
* to the mirror (a null-placement filler row indistinguishable from "not yet
* played"). Canonical ids because they cross window boundaries; each window holds
* a different season_participant row for the same canonical participant.
*/
newlyEliminatedCanonicalParticipantIds?: Set<string>;
}
/**
@ -92,13 +65,7 @@ export async function syncTournamentResults(
options: SyncOptions = {}
): Promise<SyncReport> {
const db = database();
const {
markComplete = true,
skipEventId,
skipNotifications = false,
tieCountByPlacement,
newlyEliminatedCanonicalParticipantIds,
} = options;
const { markComplete = true, skipEventId, skipNotifications = false } = options;
const report: SyncReport = {
tournamentId,
@ -113,16 +80,10 @@ export async function syncTournamentResults(
.from(schema.tournamentResults)
.where(eq(schema.tournamentResults.tournamentId, tournamentId));
// The tie span (placement → count) used to split QP across a tied group. Default
// is the count of canonical rows at each placement (a whole-tournament property,
// computed once here). For bracket majors the caller also passes the STRUCTURAL
// span from the primary bracket (R16 = 8, QF = 4, …); merge it OVER the counts so
// bracket placements split like the primary even mid-tournament while any
// non-bracket placements (e.g. CS2 Swiss exits, golf) keep their canonical count.
// The full-field tie span (placement → count) is a property of the whole
// tournament, so compute it once here and hand it to every window's
// processQualifyingEvent instead of re-querying canonical results per window.
const canonicalTieCountByPlacement = buildTieCountByPlacement(canonicalResults);
const effectiveTieCountByPlacement = tieCountByPlacement
? new Map([...canonicalTieCountByPlacement, ...tieCountByPlacement])
: canonicalTieCountByPlacement;
// 2. Load every scoring_event that points at this tournament.
const allLinkedEvents = await db
@ -227,28 +188,13 @@ export async function syncTournamentResults(
}
}
// 3d. Translate the primary's newly-eliminated canonical participants into
// THIS window's season_participant ids (same participantId → sp.id key the
// canonical result copy uses above), so processQualifyingEvent can announce
// the "Knocked Out" section for players drafted in this window's leagues.
let windowEliminatedSpIds: Set<string> | undefined;
if (newlyEliminatedCanonicalParticipantIds?.size) {
windowEliminatedSpIds = new Set<string>();
for (const sp of rosters) {
if (sp.participantId && newlyEliminatedCanonicalParticipantIds.has(sp.participantId)) {
windowEliminatedSpIds.add(sp.id);
}
}
}
// 3e. Delegate to scoring engine inside the same transaction.
// 3d. Delegate to scoring engine inside the same transaction.
await processQualifyingEvent(ev.id, tx, {
skipNotifications,
canonicalTieCountByPlacement: effectiveTieCountByPlacement,
newlyEliminatedParticipantIds: windowEliminatedSpIds,
canonicalTieCountByPlacement,
});
// 3f. Mark the window event complete (final-results sync only). This is
// 3e. Mark the window event complete (final-results sync only). This is
// what makes "score once" actually complete every window — without it,
// each sibling stayed "In Progress" and had to be completed by hand.
// Skip windows already complete so a re-run (e.g. a backfill) doesn't
@ -323,23 +269,9 @@ export async function syncTournamentResults(
*/
export async function syncMajorFromPrimaryEvent(
primaryEventId: string,
options: {
markComplete?: boolean;
skipNotifications?: boolean;
/**
* Primary-window season_participant ids knocked out this sync in a non-scoring
* round (from the primary bracket's newlyDecidedLoserIds). Translated to
* canonical participant ids here, then fanned out so each mirror window can
* announce its own "Knocked Out" section.
*/
newlyEliminatedParticipantIds?: Set<string>;
} = {}
options: { markComplete?: boolean; skipNotifications?: boolean } = {}
): Promise<SyncReport> {
const {
markComplete = false,
skipNotifications = false,
newlyEliminatedParticipantIds,
} = options;
const { markComplete = false, skipNotifications = false } = options;
const primaryEvent = await getScoringEventById(primaryEventId);
if (!primaryEvent) {
@ -401,91 +333,13 @@ export async function syncMajorFromPrimaryEvent(
`[syncMajorFromPrimaryEvent] promoted ${promotedCanonicalIds.size} result(s) from primary ${primaryEventId} to tournament ${tournamentId}; removed ${staleIds.length} stale`
);
// For a bracket major, split each placement's QP by the round's STRUCTURAL tie
// span (the same span the primary used via processQualifyingBracketEvent), not by
// the live count of canonical rows at that placement. Mid-tournament, players
// floored at a tier inflate the row-count and would make mirror windows split
// differently than the primary (e.g. R16 losers → 2 QP instead of 1.5). Derived
// here so every fan-out caller (reprocess, live rounds, finalize) stays consistent.
const tieCountByPlacement = await deriveStructuralTieSpanForBracket(
primaryEvent,
db
);
// Translate the primary window's eliminated season_participant ids into canonical
// participant ids so the fan-out can re-key them per mirror window. These are
// non-scoring-round losers (null placement), so they were skipped from canonical
// promotion above — resolve them directly from season_participants, not from
// tournament_results.
let newlyEliminatedCanonicalParticipantIds: Set<string> | undefined;
if (newlyEliminatedParticipantIds?.size) {
const eliminatedRows = await db
.select({ participantId: schema.seasonParticipants.participantId })
.from(schema.seasonParticipants)
.where(
inArray(schema.seasonParticipants.id, [...newlyEliminatedParticipantIds])
);
newlyEliminatedCanonicalParticipantIds = new Set(
eliminatedRows
.map((r) => r.participantId)
.filter((id): id is string => id !== null)
);
}
return syncTournamentResults(tournamentId, {
markComplete,
skipEventId: primaryEventId,
skipNotifications,
tieCountByPlacement,
newlyEliminatedCanonicalParticipantIds,
});
}
/**
* Build a placement structural tie-span map for a bracket major's primary
* window, mirroring the spans processQualifyingBracketEvent assigns (R16 losers
* span 8 slots, QF 4, SF 2, Final 1). Returns undefined for a primary with no
* bracket template or no matches (e.g. golf), so the fan-out falls back to counting
* canonical rows exactly as before.
*
* The span for a given placement is consistent across the bracket (every R16-tier
* state carries tieCount 8, whether the player is a final loser or still floored),
* so collapsing the per-participant states into a placement tieCount map is safe.
*/
async function deriveStructuralTieSpanForBracket(
primaryEvent: { id: string; bracketTemplateId: string | null },
db: ReturnType<typeof database>
): Promise<Map<number, number> | undefined> {
if (!primaryEvent.bracketTemplateId) return undefined;
const template = BRACKET_TEMPLATES[primaryEvent.bracketTemplateId];
if (!template) return undefined;
const matches = await db
.select()
.from(schema.playoffMatches)
.where(eq(schema.playoffMatches.scoringEventId, primaryEvent.id));
if (matches.length === 0) return undefined;
const states = deriveBracketQualifyingStates(
matches.map((m) => ({
round: m.round,
winnerId: m.winnerId,
loserId: m.loserId,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
})),
template.rounds,
(round) => getRoundConfig(round, primaryEvent.bracketTemplateId)
);
if (states.size === 0) return undefined;
const map = new Map<number, number>();
for (const { placement, tieCount } of states.values()) {
map.set(placement, tieCount);
}
return map;
}
/**
* Convenience guard for route handlers: fan out a just-scored bracket/stage event
* to its sibling windows ONLY when it's the designated primary of a shared
@ -495,15 +349,7 @@ async function deriveStructuralTieSpanForBracket(
*/
export async function fanOutMajorIfPrimary(
event: { id: string; isPrimary: boolean; tournamentId: string | null },
options: {
markComplete?: boolean;
/**
* Primary-window season_participant ids knocked out this sync in a non-scoring
* round. Forwarded to the fan-out so each mirror window announces its own
* "Knocked Out" section.
*/
newlyEliminatedParticipantIds?: Set<string>;
} = {}
options: { markComplete?: boolean } = {}
): Promise<void> {
if (!event.isPrimary || !event.tournamentId) return;
try {

View file

@ -1,122 +0,0 @@
/**
* The official 2026 LLBWS bracket (Williamsport, Aug 1930), transcribed from the PDF.
*
* The printed bracket numbers its games 138. 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 (M14); Intl G1,3,5,7 (M58)
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"],
};

View file

@ -91,62 +91,13 @@ Keep specialized pages when they provide real workflow value, such as Golf Skill
## Input Policies
Direct ratings are preferred by default. If a simulator declares derived inputs, readiness may also pass with those alternatives:
Direct ratings are always preferred. If a simulator declares derived inputs, readiness may also pass with those alternatives:
- `projectedWins` can become Elo using `seasonGames` and `parityFactor` from season config.
- `projectedTablePoints` can become Elo using `seasonGames`, `maxTablePoints`, and `parityFactor`.
- `sourceOdds` can become Elo through the shared futures-to-Elo conversion.
- `sourceOdds` can become a generic `rating` when the simulator declares `derivableInputs: { rating: ["sourceOdds"] }`.
### Raw Elo vs. projections
Raw Elo and projections are *substitutes*, not a blend: `inputPolicy.baseEloPriority`
lists them in order and the first source a participant has wins outright. The
default is `["sourceElo", "projectedWins", "projectedTablePoints"]`, so a stored Elo
beats a projection. Set the Base Elo Source control on the simulator page (or
`baseEloPriority` directly) to `["projectedWins", "sourceElo"]` when projections are
the season's source of truth. Futures odds are separate — they blend on top of
whichever base won, weighted by `inputPolicy.oddsWeight`.
Whenever you write a projection without an explicit Elo, stamp
`metadata.sourceEloMethod` (`"projectedWins"` / `"projectedTablePoints"`) on the
row. `getParticipantSimulatorInputs` reads that flag and returns `sourceElo: null`
so the Elo is re-derived from the projection on every run. Skip it and the
non-destructive upsert leaves the previous Elo in place as a *direct* value, which
then wins the priority race — the projection is stored and silently ignored. Both
the Elo Ratings page's projections mode and the simulator page's CSV importer do
this; any new importer must too.
Projections are stored and displayed exactly as entered. Never round-trip one
through its derived Elo for display: the conversion rounds to an integer Elo, and a
run re-resolves that Elo through the input policy (clamping, plus any futures
blend), so the number the admin sees drifts away from the number they typed.
### Mid-season projections
A projected win total is a projected *final* total. A simulator that seeds from
projections mid-season must spread the difference over the games still to play —
`(projectedWins - currentWins) / remainingGames` — rather than reusing the
season-long rate the derived Elo encodes, or it will never reach the projection.
See `seedingWinRateFor` in `mlb-simulator.ts` (config knob `projectedWinsWeight`,
1 = the projection is authoritative) and `simulateRegularSeasonSeeds` in
`nll-simulator.ts` (which additionally decays a preseason prior as the season
completes).
Two guards belong on any such rest-of-season rate:
- **A target outside `(0, 1)` means the projection is stale** — the team has
already met it, or can no longer reach it. Fall back to the Elo rate. Clamping to
a floor or ceiling instead simulates a team to stop winning entirely, or to win
out, and collapses its seeding variance.
- **Only apply a projection that actually produced the resolved Elo.** Check
`metadata.sourceEloMethod === "projectedWins"` (see `projectionForSeeding` in
`mlb-simulator.ts`). Any other method means the Elo represents something else: a
hand-entered Elo that won the `baseEloPriority` race, or a futures blend. Seeding
off the raw projection in those cases makes the projection simultaneously ignored
as the Elo source and authoritative for the standings, and runs seeding and
playoff matchups on two different strength scales.
Missing tail participants must remain blocked unless the season config explicitly chooses an `inputPolicy.missingEloStrategy`:
```json

View file

@ -5,6 +5,12 @@ Sentry.init({
enabled: process.env.NODE_ENV === "production",
sendDefaultPii: true,
tracesSampleRate: 0,
ignoreErrors: [
/No route matches URL ".*\.css"/,
/No route matches URL ".*\.js"/,
/No route matches URL ".*\.(php|env|xml|aspx|asp|bak|sql|ini)"/i,
/No route matches URL ".*\/(wp-admin|wp-login|phpmyadmin|xmlrpc)"/i,
],
beforeSend(event) {
const msg = event.exception?.values?.[0]?.value ?? "";
// Drop React Flight protocol probe errors (e.g. $1:aa:aa in multipart body)

View file

@ -16,8 +16,18 @@
*
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
*
* npx tsx scripts/backfill-qp-resplit.ts # apply
* npx tsx scripts/backfill-qp-resplit.ts # apply, all tournaments
* npx tsx scripts/backfill-qp-resplit.ts --dry # report only
* npx tsx scripts/backfill-qp-resplit.ts --name Wimbledon # scope to matching event names
* npx tsx scripts/backfill-qp-resplit.ts --name Wimbledon --sport Men # + scope to a sport (e.g. gender)
* npx tsx scripts/backfill-qp-resplit.ts --name Wimbledon --sport Men --dry
*
* --name matches case-insensitively against each linked scoring event's name;
* matches ALL tournaments with a matching event (e.g. both years of "Wimbledon"),
* since the reported bug spans seasons. Tournament names don't encode gender (both
* men's and women's majors are just "Wimbledon") use --sport to scope further,
* matched case-insensitively against the sport's name (e.g. "Tennis - Men").
* Both filters apply to a tournament if ANY of its linked events match.
*/
import { drizzle } from "drizzle-orm/postgres-js";
@ -32,6 +42,12 @@ import {
} from "../app/services/sync-tournament-results.js";
const DRY = process.argv.includes("--dry");
function argValue(flag: string): string | null {
const idx = process.argv.indexOf(flag);
return idx !== -1 && process.argv[idx + 1] ? process.argv[idx + 1].toLowerCase() : null;
}
const NAME_FILTER = argValue("--name");
const SPORT_FILTER = argValue("--sport");
const log = (...a: unknown[]) => console.log(...a);
async function run() {
@ -50,6 +66,23 @@ async function run() {
byTournament.set(ev.tournamentId, arr);
}
if (NAME_FILTER) {
for (const [tournamentId, evs] of byTournament) {
const matches = evs.some((e) => e.name?.toLowerCase().includes(NAME_FILTER));
if (!matches) byTournament.delete(tournamentId);
}
log(`Filtering to tournaments matching --name "${NAME_FILTER}"`);
}
if (SPORT_FILTER) {
for (const [tournamentId, evs] of byTournament) {
const matches = evs.some((e) =>
e.sportsSeason?.sport?.name?.toLowerCase().includes(SPORT_FILTER)
);
if (!matches) byTournament.delete(tournamentId);
}
log(`Filtering to tournaments matching --sport "${SPORT_FILTER}"`);
}
log(`Tournaments with linked qualifying events: ${byTournament.size}`);
let ok = 0;
let failed = 0;
@ -57,7 +90,8 @@ async function run() {
for (const [tournamentId, evs] of byTournament) {
const simulatorType = evs[0]?.sportsSeason?.sport?.simulatorType ?? null;
const bracketMajor = isBracketMajor(simulatorType);
const name = evs[0]?.name ?? tournamentId;
const sportName = evs[0]?.sportsSeason?.sport?.name ?? "unknown sport";
const name = `${evs[0]?.name ?? tournamentId} [${sportName}]`;
// Only re-score fully-scored majors so we don't mark in-progress ones complete.
const anyComplete = evs.some((e) => e.isComplete);

View file

@ -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);
});

View file

@ -11,11 +11,9 @@ export const app = express();
app.use((_, __, next) => DatabaseContext.run(db, next));
// Block common bot probe paths before React Router (and Sentry) see them.
// `blog` is here only because scanners hammer /blog/wp/v2/* — drop it from this
// list if a real blog route is ever added.
// Block common bot probe paths before React Router (and Sentry) see them
const BOT_PROBE_RE =
/\.(php|env|htaccess|aspx|asp|jsp|config|bak|sql|ini|swp|DS_Store)$|^\/(wp-admin|wp-login|phpmyadmin|xmlrpc|server-status|cgi-bin|shell|cmd|console|actuator|blog)(\/|$)/i;
/\.(php|env|htaccess|aspx|asp|jsp|config|bak|sql|ini|swp|DS_Store)$|^\/(wp-admin|wp-login|phpmyadmin|xmlrpc|server-status|cgi-bin|shell|cmd|console|actuator)(\/|$)/i;
app.use((req, res, next) => {
if (BOT_PROBE_RE.test(req.path)) {

View file

@ -7,7 +7,6 @@
"app/models/**/*.ts",
"app/services/**/*.ts",
"app/lib/**/*.ts",
"app/test/fixtures/**/*.ts",
"app/types/**/*.ts",
"vite.config.ts"
],