Compare commits

..

2 commits

Author SHA1 Message Date
Claude
30085ab3e1
Skip settings loader revalidation on section switches
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m17s
Section navigation only changes the :section path param, but the loader
returns the same user/draft-status/linked-account payload for every
section. By default React Router re-runs the loader (3 DB queries) on
each tab switch. Add shouldRevalidate to skip revalidation when only the
section changes, while still refreshing after mutations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jt8Hhsio4bHGMaDZy7ftBs
2026-06-30 06:58:41 +00:00
Claude
1ff4082129
Give user settings sections their own URLs
Each section of the user settings page (Profile, Account, Notifications,
API Access, Data & Privacy) is now a real, linkable path
(/settings/profile, /settings/account, etc.) instead of local toggle
state, so sections can be bookmarked, shared, opened in a new tab, and
reached via the browser back/forward buttons.

- Route now matches an optional segment (settings/:section?), keeping the
  single route so the shared loader/action and form submissions are
  unchanged. An unknown section redirects back to /settings.
- The shared settings nav components render proper <Link>s when given a
  buildHref/backHref, and keep their button/onClick behaviour for the
  league settings page (which has unsaved-changes guards).
- The settings page derives the active section and mobile grid/section
  view from the URL param instead of useState.
- Notifications "Account settings" link and the Discord OAuth callback
  now point at /settings/account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jt8Hhsio4bHGMaDZy7ftBs
2026-06-30 06:48:09 +00:00
143 changed files with 2556 additions and 16374 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,236 +0,0 @@
import { Link } from "react-router";
import {
addMonths,
differenceInCalendarDays,
eachMonthOfInterval,
format,
parseISO,
} from "date-fns";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import type { DraftScheduleWindow } from "~/models/sports-season";
export interface GanttSport {
id: string;
name: string;
slug: string;
iconUrl: string | null;
windows: DraftScheduleWindow[];
}
interface DraftScheduleGanttProps {
sports: GanttSport[];
/** Horizon start (today) as a YYYY-MM-DD string. */
today: string;
/** Number of months the timeline spans. */
months: number;
}
// Bar color by sport-season status, using the theme chart tokens from app.css.
const STATUS_COLORS: Record<DraftScheduleWindow["status"], string> = {
active: "var(--chart-1)",
upcoming: "var(--chart-2)",
completed: "var(--muted-foreground)",
};
const STATUS_LABELS: Record<DraftScheduleWindow["status"], string> = {
active: "Active",
upcoming: "Upcoming",
completed: "Completed",
};
const clampPct = (n: number) => Math.max(0, Math.min(100, n));
// Row layout (px). A single-lane row is LANE_HEIGHT + ROW_V_PAD tall; extra
// concurrent windows add one lane each so overlapping bars never stack on top
// of one another.
const LANE_HEIGHT = 30;
const LANE_GAP = 8;
const ROW_V_PAD = 14;
const rowHeight = (laneCount: number) => Math.max(laneCount, 1) * LANE_HEIGHT + ROW_V_PAD;
/**
* Greedy interval-scheduling: assign each window to the first lane whose last
* bar ends before this one starts, otherwise open a new lane. Windows arrive
* sorted by draftOn (see findDraftScheduleForHorizon).
*/
function assignLanes(
windows: DraftScheduleWindow[],
start: Date
): { placed: Array<{ window: DraftScheduleWindow; lane: number }>; laneCount: number } {
const laneEnds: number[] = [];
const placed = windows.map((window) => {
const startDay = differenceInCalendarDays(parseISO(window.draftOn), start);
const endDay = differenceInCalendarDays(parseISO(window.draftOff), start);
let lane = laneEnds.findIndex((laneEnd) => laneEnd < startDay);
if (lane === -1) {
lane = laneEnds.length;
laneEnds.push(endDay);
} else {
laneEnds[lane] = endDay;
}
return { window, lane };
});
return { placed, laneCount: Math.max(laneEnds.length, 1) };
}
export function DraftScheduleGantt({ sports, today, months }: DraftScheduleGanttProps) {
const start = parseISO(today);
const end = addMonths(start, months);
const totalDays = Math.max(differenceInCalendarDays(end, start), 1);
const pct = (date: Date) => (differenceInCalendarDays(date, start) / totalDays) * 100;
// Month-boundary gridlines that fall inside the horizon.
const monthLines = eachMonthOfInterval({ start, end })
.map((date) => ({ date, left: pct(date) }))
.filter((m) => m.left >= 0 && m.left <= 100);
// Pre-compute lane assignment + height for each sport row.
const rows = sports.map((sport) => {
const { placed, laneCount } = assignLanes(sport.windows, start);
return { sport, placed, height: rowHeight(laneCount) };
});
if (sports.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Draft Schedule</CardTitle>
<CardDescription>Draft windows across the next {months} months</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12 text-muted-foreground">
<p>No sports found.</p>
<p className="text-sm mt-2">Create a sport to start scheduling draft windows.</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>Draft Schedule</CardTitle>
<CardDescription>
Draft windows across the next {months} months (each bar spans a
sport-season&rsquo;s draft-on &rarr; draft-off window)
</CardDescription>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{(Object.keys(STATUS_COLORS) as DraftScheduleWindow["status"][]).map((status) => (
<div key={status} className="flex items-center gap-1.5">
<span
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: STATUS_COLORS[status] }}
aria-hidden="true"
/>
<span>{STATUS_LABELS[status]}</span>
</div>
))}
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="flex" style={{ minWidth: 160 + months * 80 }}>
{/* Sport labels column */}
<div className="w-40 shrink-0">
<div className="h-8" aria-hidden="true" />
{rows.map(({ sport, height }) => (
<div
key={sport.id}
style={{ height }}
className="flex items-center border-b border-border/50 pr-2"
>
<span className="truncate text-sm font-medium" title={sport.name}>
{sport.name}
</span>
</div>
))}
</div>
{/* Timeline column */}
<div className="relative flex-1">
{/* Month gridlines (full height) */}
{monthLines.map((m) => (
<div
key={`line-${m.date.toISOString()}`}
className="pointer-events-none absolute bottom-0 top-0 w-px bg-border/60"
style={{ left: `${m.left}%` }}
aria-hidden="true"
/>
))}
{/* Today marker (full height) */}
<div
className="pointer-events-none absolute bottom-0 top-0 w-0.5 bg-primary/80"
style={{ left: 0 }}
aria-hidden="true"
/>
{/* Month labels header */}
<div className="relative h-8">
{monthLines.map((m) => (
<span
key={`label-${m.date.toISOString()}`}
className="absolute top-1 text-xs text-muted-foreground"
style={{ left: `calc(${m.left}% + 4px)` }}
>
{format(m.date, "MMM yyyy")}
</span>
))}
</div>
{/* Rows */}
{rows.map(({ sport, placed, height }) => (
<div
key={sport.id}
style={{ height }}
className="relative border-b border-border/50"
>
{placed.length === 0 ? (
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs italic text-muted-foreground/70">
No draft window
</span>
) : (
placed.map(({ window: w, lane }) => {
const left = clampPct(pct(parseISO(w.draftOn)));
const right = clampPct(pct(parseISO(w.draftOff)));
const width = Math.max(right - left, 0.75);
return (
<Link
key={w.id}
to={`/admin/sports-seasons/${w.id}`}
className="absolute flex items-center overflow-hidden rounded px-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
style={{
left: `${left}%`,
width: `${width}%`,
top: ROW_V_PAD / 2 + lane * LANE_HEIGHT,
height: LANE_HEIGHT - LANE_GAP,
backgroundColor: STATUS_COLORS[w.status],
}}
title={`${w.name} (${w.year}) · ${w.draftOn}${w.draftOff}`}
>
<span className="truncate">{w.name}</span>
</Link>
);
})
)}
</div>
))}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -1,97 +0,0 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { DraftScheduleGantt, type GanttSport } from "../DraftScheduleGantt";
const nbaWindow = {
id: "ss-1",
name: "2026 NBA Playoffs",
year: 2026,
status: "upcoming" as const,
draftOn: "2026-08-01",
draftOff: "2026-09-15",
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
};
function renderGantt(sports: GanttSport[], months = 6) {
return render(
<MemoryRouter>
<DraftScheduleGantt sports={sports} today="2026-07-02" months={months} />
</MemoryRouter>
);
}
describe("DraftScheduleGantt", () => {
it("renders empty state when there are no sports", () => {
renderGantt([]);
expect(screen.getByText("No sports found.")).toBeInTheDocument();
});
it("renders a bar linking to the sport-season for each draft window", () => {
renderGantt([
{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] },
]);
const bar = screen.getByRole("link", { name: /2026 NBA Playoffs/ });
expect(bar).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
});
it("renders overlapping windows as separate, clickable bars", () => {
const overlappingWindow = {
id: "ss-2",
name: "2027 NBA Playoffs",
year: 2027,
status: "active" as const,
draftOn: "2026-08-15",
draftOff: "2026-10-01",
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
};
renderGantt([
{
id: "nba",
name: "NBA",
slug: "nba",
iconUrl: null,
windows: [nbaWindow, overlappingWindow],
},
]);
expect(
screen.getByRole("link", { name: /2026 NBA Playoffs/ })
).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
expect(
screen.getByRole("link", { name: /2027 NBA Playoffs/ })
).toHaveAttribute("href", "/admin/sports-seasons/ss-2");
});
it("shows a 'No draft window' hint for a sport with no windows", () => {
renderGantt([
{ id: "golf", name: "Golf", slug: "golf", iconUrl: null, windows: [] },
]);
expect(screen.getByText("Golf")).toBeInTheDocument();
expect(screen.getByText("No draft window")).toBeInTheDocument();
});
it("renders the status legend", () => {
renderGantt([
{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] },
]);
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText("Upcoming")).toBeInTheDocument();
expect(screen.getByText("Completed")).toBeInTheDocument();
});
it("reflects the horizon length in the description", () => {
renderGantt(
[{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] }],
12
);
expect(
screen.getByText(/Draft windows across the next 12 months/)
).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

@ -6,7 +6,7 @@ vi.mock("~/database/context", () => ({
}));
import { database } from "~/database/context";
import { batchUpsertParticipantSimulatorInputs } from "../simulator";
import { batchSaveFuturesOddsForSimulator } from "../simulator";
type SetPayload = Record<string, unknown>;
@ -17,71 +17,36 @@ const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => {
return Promise.resolve(undefined);
});
const tx = {
const mockDb = {
insert: vi.fn(() => ({
values: vi.fn(() => ({ onConflictDoUpdate })),
})),
};
const mockDb = {
transaction: vi.fn((cb: (t: typeof tx) => Promise<unknown>) => cb(tx)),
};
beforeEach(() => {
vi.clearAllMocks();
conflictSetCalls.length = 0;
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
});
// Recursively flatten a Drizzle `sql` template into its static text so we can
// assert how a conflict-update column is built (e.g. wrapped in COALESCE).
function sqlToText(value: unknown): string {
if (value === null || value === undefined) return "";
const chunks = (value as { queryChunks?: unknown[] }).queryChunks;
if (Array.isArray(chunks)) return chunks.map(sqlToText).join("");
const stringValue = (value as { value?: unknown }).value;
if (Array.isArray(stringValue)) return stringValue.join("");
if (typeof stringValue === "string") return stringValue;
return "";
}
describe("batchUpsertParticipantSimulatorInputs", () => {
it("updates only the columns it is given, preserving the rest (non-destructive)", async () => {
await batchUpsertParticipantSimulatorInputs([
// Odds-only import (e.g. from the bulk futures paste): no Elo/rating supplied.
describe("batchSaveFuturesOddsForSimulator", () => {
it("persists odds without destroying a stored Elo/rating", async () => {
await batchSaveFuturesOddsForSimulator([
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
]);
// Runs in a transaction, writing the simulator-inputs row first then the
// legacy EV bridge row.
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
expect(conflictSetCalls.length).toBeGreaterThanOrEqual(1);
const simulatorSet = conflictSetCalls[0];
// Every input column participates in the conflict update so partial pastes
// can target any field...
for (const column of ["sourceOdds", "sourceElo", "worldRanking", "rating", "seed", "region"]) {
expect(simulatorSet).toHaveProperty(column);
}
// ...but each is COALESCE-wrapped, so a null incoming value keeps the stored
// one instead of clobbering it. A bare `excluded.*` here would be the bug.
const sourceEloSql = sqlToText(simulatorSet.sourceElo).toLowerCase();
expect(sourceEloSql).toContain("coalesce");
const ratingSql = sqlToText(simulatorSet.rating).toLowerCase();
expect(ratingSql).toContain("coalesce");
// Metadata must drop the per-column method flag whenever that column gets a
// fresh direct value, so a stale "generated" flag can't hide a newly entered
// Elo/rating. Blindly preserving metadata (e.g. COALESCE) would be the bug.
const metadataSql = sqlToText(simulatorSet.metadata).toLowerCase();
expect(metadataSql).toContain("sourceelomethod");
expect(metadataSql).toContain("ratingmethod");
expect(metadataSql).toContain("excluded.source_elo");
expect(metadataSql).toContain("excluded.rating");
// The upsert writes the odds and leaves Elo/rating untouched — whether the
// odds override them is now decided by the configurable source priority,
// not by nulling stored values here.
expect(mockDb.insert).toHaveBeenCalledTimes(1);
expect(conflictSetCalls).toHaveLength(1);
expect(conflictSetCalls[0]).toHaveProperty("sourceOdds");
expect(conflictSetCalls[0]).not.toHaveProperty("sourceElo");
expect(conflictSetCalls[0]).not.toHaveProperty("rating");
});
it("is a no-op when given no inputs", async () => {
await batchUpsertParticipantSimulatorInputs([]);
expect(mockDb.transaction).not.toHaveBeenCalled();
await batchSaveFuturesOddsForSimulator([]);
expect(mockDb.insert).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

@ -1,113 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// populateBracketFromDraw upserts a full draw into playoff_matches and reports
// which losers' matches *transitioned to complete on this run* — the signal used
// to announce knockouts once, idempotently across re-syncs.
const existingRows: Array<Record<string, unknown>> = [];
const mockDb = {
query: {
playoffMatches: {
findMany: vi.fn(async () => existingRows),
},
},
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
})),
insert: vi.fn(() => ({
values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([]) })),
})),
};
vi.mock("~/database/context", () => ({ database: () => mockDb }));
import { populateBracketFromDraw, type ResolvedDrawMatch } from "../playoff-match";
const EVENT_ID = "ev-1";
function match(overrides: Partial<ResolvedDrawMatch> = {}): ResolvedDrawMatch {
return {
externalMatchId: "m-1",
round: "Round of 64",
matchNumber: 1,
participant1Id: "winner",
participant2Id: "loser",
winnerId: "winner",
loserId: "loser",
isScoring: false,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
existingRows.length = 0;
});
describe("populateBracketFromDraw newlyDecidedLoserIds", () => {
it("reports the loser of a brand-new completed match", async () => {
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
match({ externalMatchId: "m-1", loserId: "mensik" }),
]);
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
});
it("reports the loser of an existing match that just reached completion", async () => {
existingRows.push({
id: "row-1",
externalMatchId: "m-1",
isComplete: false,
loserId: null,
winnerId: null,
});
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
match({ externalMatchId: "m-1", loserId: "mensik" }),
]);
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
});
it("reports nothing on an idempotent re-sync of an already-complete match", async () => {
existingRows.push({
id: "row-1",
externalMatchId: "m-1",
isComplete: true,
loserId: "mensik",
winnerId: "winner",
});
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
match({ externalMatchId: "m-1", loserId: "mensik" }),
]);
expect(newlyDecidedLoserIds).toEqual([]);
});
it("ignores incomplete matches (no winner/loser yet)", async () => {
const { newlyDecidedLoserIds, completed } = await populateBracketFromDraw(EVENT_ID, [
match({ externalMatchId: "m-2", winnerId: null, loserId: null }),
]);
expect(newlyDecidedLoserIds).toEqual([]);
expect(completed).toBe(0);
});
it("collects only the newly-decided losers in a mixed batch", async () => {
existingRows.push(
{ id: "row-1", externalMatchId: "m-1", isComplete: true, loserId: "old", winnerId: "w1" },
{ id: "row-2", externalMatchId: "m-2", isComplete: false, loserId: null, winnerId: null },
);
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
match({ externalMatchId: "m-1", loserId: "old" }), // already complete → skip
match({ externalMatchId: "m-2", loserId: "freshly-out" }), // transitioned → include
match({ externalMatchId: "m-3", loserId: "brand-new-out" }), // new complete → include
match({ externalMatchId: "m-4", winnerId: null, loserId: null }), // incomplete → skip
]);
expect(newlyDecidedLoserIds.toSorted()).toEqual(["brand-new-out", "freshly-out"]);
});
});

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

@ -2,47 +2,9 @@ import { describe, it, expect } from "vitest";
import {
calculateSplitQualifyingPoints,
DEFAULT_QP_VALUES,
diffChangedQualifyingPoints,
hasProcessedQualifyingPlacement,
} from "../qualifying-points";
describe("diffChangedQualifyingPoints", () => {
it("flags a changed value, a first-time score (null → value), and ignores unchanged", () => {
const before = [
{ id: "A", qp: "10.00" },
{ id: "B", qp: "5.00" },
{ id: "C", qp: null },
];
const after = [
{ id: "A", qp: "10.00" }, // unchanged
{ id: "B", qp: "8.00" }, // changed
{ id: "C", qp: "3.00" }, // first-time score
];
expect([...diffChangedQualifyingPoints(before, after)].toSorted()).toEqual(["B", "C"]);
});
it("normalizes decimal formatting so 10 and 10.00 are equal", () => {
const changed = diffChangedQualifyingPoints([{ id: "A", qp: "10" }], [{ id: "A", qp: "10.00" }]);
expect(changed.size).toBe(0);
});
it("does not report participants absent from the after set", () => {
const changed = diffChangedQualifyingPoints(
[
{ id: "A", qp: "10.00" },
{ id: "D", qp: "2.00" },
],
[{ id: "A", qp: "10.00" }],
);
expect(changed.size).toBe(0);
});
it("treats a value → zero drop as a change", () => {
const changed = diffChangedQualifyingPoints([{ id: "A", qp: "10.00" }], [{ id: "A", qp: "0.00" }]);
expect([...changed]).toEqual(["A"]);
});
});
describe("Qualifying Points Configuration", () => {
describe("DEFAULT_QP_VALUES", () => {
it("should have 16 placement values", () => {
@ -309,6 +271,52 @@ describe("Qualifying Points Configuration", () => {
expect(totalQP2).toBe(20); // Should have 20 QP (1st place)
});
it("should not increment majorsCompleted when reprocessing", () => {
// First processing
let majorsCompleted = 0;
const wasAlreadyProcessed = false;
if (!wasAlreadyProcessed) {
majorsCompleted += 1;
}
expect(majorsCompleted).toBe(1);
// Reprocessing (wasAlreadyProcessed = true)
const reprocessing = true;
if (!reprocessing) {
majorsCompleted += 1;
}
expect(majorsCompleted).toBe(1); // Should still be 1, not 2
});
});
describe("Processed event detection", () => {
it("treats a placed zero-QP result as already processed", () => {
expect(
hasProcessedQualifyingPlacement([
{ placement: 20, qualifyingPointsAwarded: "0.00" },
])
).toBe(true);
});
it("does not treat filler zero-QP rows without placements as processed", () => {
expect(
hasProcessedQualifyingPlacement([
{ placement: null, qualifyingPointsAwarded: "0" },
])
).toBe(false);
});
it("does not treat unprocessed placed rows as processed", () => {
expect(
hasProcessedQualifyingPlacement([
{ placement: 15, qualifyingPointsAwarded: null },
])
).toBe(false);
});
});
describe("Scoring Workflow", () => {

View file

@ -1,143 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type * as QualifyingPointsModule from "~/models/qualifying-points";
// Spy on the QP Discord notification, and no-op the per-participant total recalc
// (its own eventResults query would otherwise interfere with the before/after
// snapshot counter below). Everything else in the scorer runs for real.
vi.mock("~/services/qualifying-points-discord.server", () => ({
notifyQualifyingPointsUpdate: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/models/qualifying-points", async (importActual) => {
const actual = await importActual<typeof QualifyingPointsModule>();
return {
...actual,
recalculateParticipantQP: vi.fn().mockResolvedValue({ totalQP: 0, eventsScored: 0 }),
};
});
import { processQualifyingEvent } from "../scoring-calculator";
import { DEFAULT_QP_VALUES } from "../qualifying-points";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
const EVENT_ID = "event-1";
const SPORTS_SEASON_ID = "sports-season-1";
type ResultRow = {
id: string;
seasonParticipantId: string;
placement: number | null;
qualifyingPointsAwarded: string | null;
scoringEvent: { id: string; sportsSeasonId: string };
};
/**
* Mock db for the non-bracket `processQualifyingEvent` path. `eventResults.findMany`
* returns `before` on its first call (the initial getEventResults snapshot) and
* `after` on its second (the post-reprocess re-query that drives change detection).
* Awarding updates go to a no-op update chain `after` stands in for the DB state
* once the scorer has written its QP.
*/
function makeDb(before: ResultRow[], after: ResultRow[]) {
const results = [before, after];
let findManyCall = 0;
return {
query: {
scoringEvents: {
findFirst: async () => ({
id: EVENT_ID,
sportsSeasonId: SPORTS_SEASON_ID,
isQualifyingEvent: true,
bracketTemplateId: null,
sportsSeason: { majorsCompleted: 1 },
}),
},
eventResults: {
findMany: async () => results[Math.min(findManyCall++, 1)],
},
qualifyingPointConfig: {
findMany: async () =>
DEFAULT_QP_VALUES.map((qp) => ({ placement: qp.placement, points: qp.points.toString() })),
},
},
update: () => ({ set: () => ({ where: async () => [] }) }),
} as never;
}
const scoringEvent = { id: EVENT_ID, sportsSeasonId: SPORTS_SEASON_ID };
beforeEach(() => {
vi.clearAllMocks();
});
describe("processQualifyingEvent — QP change notification", () => {
it("notifies only participants whose QP changed (first-time score: null → value)", async () => {
const db = makeDb(
[
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: null, scoringEvent },
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
],
[
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
],
);
await processQualifyingEvent(EVENT_ID, db);
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
const [ssId, evId, , filter] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
expect(ssId).toBe(SPORTS_SEASON_ID);
expect(evId).toBe(EVENT_ID);
// p1 went null → 100 (changed); p2 stayed at 0 (unchanged).
expect([...(filter as Set<string>)]).toEqual(["p1"]);
});
it("does not notify when a re-sync produces identical QP", async () => {
const rows: ResultRow[] = [
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
];
const db = makeDb(rows, rows.map((r) => ({ ...r })));
await processQualifyingEvent(EVENT_ID, db);
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

@ -200,21 +200,7 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
});
describe("processQualifyingEvent", () => {
function makeProcessQPMockDb(
eventResults: Array<Record<string, unknown>>,
options: {
tournamentId?: string | null;
canonicalPlacements?: number[];
bracketTemplateId?: string | null;
playoffMatchIds?: string[];
} = {}
) {
const {
tournamentId = null,
canonicalPlacements = [],
bracketTemplateId = null,
playoffMatchIds = [],
} = options;
function makeProcessQPMockDb(eventResults: Array<Record<string, unknown>>) {
const setCalls: unknown[] = [];
const updateChain = {
set: (values: unknown) => {
@ -231,8 +217,6 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
id: "event-1",
sportsSeasonId: "sports-season-1",
isQualifyingEvent: true,
tournamentId,
bracketTemplateId,
sportsSeason: { majorsCompleted: 1 },
}),
},
@ -254,23 +238,6 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
}),
},
},
// Two select shapes flow through the non-bracket path:
// • playoff-match existence check (has an `id` projection, uses .limit(1))
// • canonical tournament_results tie span (has a `placement` projection).
// Discriminate by the projection keys and return a thenable that also
// exposes .limit so both call shapes resolve.
select: (fields: Record<string, unknown>) => {
const isPlayoff = fields && "id" in fields;
const rows = isPlayoff
? playoffMatchIds.map((id) => ({ id }))
: canonicalPlacements.map((placement) => ({ placement }));
return {
from: () => ({
where: () =>
Object.assign(Promise.resolve(rows), { limit: async () => rows }),
}),
};
},
update: () => updateChain,
} as any;
@ -306,123 +273,6 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
);
});
it("splits a tied placement by the FULL canonical field, not the window's roster subset", async () => {
// Regression for the reported 2-vs-1.5 QP bug. Tennis Round-of-16 losers all
// land at placement 9 with a structural tie span of 8 (positions 916 →
// (2+2+2+2+1+1+1+1)/8 = 1.5). A sibling/mirror window only holds the drafted
// subset — here just 2 of the 8 tied players — but the split must still use 8,
// not the 2 present locally (which would wrongly give (2+2)/2 = 2.00).
const windowRows = [
{
id: "result-1",
seasonParticipantId: "participant-1",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
{
id: "result-2",
seasonParticipantId: "participant-2",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
];
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
tournamentId: "tournament-1",
canonicalPlacements: Array.from({ length: 8 }, () => 9), // full field: 8 at 9th
});
await processQualifyingEvent("event-1", db, { skipNotifications: true });
// Both present players earn the correct split of 1.50, not 2.00.
expect(setCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
])
);
expect(setCalls).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
])
);
});
it("scores a cloned window (bracketTemplateId but no playoff matches) via the canonical path", async () => {
// cloneSportsSeason copies bracketTemplateId to league windows but not the
// playoff matches. Such a window must NOT take the bracket branch (which would
// derive zero states and write no QP) — it has to fall through to the
// placement/canonical path and still split R16 losers to 1.5.
const windowRows = [
{
id: "result-1",
seasonParticipantId: "participant-1",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
{
id: "result-2",
seasonParticipantId: "participant-2",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
];
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
tournamentId: "tournament-1",
bracketTemplateId: "tennis_128", // copied by clone…
playoffMatchIds: [], // …but no matches exist on this window
canonicalPlacements: Array.from({ length: 8 }, () => 9),
});
await processQualifyingEvent("event-1", db, { skipNotifications: true });
expect(setCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
])
);
expect(setCalls).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
])
);
});
it("falls back to the live roster count for standalone events with no tournament", async () => {
// No canonical field exists for a manual/standalone qualifying event, so the
// tie span is the players actually present: 2 players tied at 9th →
// (2+2)/2 = 2.00. This preserves existing behavior where there is no full field.
const windowRows = [
{
id: "result-1",
seasonParticipantId: "participant-1",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
{
id: "result-2",
seasonParticipantId: "participant-2",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
];
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
tournamentId: null,
});
await processQualifyingEvent("event-1", db, { skipNotifications: true });
expect(setCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
])
);
});
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
const { db, setCalls } = makeProcessQPMockDb([
{

View file

@ -15,10 +15,7 @@ vi.mock("../qualifying-points", async (importOriginal) => {
import { deleteScoringEvent } from "../scoring-event";
describe("deleteScoringEvent", () => {
it("does not write majorsCompleted on delete (it is derived on read)", async () => {
// majorsCompleted is no longer a stored counter — it is computed via
// getMajorsCompleted from completed qualifying events. Deleting a qualifying
// event must therefore never issue a sportsSeasons.majorsCompleted update.
it("decrements majorsCompleted for a processed zero-QP qualifying event", async () => {
const setCalls: unknown[] = [];
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
const updateChain = {
@ -46,6 +43,12 @@ describe("deleteScoringEvent", () => {
},
]),
},
sportsSeasons: {
findFirst: vi.fn().mockResolvedValue({
id: "sports-season-1",
majorsCompleted: 1,
}),
},
},
transaction: vi.fn(async (callback) => callback(db)),
delete: vi.fn(() => deleteChain),
@ -54,125 +57,10 @@ describe("deleteScoringEvent", () => {
await deleteScoringEvent("event-1", db);
expect(setCalls).not.toEqual(
expect(setCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ majorsCompleted: expect.anything() }),
expect.objectContaining({ majorsCompleted: 0 }),
])
);
});
describe("shared tournament handling", () => {
// Builds a mock db for a non-qualifying schedule_event linked to a tournament,
// so the delete skips the QP/league branches and exercises only the
// shared-tournament bookkeeping.
function makeSharedDb({
deletedEvent,
remaining,
siblingTournamentId = "tournament-1",
}: {
deletedEvent: { tournamentId: string | null; isPrimary: boolean };
remaining: Array<{ id: string; isPrimary: boolean }>;
siblingTournamentId?: string;
}) {
const findFirst = vi
.fn()
// 1st call: the event being deleted
.mockResolvedValueOnce({
id: "event-1",
sportsSeasonId: "sports-season-1",
eventType: "schedule_event",
isQualifyingEvent: false,
...deletedEvent,
})
// subsequent calls: setPrimaryEvent looking up the promoted event
.mockResolvedValue({ id: "promoted", tournamentId: siblingTournamentId });
const deleteWhere = vi.fn().mockResolvedValue(undefined);
const updateWhere = vi.fn().mockResolvedValue(undefined);
const db = {
query: {
scoringEvents: {
findFirst,
findMany: vi.fn().mockResolvedValue(remaining),
},
},
transaction: vi.fn(async (callback) => callback(db)),
delete: vi.fn(() => ({ where: deleteWhere })),
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
} as any;
return { db, deleteWhere };
}
it("keeps the tournament and promotes nothing when a non-primary window is deleted", async () => {
const { db } = makeSharedDb({
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
remaining: [{ id: "event-2", isPrimary: true }],
});
const result = await deleteScoringEvent("event-1", db);
expect(result.remainingWindows).toBe(1);
expect(result.promotedPrimaryId).toBeNull();
expect(result.deletedTournament).toBe(false);
});
it("does not promote a primary when a golf-style tournament (no primary) loses a window", async () => {
// Golf-style shared majors intentionally have no primary window — every
// linked event is isPrimary=false. Deleting one must not flip a sibling
// into a primary, which would change its scoring/guard behavior.
const { db } = makeSharedDb({
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
remaining: [
{ id: "event-2", isPrimary: false },
{ id: "event-3", isPrimary: false },
],
});
const result = await deleteScoringEvent("event-1", db);
expect(result.promotedPrimaryId).toBeNull();
expect(result.remainingWindows).toBe(2);
});
it("promotes the earliest remaining window when the primary window is deleted", async () => {
const { db } = makeSharedDb({
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
// findMany is ordered asc(createdAt); first is the earliest.
remaining: [
{ id: "event-2", isPrimary: false },
{ id: "event-3", isPrimary: false },
],
});
const result = await deleteScoringEvent("event-1", db);
expect(result.promotedPrimaryId).toBe("event-2");
expect(result.remainingWindows).toBe(2);
});
it("leaves the orphaned tournament intact when the last window is deleted without the flag", async () => {
const { db } = makeSharedDb({
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
remaining: [],
});
const result = await deleteScoringEvent("event-1", db);
expect(result.remainingWindows).toBe(0);
expect(result.deletedTournament).toBe(false);
});
it("deletes the orphaned tournament when the last window is deleted with the flag", async () => {
const { db } = makeSharedDb({
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
remaining: [],
});
const result = await deleteScoringEvent("event-1", db, {
deleteOrphanTournament: true,
});
expect(result.deletedTournament).toBe(true);
});
});
});

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

@ -27,11 +27,7 @@ vi.mock("drizzle-orm", () => ({
asc: (col: unknown) => ({ type: "asc", col }),
}));
import {
findAllSportsSeasons,
findDraftableSportsSeasons,
findDraftScheduleForHorizon,
} from "../sports-season";
import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season";
import { database } from "~/database/context";
const today = new Date().toISOString().slice(0, 10);
@ -129,58 +125,3 @@ describe("findDraftableSportsSeasons", () => {
);
});
});
describe("findDraftScheduleForHorizon", () => {
const horizonWindows = [
{
id: "w1",
name: "2026 NBA Playoffs",
year: 2026,
status: "upcoming",
draftOn: today,
draftOff: future,
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
},
{
id: "w2",
name: "2026 F1 Season",
year: 2026,
status: "active",
draftOn: today,
draftOff: future,
sport: { id: "f1", name: "F1", slug: "f1", iconUrl: null },
},
];
it("maps rows to windows carrying their sport info", async () => {
vi.mocked(database).mockReturnValue(makeMockDb(horizonWindows) as never);
const result = await findDraftScheduleForHorizon(6);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
id: "w1",
draftOn: today,
draftOff: future,
sport: { id: "nba", name: "NBA" },
});
});
it("returns an empty array when no windows overlap the horizon", async () => {
vi.mocked(database).mockReturnValue(makeMockDb([]) as never);
const result = await findDraftScheduleForHorizon(12);
expect(result).toHaveLength(0);
});
it("queries with a where clause, ordering, and the sport relation", async () => {
const mockDb = makeMockDb([]);
vi.mocked(database).mockReturnValue(mockDb as never);
await findDraftScheduleForHorizon(6);
expect(mockDb.query.sportsSeasons.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.anything(),
orderBy: expect.anything(),
with: expect.anything(),
})
);
});
});

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

@ -11,7 +11,6 @@ import {
findTournamentBySportNameYear,
upsertTournament,
updateTournamentStatus,
deleteTournament,
} from "../tournament";
import { database } from "~/database/context";
@ -173,26 +172,3 @@ describe("updateTournamentStatus", () => {
expect(result.status).toBe("in_progress");
});
});
describe("deleteTournament", () => {
it("deletes the tournament row by id", async () => {
const where = vi.fn().mockResolvedValue(undefined);
const db = { delete: vi.fn().mockReturnValue({ where }) };
vi.mocked(database).mockReturnValue(db as never);
await deleteTournament(TOURNAMENT_ID);
expect(db.delete).toHaveBeenCalledTimes(1);
expect(where).toHaveBeenCalledTimes(1);
});
it("uses a provided db when passed (transaction)", async () => {
const where = vi.fn().mockResolvedValue(undefined);
const providedDb = { delete: vi.fn().mockReturnValue({ where }) };
await deleteTournament(TOURNAMENT_ID, providedDb as never);
expect(providedDb.delete).toHaveBeenCalledTimes(1);
expect(vi.mocked(database)).not.toHaveBeenCalled();
});
});

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

@ -17,8 +17,6 @@ import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/databa
import { eq, and, sql } from "drizzle-orm";
import { getQPConfig, calculateSplitQualifyingPoints, writeEventResultsQP, recalculateParticipantQP } from "~/models/qualifying-points";
import { deleteEventResults } from "~/models/event-result";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import { logger } from "~/lib/logger";
export interface Cs2StageResult {
id: string;
@ -440,30 +438,5 @@ export async function assignCs2EliminationQP(
}
}
// Snapshot existing QP before writing so the notification only covers new/changed participants,
// preventing earlier stage exits from being re-announced on each subsequent stage call.
const existingRows = await db.query.eventResults.findMany({
where: eq(eventResults.scoringEventId, scoringEventId),
});
const existingQPBySPId = new Map<string, number>(
existingRows
.filter((r) => r.qualifyingPointsAwarded !== null)
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
);
await writeEventResultsQP(scoringEventId, sportsSeasonId, resultsByParticipant, db);
const changedIds = new Set<string>(
[...resultsByParticipant.entries()]
.filter(([id, { qp }]) => existingQPBySPId.get(id) !== qp)
.map(([id]) => id)
);
if (changedIds.size > 0) {
try {
await notifyQualifyingPointsUpdate(sportsSeasonId, scoringEventId, db, changedIds);
} catch (error) {
logger.error(`[CS2MajorStage] QP Discord notification failed for event ${scoringEventId}:`, error);
}
}
}

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

@ -6,11 +6,11 @@
*/
import { database } from "~/database/context";
import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema";
import { eq, and, count, sql } from "drizzle-orm";
import { seasonParticipantExpectedValues, seasonParticipants, seasonParticipantSimulatorInputs } from "~/database/schema";
import { eq, and, count, inArray, sql } from "drizzle-orm";
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator";
import { batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
import { batchSaveParticipantSimulatorSourceOdds, batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
@ -366,6 +366,100 @@ export async function batchUpsertParticipantEVs(
return results;
}
/**
* Save American odds for a batch of seasonParticipants without touching probabilities or EV.
* Used by the futures-odds admin page to persist odds before running the full simulation.
*/
export async function batchSaveSourceOdds(
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
): Promise<void> {
const db = database();
await db.transaction(async (tx) => {
const now = new Date();
for (const { participantId, sportsSeasonId, sourceOdds } of inputs) {
const existing = await tx
.select({ id: seasonParticipantExpectedValues.id })
.from(seasonParticipantExpectedValues)
.where(
and(
eq(seasonParticipantExpectedValues.participantId, participantId),
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
)
)
.limit(1);
if (existing.length > 0) {
await tx
.update(seasonParticipantExpectedValues)
// Persist the odds and label the source as odds-driven for display.
// We no longer null a stored Elo here: how much these odds move it is
// decided at run time by the season's blend weight
// (SimulatorInputPolicy.oddsWeight), and prepareSimulatorInputsForRun
// overwrites the resolved Elo before the simulator reads it.
.set({ sourceOdds, source: "futures_odds", updatedAt: now })
.where(eq(seasonParticipantExpectedValues.id, existing[0].id));
} else {
// Insert a stub record — probabilities/EV will be filled in by the simulator
await tx.insert(seasonParticipantExpectedValues).values({
participantId,
sportsSeasonId,
probFirst: "0",
probSecond: "0",
probThird: "0",
probFourth: "0",
probFifth: "0",
probSixth: "0",
probSeventh: "0",
probEighth: "0",
expectedValue: "0",
source: "futures_odds",
sourceOdds,
calculatedAt: now,
updatedAt: now,
});
}
}
});
await batchSaveParticipantSimulatorSourceOdds(inputs);
}
export async function clearSourceOddsForParticipants(
sportsSeasonId: string,
participantIds: string[]
): Promise<void> {
if (participantIds.length === 0) return;
const db = database();
const now = new Date();
await db.transaction(async (tx) => {
await tx
.update(seasonParticipantExpectedValues)
.set({ sourceOdds: null, updatedAt: now })
.where(
and(
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId),
inArray(seasonParticipantExpectedValues.participantId, participantIds)
)
);
await tx
.update(seasonParticipantSimulatorInputs)
.set({
sourceOdds: null,
rating: null,
metadata: sql`coalesce(${seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
updatedAt: now,
})
.where(
and(
eq(seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId),
inArray(seasonParticipantSimulatorInputs.participantId, participantIds)
)
);
});
}
/**
* Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants.
* Used by Elo-based simulators like snooker_bracket and darts_bracket.

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;
@ -175,16 +164,12 @@ export interface ResolvedDrawMatch {
* (e.g. Wikipedia) that reports the full draw including completed rounds. A row
* is marked complete when both its winner and loser are known.
*
* @returns counts of rows written (inserted or updated), those carrying a result,
* and the participant ids of losers whose match *transitioned to complete on this
* run* (a row that was absent or not-yet-complete before and is complete now).
* That set is the newly-decided eliminations used to announce knockouts once,
* idempotently across re-syncs, since playoff_matches persist between syncs.
* @returns counts of rows written (inserted or updated) and those carrying a result.
*/
export async function populateBracketFromDraw(
eventId: string,
matches: ResolvedDrawMatch[]
): Promise<{ written: number; completed: number; newlyDecidedLoserIds: string[] }> {
): Promise<{ written: number; completed: number }> {
const db = database();
const existing = await db.query.playoffMatches.findMany({
@ -199,20 +184,12 @@ export async function populateBracketFromDraw(
const toInsert: NewPlayoffMatch[] = [];
let written = 0;
let completed = 0;
const newlyDecidedLoserIds: string[] = [];
for (const m of matches) {
const isComplete = m.winnerId !== null && m.loserId !== null;
if (isComplete) completed++;
const existingRow = byExternalId.get(m.externalMatchId);
// Loser is "newly decided" when this match reaches completion for the first
// time: either a brand-new complete row, or an existing row that was not
// complete before. Re-syncing an already-complete match yields nothing.
if (isComplete && m.loserId && !existingRow?.isComplete) {
newlyDecidedLoserIds.push(m.loserId);
}
if (existingRow) {
await db
.update(schema.playoffMatches)
@ -252,7 +229,7 @@ export async function populateBracketFromDraw(
written += toInsert.length;
}
return { written, completed, newlyDecidedLoserIds };
return { written, completed };
}
/**
@ -478,11 +455,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 +718,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 +772,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 +789,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 +839,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 +857,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 +896,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 +968,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 +1415,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 +1526,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

@ -33,33 +33,6 @@ export function roundQualifyingPoints(points: number): number {
return Math.round((points + Number.EPSILON) * 100) / 100;
}
/**
* Given a participant's awarded QP before and after a re-score, return the ids
* whose QP changed. A row's `qp` is the raw decimal string (or null when no QP
* was awarded). A participant counts as changed when their new value differs from
* the old, including a first-time score (null value). Participants absent from
* `after` are not reported (their removal is handled by the caller's recalc).
*
* Shared by the two QP re-score paths processQualifyingEvent (sibling windows,
* CS2, manual admin) and rescoreTennisBracketAndDetectChanges (tennis primary)
* so the "only announce real changes" semantics live in one place.
*/
export function diffChangedQualifyingPoints(
before: Iterable<{ id: string; qp: string | null }>,
after: Iterable<{ id: string; qp: string | null }>
): Set<string> {
const beforeQP = new Map<string, number>();
for (const r of before) {
if (r.qp !== null) beforeQP.set(r.id, parseFloat(r.qp));
}
const changed = new Set<string>();
for (const r of after) {
if (r.qp === null) continue;
if (beforeQP.get(r.id) !== parseFloat(r.qp)) changed.add(r.id);
}
return changed;
}
export function calculateSplitQualifyingPoints(
placement: number,
tieCount: number,
@ -75,6 +48,21 @@ export function calculateSplitQualifyingPoints(
return roundQualifyingPoints(totalQP / tieCount);
}
export function hasProcessedQualifyingPlacement(
results: Array<{
placement?: number | null;
qualifyingPointsAwarded?: string | null;
}>
): boolean {
return results.some(
(result) =>
result.placement !== null &&
result.placement !== undefined &&
result.qualifyingPointsAwarded !== null &&
result.qualifyingPointsAwarded !== undefined
);
}
/**
* Initialize default qualifying point configuration for a sports season
*/

View file

@ -10,20 +10,18 @@ import {
import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates";
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
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";
import {
calculateSplitQualifyingPoints,
diffChangedQualifyingPoints,
getQPConfig,
hasProcessedQualifyingPlacement,
recalculateParticipantQP,
writeEventResultsQP,
getQPStandings,
@ -114,21 +112,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 +125,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 +280,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 +352,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 +418,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 +428,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 +440,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 +507,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 +515,6 @@ export async function processMatchResult(
error
);
}
}
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
}
}
@ -991,49 +846,13 @@ export async function processQualifyingBracketEvent(
await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db);
}
/**
* Count how many results share each placement the structural tie span used to
* split QP across a tied group. Callers pass the FULL canonical field
* (tournament_results) so the span reflects the whole tournament, not one window's
* roster subset. Null placements (filler / not-participating) are ignored.
*/
export function buildTieCountByPlacement(
results: Array<{ placement: number | null }>
): Map<number, number> {
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;
}
/**
* Process a qualifying event completion and update QP totals.
* Ties in QP are handled by sharing placements (averaged points).
*/
export async function processQualifyingEvent(
eventId: string,
providedDb?: ReturnType<typeof database>,
options: {
skipNotifications?: boolean;
/**
* Pre-computed full-field tie span (placement count) from the canonical
* tournament_results. When the fan-out already loaded the canonical results it
* passes this in so we don't re-query per window. Omitted for direct callers,
* 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>;
} = {}
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
@ -1056,32 +875,10 @@ export async function processQualifyingEvent(
// Get all event results for this qualifying event
const results = await getEventResults(eventId, db);
// Snapshot awarded QP before reprocessing so the Discord notification below can
// announce only the participants whose QP actually changed. Without this, a
// sibling window re-scored on every fan-out sync (syncTournamentResults) would
// re-ping the full QP standings each run even when nothing changed.
const beforeQP = results.map((r) => ({
id: r.seasonParticipantId,
qp: r.qualifyingPointsAwarded,
}));
// Check if this was already processed (for majorsCompleted counter)
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
// Route to the bracket writer only when this window actually OWNS a bracket (has
// playoff matches). A window can carry a bracketTemplateId with no matches — e.g. a
// league window cloned from a bracket season copies the template id but not the
// matches (cloneSportsSeason) — and processQualifyingBracketEvent would derive zero
// states and write NO QP. Those windows must be scored via the placement/canonical
// path below, exactly like a no-template sibling.
let hasBracketMatches = false;
if (event.bracketTemplateId) {
const existing = await db
.select({ id: schema.playoffMatches.id })
.from(schema.playoffMatches)
.where(eq(schema.playoffMatches.scoringEventId, eventId))
.limit(1);
hasBracketMatches = existing.length > 0;
}
if (hasBracketMatches) {
// Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the
// bracket/stage writers, which assign each placement its STRUCTURAL tie span.
// Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows
@ -1106,28 +903,6 @@ export async function processQualifyingEvent(
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
);
// Full-field tie span. The number of players tied at a placement is a property
// of the whole tournament field (canonical tournament_results), NOT of who
// happens to be on THIS window's roster. Sibling/mirror windows only hold the
// draftable subset of the field, so counting the placements present locally
// (group.length) under-counts a tied group and over-awards it: tennis R16 losers
// all sit at placement 9 with a structural span of 8 → (2+2+2+2+1+1+1+1)/8 = 1.5
// QP; a window holding only 4 of them would wrongly split 4 ways → 2 QP. Deriving
// the span from canonical results keeps every window/league identical. The fan-out
// passes this map in (already loaded once per tournament); direct callers with a
// tournament link query it here. Standalone events (no tournamentId, no map) have
// no canonical field, so fall back to the live count.
const canonicalTieCountByPlacement: Map<number, number> | null =
options.canonicalTieCountByPlacement ??
(event.tournamentId
? buildTieCountByPlacement(
await db
.select({ placement: schema.tournamentResults.placement })
.from(schema.tournamentResults)
.where(eq(schema.tournamentResults.tournamentId, event.tournamentId))
)
: null);
// Group results by placement to handle ties
const placementGroups = new Map<number, typeof results>();
for (const result of results) {
@ -1140,7 +915,7 @@ export async function processQualifyingEvent(
// Process each placement group and update event_results with QP awarded
for (const [placement, group] of placementGroups) {
const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length;
const tieCount = group.length;
const qpPerParticipant = calculateSplitQualifyingPoints(
placement,
@ -1168,46 +943,21 @@ export async function processQualifyingEvent(
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
}
// NOTE: majorsCompleted is NOT a stored counter. It is derived on read via
// getMajorsCompleted() (count of completed qualifying events). Incrementing here
// per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was
// removed. See app/models/scoring-event.ts:getMajorsCompleted.
// Increment majorsCompleted counter (only if this is the first time processing)
if (!wasAlreadyProcessed) {
const sportsSeason = event.sportsSeason;
await db
.update(schema.sportsSeasons)
.set({
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
updatedAt: new Date(),
})
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
}
logger.log(
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
);
// Announce only participants whose awarded QP changed vs. the pre-reprocess
// snapshot (a differing value, or a first-time score: null → value). This keeps
// repeated fan-out syncs of an unchanged window from re-pinging the standings.
const afterRows = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, eventId),
});
const changedParticipantIds = diffChangedQualifyingPoints(
beforeQP,
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
) {
try {
await notifyQualifyingPointsUpdate(
event.sportsSeasonId,
eventId,
db,
changedParticipantIds,
eliminatedIds,
);
} catch (error) {
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
}
}
}
/**
@ -1481,7 +1231,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 +1344,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;
}
@ -2069,17 +1827,17 @@ export async function recalculateAffectedLeagues(
);
// Build scored matches for the notification.
// A match is worth announcing when:
// • the winner is owned by a manager AND earned points this round, OR
// • the loser is owned by a manager (eliminated or scored).
// A winning team that merely advanced through a non-scoring round (e.g. R32 → R16
// 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.
// A match is included only when it is "notable": winner earned points this round OR
// the loser is notifiable (eliminated / scored). A drafted winner that merely advanced
// without earning points is NOT enough on its own.
// On any included match, the winning team's owner is shown in the embed (winnerUsername),
// but Discord-pinged only when they actually scored (winnerDiscordUserId gated on
// winnerScoreChanged) — advancing-only wins don't warrant a ping.
// Losers appear if their score changed or they were definitively eliminated (finalPosition
// set, non-partial); losers who advance to another match (loserAdvances=true, e.g. NBA
// 7v8 → PIR2) are correctly suppressed.
// Only entries with at least one displayable username are kept, so scoredMatches always
// reflects exactly what will be shown in the embed.
let scoredMatches: ScoredMatch[] | undefined;
if (allCompletedMatches.length > 0) {
const relevant = allCompletedMatches.filter(
@ -2107,17 +1865,12 @@ export async function recalculateAffectedLeagues(
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId };
})
.filter((x) => (x.winnerScoreChanged && !!x.winnerOwnerId) || (x.showLoser && !!x.loserOwnerId))
.filter((x) => x.winnerScoreChanged || x.showLoser)
.map((x) => ({
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

@ -1,11 +0,0 @@
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
export function getEventTypeLabel(eventType: string): string {
switch (eventType) {
case "playoff_game": return "Bracket";
case "major_tournament": return "Major Tournament";
case "final_standings": return "Final Standings";
case "schedule_event": return "Non-Scoring";
default: return eventType;
}
}

View file

@ -3,11 +3,20 @@ import * as schema from "~/database/schema";
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
import type { BracketRegion } from "~/lib/bracket-templates";
import { recalculateAffectedLeagues } from "./scoring-calculator";
import { recalculateParticipantQP } from "./qualifying-points";
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
import { findParticipantNamesByIds } from "./season-participant";
import { deleteTournament } from "./tournament";
import type { EventType } from "./scoring-event-types";
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
export function getEventTypeLabel(eventType: string): string {
switch (eventType) {
case "playoff_game": return "Bracket";
case "major_tournament": return "Major Tournament";
case "final_standings": return "Final Standings";
case "schedule_event": return "Non-Scoring";
default: return eventType;
}
}
export interface CreateScoringEventData {
sportsSeasonId: string;
@ -144,33 +153,6 @@ export async function getQualifyingEvents(
});
}
/**
* Number of "majors completed" for a sports season, derived on read as the count of
* qualifying events that have been marked complete. Replaces the old stored
* sportsSeasons.majorsCompleted counter, which was incremented per fan-out sync and
* over-counted past totalMajors ("11 of 4"). Computing it makes the value
* self-correcting and immune to double-counting.
*/
export async function getMajorsCompleted(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<number> {
const db = providedDb || database();
const rows = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.scoringEvents)
.where(
and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isQualifyingEvent, true),
eq(schema.scoringEvents.isComplete, true)
)
);
return rows[0]?.count ?? 0;
}
/**
* Update a scoring event
*/
@ -229,55 +211,28 @@ export async function completeScoringEvent(
* sports season (placements are stored there with no FK back to the event) and
* recalculates league standings.
*/
export interface DeleteScoringEventOptions {
/**
* When this was the last window linked to its shared tournament, also delete
* the now-orphaned canonical tournament (and its results). No-op when other
* windows remain. Off by default so deleting one season's event never touches
* the shared tournament unless explicitly requested.
*/
deleteOrphanTournament?: boolean;
}
export interface DeleteScoringEventResult {
/** The shared tournament this event was linked to, if any. */
tournamentId: string | null;
/** How many other windows still link to that tournament after the delete. */
remainingWindows: number;
/** Event promoted to primary because the deleted event was the primary window. */
promotedPrimaryId: string | null;
/** Whether the orphaned canonical tournament was deleted. */
deletedTournament: boolean;
}
export async function deleteScoringEvent(
eventId: string,
providedDb?: ReturnType<typeof database>,
opts?: DeleteScoringEventOptions
): Promise<DeleteScoringEventResult> {
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
});
if (!event) {
return {
tournamentId: null,
remainingWindows: 0,
promotedPrimaryId: null,
deletedTournament: false,
};
}
if (!event) return;
// For qualifying events: capture affected participant IDs before the cascade deletes
// eventResults (which is how we know who had QP awarded from this event).
let affectedParticipantIds: string[] = [];
let wasQPProcessed = false;
if (event.isQualifyingEvent) {
const results = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, eventId),
});
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
wasQPProcessed = hasProcessedQualifyingPlacement(results);
}
await db.transaction(async (tx) => {
@ -300,49 +255,21 @@ export async function deleteScoringEvent(
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
}
// majorsCompleted is derived on read (getMajorsCompleted), so deleting the event
// — which removes it from the completed-qualifying-event count — self-corrects the
// number. No stored counter to decrement.
// Decrement majorsCompleted if this event had already been processed
if (wasQPProcessed) {
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
});
if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) {
await db
.update(schema.sportsSeasons)
.set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() })
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
}
}
await recalculateAffectedLeagues(event.sportsSeasonId, db);
}
// Shared-tournament bookkeeping: keep the primary window valid and optionally
// clean up a canonical tournament left with no windows.
let remainingWindows = 0;
let promotedPrimaryId: string | null = null;
let deletedTournament = false;
if (event.tournamentId) {
const remaining = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.tournamentId, event.tournamentId),
orderBy: asc(schema.scoringEvents.createdAt),
});
remainingWindows = remaining.length;
if (remaining.length > 0) {
// Auto-heal: if the deleted event was the primary window, promote the
// earliest remaining window so the shared major stays scorable and
// fan-out stays deterministic. Only heal when we actually removed the
// primary — golf-style majors intentionally have no primary (scored on the
// canonical tournament page), and event.isPrimary is false for them, so
// they are left untouched.
if (event.isPrimary) {
await setPrimaryEvent(remaining[0].id, db);
promotedPrimaryId = remaining[0].id;
}
} else if (opts?.deleteOrphanTournament) {
await deleteTournament(event.tournamentId, db);
deletedTournament = true;
}
}
return {
tournamentId: event.tournamentId,
remainingWindows,
promotedPrimaryId,
deletedTournament,
};
}
/**
@ -1144,24 +1071,6 @@ export async function getSportsSeasonsByTournament(tournamentId: string) {
});
}
/**
* Count the distinct sports-season windows linked to a tournament. Cheaper than
* getSportsSeasonsByTournament when only the count is needed (no relations).
*/
export async function countWindowsByTournament(
tournamentId: string,
providedDb?: ReturnType<typeof database>
): Promise<number> {
const db = providedDb || database();
const [row] = await db
.select({
count: sql<number>`count(distinct ${schema.scoringEvents.sportsSeasonId})::int`,
})
.from(schema.scoringEvents)
.where(eq(schema.scoringEvents.tournamentId, tournamentId));
return row?.count ?? 0;
}
/**
* The primary scoring event for a tournament the single window where the admin
* builds the bracket/stages and scoring happens; its results fan out to siblings.

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;
@ -311,6 +307,93 @@ export async function getParticipantSimulatorInputs(
});
}
function generatedRatingMethodSql() {
return sql`${schema.seasonParticipantSimulatorInputs.metadata}->>'ratingMethod' in ('sourceOdds', 'fallbackRating', 'averageKnown', 'worstKnownMinus')`;
}
export async function batchSaveParticipantSimulatorSourceOdds(
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
): Promise<void> {
if (inputs.length === 0) return;
const db = database();
const now = new Date();
const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))];
await db.transaction(async (tx) => {
await tx
.update(schema.seasonParticipantSimulatorInputs)
.set({
rating: null,
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
updatedAt: now,
})
.where(
and(
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
generatedRatingMethodSql()
)
);
await tx
.insert(schema.seasonParticipantSimulatorInputs)
.values(
inputs.map((input) => ({
participantId: input.participantId,
sportsSeasonId: input.sportsSeasonId,
sourceOdds: input.sourceOdds,
createdAt: now,
updatedAt: now,
}))
)
.onConflictDoUpdate({
target: [
schema.seasonParticipantSimulatorInputs.participantId,
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
],
set: {
sourceOdds: sql`excluded.source_odds`,
rating: sql`case when ${generatedRatingMethodSql()} then null else ${schema.seasonParticipantSimulatorInputs.rating} end`,
metadata: sql`case when ${generatedRatingMethodSql()} then coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' else ${schema.seasonParticipantSimulatorInputs.metadata} end`,
updatedAt: now,
},
});
});
}
export async function batchSaveFuturesOddsForSimulator(
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
): Promise<void> {
if (inputs.length === 0) return;
const db = database();
const now = new Date();
// Persist the odds non-destructively. How much these odds move a stored
// Elo/rating is now governed by the season's blend weight (see resolveSourceElos
// / SimulatorInputPolicy.oddsWeight), so we no longer null out manually entered
// Elo here — that policy decides at run time.
await db
.insert(schema.seasonParticipantSimulatorInputs)
.values(
inputs.map((input) => ({
participantId: input.participantId,
sportsSeasonId: input.sportsSeasonId,
sourceOdds: input.sourceOdds,
createdAt: now,
updatedAt: now,
}))
)
.onConflictDoUpdate({
target: [
schema.seasonParticipantSimulatorInputs.participantId,
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
],
set: {
sourceOdds: sql`excluded.source_odds`,
updatedAt: now,
},
});
}
export async function batchUpsertParticipantSimulatorInputs(
inputs: UpsertParticipantSimulatorInput[]
): Promise<void> {
@ -343,44 +426,16 @@ export async function batchUpsertParticipantSimulatorInputs(
schema.seasonParticipantSimulatorInputs.participantId,
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
],
// Non-destructive: only overwrite a column when the incoming value is
// non-null. This lets a partial import (e.g. odds-only) update just the
// columns it provides without clobbering previously stored inputs like a
// manually entered Elo or rating. Mirrors the COALESCE bridge used for the
// legacy EV table below.
set: {
sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantSimulatorInputs.sourceOdds})`,
sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantSimulatorInputs.sourceElo})`,
worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantSimulatorInputs.worldRanking})`,
rating: sql`COALESCE(excluded.rating, ${schema.seasonParticipantSimulatorInputs.rating})`,
projectedWins: sql`COALESCE(excluded.projected_wins, ${schema.seasonParticipantSimulatorInputs.projectedWins})`,
projectedTablePoints: sql`COALESCE(excluded.projected_table_points, ${schema.seasonParticipantSimulatorInputs.projectedTablePoints})`,
seed: sql`COALESCE(excluded.seed, ${schema.seasonParticipantSimulatorInputs.seed})`,
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)
- (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)`,
sourceOdds: sql`excluded.source_odds`,
sourceElo: sql`excluded.source_elo`,
worldRanking: sql`excluded.world_ranking`,
rating: sql`excluded.rating`,
projectedWins: sql`excluded.projected_wins`,
projectedTablePoints: sql`excluded.projected_table_points`,
seed: sql`excluded.seed`,
region: sql`excluded.region`,
metadata: sql`excluded.metadata`,
updatedAt: sql`excluded.updated_at`,
},
});
@ -502,19 +557,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

@ -169,57 +169,6 @@ export async function findDraftableSportsSeasonBySportId(sportId: string) {
});
}
export type DraftScheduleWindow = {
id: string;
name: string;
year: number;
status: SportsSeasonStatus;
draftOn: string;
draftOff: string;
sport: {
id: string;
name: string;
slug: string;
iconUrl: string | null;
};
};
/**
* Returns admin sport-seasons (fantasySeasonId IS NULL) whose draft window
* [draftOn, draftOff] overlaps the horizon [today, today + monthsAhead]. Uses an
* overlap test (not containment) so windows already open, or spanning the horizon
* edges, are still included. Powers the admin draft-schedule Gantt chart.
*/
export async function findDraftScheduleForHorizon(
monthsAhead: number
): Promise<DraftScheduleWindow[]> {
const db = database();
const horizonEnd = sql`CURRENT_DATE + make_interval(months => ${monthsAhead})`;
const seasons = await db.query.sportsSeasons.findMany({
where: (ss, { and }) =>
and(
isNull(ss.fantasySeasonId),
gte(ss.draftOff, sql`CURRENT_DATE`),
lte(ss.draftOn, horizonEnd)
),
orderBy: (sportsSeasons, { asc }) => [asc(sportsSeasons.draftOn)],
with: {
sport: {
columns: { id: true, name: true, slug: true, iconUrl: true },
},
},
});
return seasons.map((s) => ({
id: s.id,
name: s.name,
year: s.year,
status: s.status,
draftOn: s.draftOn,
draftOff: s.draftOff,
sport: s.sport,
})) as DraftScheduleWindow[];
}
export async function updateSportsSeason(
id: string,
data: Partial<NewSportsSeason>

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

@ -136,17 +136,3 @@ export async function updateTournamentStatus(
.returning();
return tournament;
}
/**
* Delete a canonical tournament. The DB cascades remove its tournament_results;
* any surviving scoring_events have their tournamentId set to NULL (onDelete:
* "set null"). Callers should only use this once the last linked window has been
* removed, so no scoring events are left pointing at it.
*/
export async function deleteTournament(
id: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
await db.delete(schema.tournaments).where(eq(schema.tournaments.id, id));
}

View file

@ -96,7 +96,6 @@ export default [
route("simulators", "routes/admin.simulators.tsx"),
route("sports/new", "routes/admin.sports.new.tsx"),
route("sports/:id", "routes/admin.sports.$id.tsx"),
route("draft-schedule", "routes/admin.draft-schedule.tsx"),
route("sports-seasons", "routes/admin.sports-seasons.tsx"),
route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"),
route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"),

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

@ -1,123 +0,0 @@
import { Link } from "react-router";
import type { Route } from "./+types/admin.draft-schedule";
import { findAllSports } from "~/models/sport";
import { findDraftScheduleForHorizon } from "~/models/sports-season";
import {
DraftScheduleGantt,
type GanttSport,
} from "~/components/admin/DraftScheduleGantt";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { AlertTriangle, ArrowRight } from "lucide-react";
const ALLOWED_MONTHS = [6, 12] as const;
export function meta(): Route.MetaDescriptors {
return [{ title: "Draft Schedule - Brackt" }];
}
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const monthsParam = Number(url.searchParams.get("months"));
const months = ALLOWED_MONTHS.includes(monthsParam as (typeof ALLOWED_MONTHS)[number])
? monthsParam
: 6;
const [allSports, windows] = await Promise.all([
findAllSports(),
findDraftScheduleForHorizon(months),
]);
const windowsBySport = new Map<string, typeof windows>();
for (const w of windows) {
const list = windowsBySport.get(w.sport.id) ?? [];
list.push(w);
windowsBySport.set(w.sport.id, list);
}
const sports: GanttSport[] = allSports.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
iconUrl: s.iconUrl,
windows: windowsBySport.get(s.id) ?? [],
}));
const sportsWithoutCoverage = sports.filter((s) => s.windows.length === 0);
const today = new Date().toISOString().split("T")[0];
return { sports, sportsWithoutCoverage, today, months };
}
export default function AdminDraftSchedule({ loaderData }: Route.ComponentProps) {
const { sports, sportsWithoutCoverage, today, months } = loaderData;
return (
<div className="p-4 md:p-8">
<div className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Draft Schedule</h1>
<p className="text-muted-foreground mt-2">
See when each sport is scheduled to be drafted and spot gaps in coverage.
</p>
</div>
<div className="flex items-center gap-1 rounded-md border p-1">
{ALLOWED_MONTHS.map((m) => (
<Button
key={m}
variant={m === months ? "default" : "ghost"}
size="sm"
asChild
>
<Link to={`?months=${m}`}>{m} months</Link>
</Button>
))}
</div>
</div>
{sportsWithoutCoverage.length > 0 && (
<Card className="mb-6 border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-amber-800 dark:text-amber-300">
<AlertTriangle className="h-5 w-5" />
Sports without a draft window
</CardTitle>
<CardDescription className="text-amber-700 dark:text-amber-400/80">
{sportsWithoutCoverage.length} sport
{sportsWithoutCoverage.length !== 1 ? "s" : ""}{" "}
{sportsWithoutCoverage.length !== 1 ? "have" : "has"} no draft window
open or upcoming in the next {months} months.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{sportsWithoutCoverage.map((s) => (
<li
key={s.id}
className="flex items-center justify-between gap-3 rounded-md border border-amber-200 bg-background/60 px-3 py-2 dark:border-amber-900"
>
<span className="text-sm font-medium">{s.name}</span>
<Button variant="outline" size="sm" asChild className="h-7 text-xs">
<Link to="/admin/sports-seasons/new">
Schedule a season
<ArrowRight className="ml-1 h-3 w-3" />
</Link>
</Button>
</li>
))}
</ul>
</CardContent>
</Card>
)}
<DraftScheduleGantt sports={sports} today={today} months={months} />
</div>
);
}

View file

@ -271,6 +271,11 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
Setup
</Link>
</Button>
{sim.supportsFuturesOdds && (
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/futures-odds`}>Futures</Link>
</Button>
)}
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link>
</Button>

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 {
@ -1154,11 +882,9 @@ export async function action({ request, params }: Route.ActionArgs) {
// fantasy placements come from finalizeQualifyingPoints across all of them.
if (event.isQualifyingEvent) {
const db = database();
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent)
// and recalcs participant QP totals. majorsCompleted is derived on read from
// completed qualifying events (see getMajorsCompleted) — marking this event
// complete below is what advances it. Season-wide fantasy finalization stays with
// finalizeQualifyingPoints across all majors.
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
// increments majorsCompleted, and recalcs participant QP totals. Season-wide
// fantasy finalization stays with finalizeQualifyingPoints across all majors.
await processQualifyingEvent(params.eventId, db);
await db
.update(schema.scoringEvents)
@ -1296,10 +1022,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

@ -21,7 +21,7 @@ import {
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets, Save } from "lucide-react";
import { getEventTypeLabel } from "~/models/scoring-event-types";
import { getEventTypeLabel } from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
import {
Table,

View file

@ -8,8 +8,6 @@ import {
deleteScoringEvent,
bulkCreateScoringEvents,
ensurePrimaryEvent,
countWindowsByTournament,
getMajorsCompleted,
type CreateScoringEventData,
} from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
@ -29,14 +27,10 @@ export async function loader({ params }: Route.LoaderArgs) {
const events = await getScoringEventsForSportsSeason(params.id);
// For qualifying sports seasons, get QP standings with global ranks attached.
// majorsCompleted is derived on read (count of completed qualifying events), not a
// stored counter — see getMajorsCompleted.
// For qualifying sports seasons, get QP standings with global ranks attached
let qpStandings = null;
const scoringRules = null;
let majorsCompleted = 0;
if (sportsSeason.scoringPattern === "qualifying_points") {
majorsCompleted = await getMajorsCompleted(params.id);
const standings = await getQPStandings(params.id);
let prevQP = -1;
let prevRankStart = 1;
@ -65,38 +59,11 @@ export async function loader({ params }: Route.LoaderArgs) {
);
const availableTournaments = allTournamentsForSport.filter((t) => !linkedTournamentIds.has(t.id));
// Shared-tournament context for the delete confirmation dialog: for each event
// linked to a canonical tournament, how many OTHER seasons ("windows") also use
// it, and the tournament's name. Lets the UI explain exactly what a delete does.
const sharedInfo = new Map<string, { name: string; windowCount: number }>();
await Promise.all(
[...linkedTournamentIds].map(async (tid) => {
const cached = allTournamentsForSport.find((t) => t.id === tid);
const [tournament, windowCount] = await Promise.all([
cached ?? getTournamentById(tid),
countWindowsByTournament(tid),
]);
sharedInfo.set(tid, {
name: tournament?.name ?? "shared tournament",
windowCount,
});
})
);
const eventsWithSharing = events.map((e) => {
const info = e.tournamentId ? sharedInfo.get(e.tournamentId) : null;
return {
...e,
tournamentName: info?.name ?? null,
otherWindowCount: info ? Math.max(0, info.windowCount - 1) : 0,
};
});
return {
sportsSeason: { ...sportsSeason, majorsCompleted } as typeof sportsSeason & {
sportsSeason: sportsSeason as typeof sportsSeason & {
sport: { id: string; name: string; type: string; slug: string };
},
events: eventsWithSharing,
events,
qpStandings,
scoringRules,
availableTournaments,
@ -248,19 +215,8 @@ export async function action({ request, params }: Route.ActionArgs) {
}
try {
const result = await deleteScoringEvent(eventId, undefined, {
deleteOrphanTournament: formData.get("deleteTournament") === "1",
});
let success = "Event deleted";
if (result.deletedTournament) {
success = "Event deleted and its shared tournament removed";
} else if (result.tournamentId && result.remainingWindows > 0) {
success = `Event removed from this season (shared tournament kept — still used by ${result.remainingWindows} season${result.remainingWindows !== 1 ? "s" : ""})`;
if (result.promotedPrimaryId) {
success += "; another season was promoted to primary";
}
}
return { success };
await deleteScoringEvent(eventId);
return { success: "Event deleted successfully" };
} catch (error) {
logger.error("Error deleting event:", error);
return { error: "Failed to delete event" };

View file

@ -37,7 +37,7 @@ import {
import { Calendar, Trophy, ArrowLeft, Trash2, ListPlus } from "lucide-react";
import { format, parseISO } from "date-fns";
import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings";
import { getEventTypeLabel } from "~/models/scoring-event-types";
import { getEventTypeLabel } from "~/models/scoring-event";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Events — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
@ -305,11 +305,7 @@ export default function SportsSeasonEvents({
</p>
) : (
<div className="space-y-3">
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean; tournamentId: string | null; isPrimary: boolean; tournamentName: string | null; otherWindowCount: number }) => {
const isLinked = !!event.tournamentId;
const hasOtherWindows = event.otherWindowCount > 0;
const isLastWindow = isLinked && !hasOtherWindows;
return (
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean }) => (
<Card key={event.id} className="hover:border-primary/50 transition-colors">
<CardContent className="pt-6">
<div className="flex items-start justify-between gap-4">
@ -356,65 +352,28 @@ export default function SportsSeasonEvents({
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
<AlertDialogDescription>
This will also delete all results for this event. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Form method="post">
<input type="hidden" name="intent" value="delete-event" />
<input type="hidden" name="eventId" value={event.id} />
<AlertDialogHeader>
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
<AlertDialogDescription asChild>
{!isLinked ? (
<span>
This will also delete all results for this event. This action cannot be undone.
</span>
) : hasOtherWindows ? (
<span>
This event is part of the shared tournament{" "}
<span className="font-medium">"{event.tournamentName}"</span>, also used by{" "}
<span className="font-medium">
{event.otherWindowCount} other season{event.otherWindowCount !== 1 ? "s" : ""}
</span>
. Deleting removes it from <span className="font-medium">this season only</span> the
tournament and the other seasons keep their data.
{event.isPrimary && (
<> This is the primary scoring window, so another season will be promoted to primary automatically.</>
)}
</span>
) : (
<span>
This is the only season linked to the shared tournament{" "}
<span className="font-medium">"{event.tournamentName}"</span>. Deleting removes this
event and its results. This action cannot be undone.
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
{isLastWindow && (
<label className="flex items-start gap-2 text-sm my-2 cursor-pointer">
<input
type="checkbox"
name="deleteTournament"
value="1"
className="mt-0.5 h-4 w-4 rounded border-input"
/>
<span>
Also delete the shared tournament and its recorded results
</span>
</label>
)}
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
);
})}
))}
</div>
)}
</CardContent>

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

@ -1,9 +1,394 @@
import { redirect } from 'react-router';
import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router';
import type { Route } from './+types/admin.sports-seasons.$id.futures-odds';
// Futures odds entry has been consolidated into the Bulk Simulator Inputs card on
// the simulator setup page (paste sportsbook lines like `Chiefs +450` or a CSV with
// a sourceOdds column). This route now just redirects old bookmarks/links there.
export async function loader({ params }: Route.LoaderArgs) {
return redirect(`/admin/sports-seasons/${params.id}/simulator`);
import { logger } from '~/lib/logger';
import { findSportsSeasonById } from '~/models/sports-season';
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
import { getAllParticipantEVsForSeason, batchSaveSourceOdds, clearSourceOddsForParticipants } from '~/models/participant-expected-value';
import { batchSaveFuturesOddsForSimulator } from '~/models/simulator';
import { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { Textarea } from '~/components/ui/textarea';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '~/components/ui/card';
import { useState } from 'react';
import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react';
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Futures Odds — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeasonId = params.id;
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
throw new Response('Sports season not found', { status: 404 });
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
// Show any saved odds regardless of what simulation ran afterwards
const existingOdds = new Map(
existingEVs
.filter(ev => ev.sourceOdds !== null)
.map(ev => [ev.participantId, ev.sourceOdds])
);
return {
sportsSeason,
participants,
existingOdds,
};
}
interface ActionData {
success?: boolean;
message?: string;
}
export async function action({ request, params }: Route.ActionArgs) {
const sportsSeasonId = params.id;
const formData = await request.formData();
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
return { success: false, message: 'Sports season not found' };
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
// Parse odds from form
const futuresOdds: Array<{ participantId: string; odds: number; name: string }> = [];
for (const participant of participants) {
const oddsValue = formData.get(`odds_${participant.id}`) as string;
if (oddsValue && oddsValue.trim() !== '') {
const odds = Number(oddsValue);
if (!isNaN(odds)) {
futuresOdds.push({
participantId: participant.id,
odds,
name: participant.name,
});
}
}
}
if (futuresOdds.length === 0) {
return { success: false, message: 'Please enter odds for at least one participant' };
}
if (!sportsSeason.sport?.simulatorType) {
return { success: false, message: 'This sport has no simulator type configured. Set one on the sport in the admin panel.' };
}
if (sportsSeason.simulationStatus === 'running') {
return { success: false, message: 'A simulation is already running. Please wait.' };
}
const shouldClearExisting = formData.get('clearExisting') === '1';
try {
const oddsInputs = futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds }));
// Save to legacy table (EV-page display), then clear all ratings and save
// sourceOdds to the simulator inputs table so odds always drive the run.
// Sequential: batchSaveFuturesOddsForSimulator must win on the inputs table.
await batchSaveSourceOdds(oddsInputs);
await batchSaveFuturesOddsForSimulator(oddsInputs);
if (shouldClearExisting) {
const keptIds = new Set(futuresOdds.map(f => f.participantId));
const clearedIds = participants.filter(p => !keptIds.has(p.id)).map(p => p.id);
await clearSourceOddsForParticipants(sportsSeasonId, clearedIds);
}
await runSportsSeasonSimulation(sportsSeasonId);
} catch (error) {
logger.error('Error running simulation:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Simulation failed',
};
}
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
}
function normalizeName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
}
export default function AdminSportsSeasonFuturesOdds() {
const { sportsSeason, participants, existingOdds } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const navigation = useNavigation();
// Initialize odds values from existing data
const [oddsValues, setOddsValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
participants.forEach(p => {
const existingOdd = existingOdds.get(p.id);
if (existingOdd !== undefined && existingOdd !== null) {
initial[p.id] = existingOdd.toString();
}
});
return initial;
});
// Bulk import state
const [clearExisting, setClearExisting] = useState(false);
const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<{
matched: Array<{ participantId: string; name: string; odds: number; inputName: string }>;
unmatched: Array<{ inputName: string; odds: number }>;
} | null>(null);
function findParticipantMatch(inputName: string) {
const normalizedInput = normalizeName(inputName);
const normalized = participants.map(p => ({ p, n: normalizeName(p.name) }));
const exact = normalized.find(({ n }) => n === normalizedInput);
if (exact) return exact.p;
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
if (contains) return contains.p;
const inputWords = normalizedInput.split(' ').filter(w => w.length > 2);
const overlap = normalized.find(({ n }) => {
const pWords = n.split(' ').filter(w => w.length > 2);
const shared = inputWords.filter(w => pWords.includes(w));
return shared.length > 0 && shared.length >= Math.min(inputWords.length, pWords.length) * 0.5;
});
return overlap?.p ?? null;
}
function parseBulkText() {
const lines = bulkText.split('\n');
const oddsPattern = /^(.+?)\s+([+-]\d{2,6})\s*$/;
const matched: Array<{ participantId: string; name: string; odds: number; inputName: string }> = [];
const unmatched: Array<{ inputName: string; odds: number }> = [];
const seenParticipants = new Set<string>();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.length < 3) continue;
const match = oddsPattern.exec(trimmed);
if (!match) continue;
const inputName = match[1].trim();
const odds = parseInt(match[2], 10);
if (isNaN(odds)) continue;
const participant = findParticipantMatch(inputName);
if (participant && !seenParticipants.has(participant.id)) {
seenParticipants.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, odds, inputName });
} else if (!participant) {
unmatched.push({ inputName, odds });
}
}
setParseResults({ matched, unmatched });
}
function applyMatches() {
if (!parseResults) return;
const newOdds = clearExisting ? {} : { ...oddsValues };
for (const m of parseResults.matched) {
newOdds[m.participantId] = m.odds.toString();
}
setOddsValues(newOdds);
setParseResults(null);
setBulkText('');
setClearExisting(false);
}
const isSubmitting = navigation.state === 'submitting';
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Futures Odds Entry</h1>
<p className="text-muted-foreground">
{sportsSeason.sport.name} - {sportsSeason.name}
</p>
</div>
{/* Bulk Import */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Bulk Import</CardTitle>
<CardDescription>
Paste odds from FanDuel, DraftKings, OddsChecker, or any site. Each line should end with
American odds (e.g. <code>Kansas City Chiefs +450</code>). Team names are fuzzy-matched to
participants.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder={`Paste odds here, one team per line:\nKansas City Chiefs +450\nSan Francisco 49ers +600\nBaltimore Ravens +700`}
value={bulkText}
onChange={e => { setBulkText(e.target.value); setParseResults(null); }}
rows={8}
className="font-mono text-sm"
/>
<div className="flex items-center gap-4">
<Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}>
Parse Odds
</Button>
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input
type="checkbox"
checked={clearExisting}
onChange={e => setClearExisting(e.target.checked)}
className="h-4 w-4"
/>
Clear existing odds not in this import
</label>
</div>
{parseResults && (
<div className="space-y-3">
{parseResults.matched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 mb-2">
<CheckCircle2 className="h-4 w-4" />
Matched ({parseResults.matched.length})
</div>
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 divide-y divide-emerald-500/20 text-sm">
{parseResults.matched.map(m => (
<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.odds > 0 ? '+' : ''}{m.odds}
</span>
</div>
))}
</div>
</div>
)}
{parseResults.unmatched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-2">
<AlertCircle className="h-4 w-4" />
Not matched ({parseResults.unmatched.length}) enter manually below
</div>
<div className="rounded-md border border-amber-200 bg-amber-50 divide-y divide-amber-100 text-sm">
{parseResults.unmatched.map((u) => (
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
<span>{u.inputName}</span>
<span className="font-medium">{u.odds > 0 ? '+' : ''}{u.odds}</span>
</div>
))}
</div>
</div>
)}
{parseResults.matched.length === 0 && parseResults.unmatched.length === 0 && (
<p className="text-sm text-muted-foreground">No odds found. Make sure each line ends with a value like +450 or -200.</p>
)}
{parseResults.matched.length > 0 && (
<Button type="button" onClick={applyMatches}>
Apply {parseResults.matched.length} matched odds to form
</Button>
)}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<div>
<Card>
<CardHeader>
<CardTitle>Championship Futures Odds</CardTitle>
<CardDescription>
Enter American odds (e.g., +550, -200) for each participant's championship probability.
Enter American odds, then run the ICM simulation to compute and save probability distributions.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="clearExisting" value={clearExisting ? "1" : ""} />
<div className="space-y-3">
{participants.map((participant) => (
<div key={participant.id} className="grid grid-cols-2 gap-4 items-center">
<Label htmlFor={`odds_${participant.id}`}>{participant.name}</Label>
<Input
type="number"
id={`odds_${participant.id}`}
name={`odds_${participant.id}`}
placeholder="+550"
value={oddsValues[participant.id] ?? ''}
onChange={(e) =>
setOddsValues((prev) => ({
...prev,
[participant.id]: e.target.value,
}))
}
/>
</div>
))}
</div>
<div className="bg-muted p-4 rounded-lg space-y-2">
<div className="flex items-center gap-2 font-medium">
<Info className="h-4 w-4" />
ICM Calculation
</div>
<div className="space-y-1 text-sm">
<div>Uses Independent Chip Model from poker tournaments</div>
<div>Works with any number of participants</div>
<div className="text-xs text-muted-foreground mt-2">
Every team gets probabilities for 1st-8th place, even longshots.
</div>
</div>
</div>
{actionData && !actionData.success && actionData.message && (
<div className="text-sm text-destructive">{actionData.message}</div>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSubmitting ? 'Running...' : 'Run Simulation'}
</Button>
</Form>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle>How It Works</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-2">
<ol className="list-decimal list-inside space-y-2">
<li>Converts American odds to championship win probabilities</li>
<li>Removes bookmaker vig (normalizes to 100%)</li>
<li>Uses ICM algorithm to distribute probabilities across all placements</li>
<li>Generates probability distribution (1st through 8th place) for ALL participants</li>
<li>Even teams with +100000 odds get non-zero probabilities</li>
</ol>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

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,44 +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
* never shows a control that silently does nothing bespoke per-sim constants
* (e.g. homeFieldElo, eloDivisor, srsEloScale, raceNoise) that live in a profile
* but are not read from config stay editable only via the raw-JSON escape hatch.
*/
const HONORED_ENGINE_KNOBS = new Set([
"iterations",
"parityFactor",
"seasonGames",
"overtimeRate",
"matchParityFactor",
"averageOpponentElo",
"baseDrawRate",
"drawDecay",
"ratingScaleFactor",
"projectedWinsWeight",
]);
function parseOptionalNumber(value: string | undefined): number | null {
if (value === undefined || value.trim() === "") return null;
const parsed = Number(value);
@ -226,39 +110,6 @@ function findParticipantId(name: string, participants: Array<{ id: string; name:
);
}
const ODDS_LINE_PATTERN = /^(.+?)\s+([+-]\d{2,6})\s*$/;
function parseOddsLines(
lines: string[],
sportsSeasonId: string,
participants: Array<{ id: string; name: string }>
): { inputs: UpsertParticipantSimulatorInput[]; unmatched: string[] } {
const inputs: UpsertParticipantSimulatorInput[] = [];
const unmatched: string[] = [];
const seen = new Set<string>();
for (const line of lines) {
const match = ODDS_LINE_PATTERN.exec(line);
if (!match) continue;
const name = match[1].trim();
const sourceOdds = Number(match[2]);
if (!Number.isFinite(sourceOdds)) continue;
const participantId = findParticipantId(name, participants);
if (!participantId) {
unmatched.push(name);
continue;
}
if (seen.has(participantId)) continue;
seen.add(participantId);
inputs.push({ participantId, sportsSeasonId, sourceOdds });
}
return { inputs, unmatched };
}
function parseInputCsv(
text: string,
sportsSeasonId: string,
@ -271,10 +122,7 @@ function parseInputCsv(
const indexes = new Map(header.map((value, index) => [value, index]));
const nameIndex = indexes.get("name");
if (nameIndex === undefined) {
// No CSV header — treat the paste as sportsbook futures odds, one team per
// line ending in American odds (e.g. `Kansas City Chiefs +450`). This is the
// friendly bulk-futures path; team names are fuzzy-matched to participants.
return parseOddsLines(lines, sportsSeasonId, participants);
throw new Error("Bulk input CSV must include a `name` column.");
}
const inputs: UpsertParticipantSimulatorInput[] = [];
@ -291,28 +139,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),
});
}
@ -359,30 +196,19 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
}
}
if (intent === "save-configuration") {
if (intent === "save-input-policy") {
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
if (!currentConfig) return { success: false, message: "Simulator config not found." };
// Start from the current merged config so keys not exposed as structured
// fields (e.g. string knobs) are preserved untouched.
const next: Record<string, unknown> = { ...currentConfig.config };
// Engine knobs: every numeric field rendered as `engine.<key>`.
for (const [field, value] of formData.entries()) {
if (typeof value !== "string" || !field.startsWith("engine.")) continue;
const key = field.slice("engine.".length);
const parsed = Number(value);
if (value.trim() !== "" && Number.isFinite(parsed)) next[key] = parsed;
}
// Input-derivation policy (only when the simulator consumes Elo/ratings).
if (formData.get("hasInputPolicy") === "1") {
const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
next.inputPolicy = {
...currentPolicy,
await upsertSportsSeasonSimulatorConfig({
sportsSeasonId,
simulatorType: currentConfig.simulatorType,
config: {
...currentConfig.config,
inputPolicy: {
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),
@ -393,15 +219,10 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
};
}
await upsertSportsSeasonSimulatorConfig({
sportsSeasonId,
simulatorType: currentConfig.simulatorType,
config: next,
},
},
});
return { success: true, message: "Simulator configuration saved." };
return { success: true, message: "Simulator input policy saved." };
}
if (intent === "save-inputs") {
@ -420,26 +241,7 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
const suffix = parsed.unmatched.length > 0
? ` ${parsed.unmatched.length} row(s) were unmatched: ${parsed.unmatched.join(", ")}.`
: "";
const saved = `Saved ${parsed.inputs.length} simulator input row(s).${suffix}`;
// Auto-run the simulation so saved inputs immediately drive the standings.
// The save itself already succeeded, so a run that never started (e.g.
// participants missing required inputs) is reported as success with the
// readiness gap — never as a failed save.
try {
await runSportsSeasonSimulation(sportsSeasonId);
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
} catch (runError) {
const reason = runError instanceof Error ? runError.message : "could not run.";
// Distinguish "saved but never ran" (readiness/already-running) from
// "started running and failed mid-run": the runner only flips the season
// to status 'failed' once the simulation itself throws.
const season = await findSportsSeasonById(sportsSeasonId);
if (season?.simulationStatus === "failed") {
return { success: false, message: `${saved} The simulation failed: ${reason}` };
}
return { success: true, message: `${saved} Simulation not run yet: ${reason}` };
}
return { success: true, message: `Saved ${parsed.inputs.length} simulator input row(s).${suffix}` };
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to save simulator inputs." };
}
@ -455,63 +257,10 @@ 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");
// Structured engine knobs: every top-level numeric config key (inputPolicy is a
// nested object edited in its own section). Driving the fields from the merged
// config means each simulator shows exactly the knobs it actually reads.
const engineEntries = Object.entries(config.config)
.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">
@ -585,7 +334,8 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
)}
<div className="flex flex-wrap gap-2">
{setupSections.includes("eloRatings") &&<Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
{setupSections.includes("futuresOdds") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/futures-odds`}>Futures Odds</Link></Button>}
{setupSections.includes("eloRatings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
{setupSections.includes("surfaceElo") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/surface-elo`}>Surface Elo</Link></Button>}
{setupSections.includes("golfSkills") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/golf-skills`}>Golf Skills</Link></Button>}
{setupSections.includes("regularStandings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/regular-standings`}>Regular Standings</Link></Button>}
@ -603,55 +353,20 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</CardContent>
</Card>
{showsInputPolicy && (
<Card>
<CardHeader>
<CardTitle>Simulator Configuration</CardTitle>
<CardTitle>Input Policy</CardTitle>
<CardDescription>
One place for this season's settings. <strong>Engine</strong> controls how the Monte Carlo
runs; <strong>Input derivation</strong> controls how raw inputs become the single Elo/rating
the engine consumes. Defaults come from the simulator profile; values set here override them
for this season only. Both sections write the same stored config.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Form method="post" className="space-y-6">
<input type="hidden" name="intent" value="save-configuration" />
{showsInputPolicy && <input type="hidden" name="hasInputPolicy" value="1" />}
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold">Engine</h3>
<p className="text-xs text-muted-foreground">
How the simulation runs. A higher <code>parityFactor</code> flattens the finish-position
distribution (favorites win less often); <code>iterations</code> trades speed for precision.
</p>
</div>
{engineEntries.length > 0 ? (
<div className="grid gap-4 md:grid-cols-3">
{engineEntries.map(([key, value]) => (
<div key={key} className="space-y-2">
<Label htmlFor={`engine.${key}`} className="font-mono text-xs">{key}</Label>
<Input id={`engine.${key}`} name={`engine.${key}`} type="number" step="any" defaultValue={value} />
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">This simulator exposes no numeric engine knobs.</p>
)}
</section>
{showsInputPolicy && (
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold">Input derivation</h3>
<p className="text-xs text-muted-foreground">
Direct inputs win. This simulator can derive Elo from{" "}
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"}
{ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}.
Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
</p>
</div>
<div className="grid gap-4 md:grid-cols-5">
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="grid gap-4 md:grid-cols-5">
<input type="hidden" name="intent" value="save-input-policy" />
<div className="space-y-2 md:col-span-5">
<Label htmlFor="oddsWeight">Futures vs. Elo Odds Blend Weight (01)</Label>
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
@ -659,30 +374,9 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into
the single Elo that feeds the simulator. This is the weight given to futures odds:
<strong> 0</strong> = Elo / projections only, <strong>1</strong> = futures fully override,
in between = blend (e.g. 0.3 = 70% Elo / 30% futures). Odds enter the engine only through
this Elo they are not blended again per game.
in between = blend (e.g. 0.3 = 70% Elo / 30% futures).
</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">
@ -751,19 +445,26 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
<Label htmlFor="ratingMax">Rating Ceiling</Label>
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
</div>
</div>
</section>
)}
<div className="md:col-span-5">
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
Save Configuration
Save Input Policy
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
<details>
<summary className="cursor-pointer text-sm font-medium">Advanced: edit raw config JSON</summary>
<Form method="post" className="space-y-3 mt-3">
<Card>
<CardHeader>
<CardTitle>Season Config</CardTitle>
<CardDescription>
JSON overrides for this specific sports season. Defaults come from the simulator profile.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="save-config" />
<Textarea
name="config"
@ -771,16 +472,11 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
className="font-mono text-sm"
defaultValue={JSON.stringify(config.config, null, 2)}
/>
<p className="text-xs text-muted-foreground">
Power-user escape hatch for keys not shown above. This is the same stored config the fields
edit; saving here writes the whole object.
</p>
<Button type="submit" variant="outline" disabled={isSubmitting}>
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
Save Raw JSON
Save Config
</Button>
</Form>
</details>
</CardContent>
</Card>
@ -788,12 +484,8 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
<CardHeader>
<CardTitle>Bulk Simulator Inputs</CardTitle>
<CardDescription>
Two ways to paste, then the simulation re-runs automatically on save:
<strong> CSV</strong> with a header row supported columns: name, sourceElo, sourceOdds,
worldRanking, rating, projectedWins, projectedTablePoints, seed, region (values must not
contain commas); or <strong>futures odds</strong>, one team per line ending in American
odds (e.g. <code>Kansas City Chiefs +450</code>). Team names are fuzzy-matched to
participants. A partial paste only updates the columns it includes other inputs are kept.
Paste CSV with a header row. Supported columns: name, sourceElo, sourceOdds, worldRanking, rating,
projectedWins, projectedTablePoints, seed, region. Values must not contain commas.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -803,7 +495,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
name="bulkInputs"
rows={8}
className="font-mono text-sm"
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5\n\n— or paste futures odds only —\n${inputRows[0]?.participant.name ?? "Team Name"} +2500`}
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5`}
/>
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
@ -811,153 +503,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

@ -675,6 +675,14 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
<Calculator className="mr-2 h-4 w-4" />
Simulator Setup
</Button>
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/futures-odds`)}
>
<Calculator className="mr-2 h-4 w-4" />
Futures Odds
</Button>
<Button
size="sm"
variant="outline"

View file

@ -21,7 +21,6 @@ import {
getSportsSeasonsByTournament,
getPrimaryEventForTournament,
setPrimaryEvent,
deleteScoringEvent,
} from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
import {
@ -31,17 +30,6 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
@ -226,26 +214,6 @@ export async function action(args: Route.ActionArgs) {
}
}
if (intent === "unlink-window") {
const eventId = formData.get("eventId");
if (typeof eventId !== "string" || !eventId) {
return { success: false as const, error: "Event ID is required", syncReport: null };
}
try {
// Remove that season's scoring event. The tournament stays (we're on its
// page); if this was the primary window, another is auto-promoted.
await deleteScoringEvent(eventId);
return { success: true as const, error: null, syncReport: null };
} catch (error) {
logger.error("unlink-window failed:", error);
return {
success: false as const,
error: error instanceof Error ? error.message : "Failed to remove season",
syncReport: null,
};
}
}
return {
success: false as const,
error: "Invalid intent",
@ -268,7 +236,6 @@ export default function AdminTournamentDetail({
} = loaderData;
const retryFetcher = useFetcher<typeof action>();
const primaryFetcher = useFetcher<typeof action>();
const unlinkFetcher = useFetcher<typeof action>();
// For bracket majors, scoring lives on the primary window's bracket/stage UI.
const primaryScoringHref =
@ -452,51 +419,6 @@ export default function AdminTournamentDetail({
>
{link.sportsSeason.status}
</Badge>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={unlinkFetcher.state !== "idle"}
>
Remove
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove {link.sportsSeason.sport.name} - {link.sportsSeason.name}?
</AlertDialogTitle>
<AlertDialogDescription asChild>
<span>
This deletes that season's scoring event (and its results) but keeps{" "}
<span className="font-medium">{tournament.name}</span> and the other linked seasons.
{link.isPrimary && linkedSportsSeasons.length > 1 && (
<> Because this is the primary scoring window, another season will be promoted to primary automatically.</>
)}
{linkedSportsSeasons.length === 1 && (
<> This is the only linked season the tournament will remain but have no windows until you link one again.</>
)}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<unlinkFetcher.Form method="post">
<input type="hidden" name="intent" value="unlink-window" />
<input type="hidden" name="eventId" value={link.id} />
<AlertDialogAction
type="submit"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</unlinkFetcher.Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}

View file

@ -23,7 +23,6 @@ import {
Users,
Menu,
Shield,
CalendarRange,
} from "lucide-react";
export function meta(): Route.MetaDescriptors {
@ -73,12 +72,6 @@ function AdminNavLinks({ onNavigate }: { onNavigate?: () => void }) {
Sports Seasons
</Link>
</Button>
<Button variant="ghost" className="w-full justify-start" asChild onClick={onNavigate}>
<Link to="/admin/draft-schedule">
<CalendarRange className="mr-2 h-4 w-4" />
Draft Schedule
</Link>
</Button>
<Button variant="ghost" className="w-full justify-start" asChild onClick={onNavigate}>
<Link to="/admin/simulators">
<Activity className="mr-2 h-4 w-4" />

View file

@ -14,7 +14,6 @@ import { getQPStandings } from "~/models/qualifying-points";
import {
getUpcomingScoringEvents,
getRecentCompletedEvents,
getMajorsCompleted,
} from "~/models/scoring-event";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
@ -158,7 +157,6 @@ export async function loader(args: Route.LoaderArgs) {
let seasonStandings: SeasonStanding[] = [];
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number] & { globalRank: number };
let qpStandings: QPStanding[] = [];
let majorsCompleted = 0;
// Group standings for group-stage events (e.g. FIFA World Cup)
type RawGroupMatch = Awaited<ReturnType<typeof findMatchesByGroupIds>> extends Map<string, Array<infer T>> ? T : never;
@ -295,9 +293,6 @@ export async function loader(args: Route.LoaderArgs) {
},
}));
} else if (scoringPattern === "qualifying_points") {
// majorsCompleted is derived on read (count of completed qualifying events), not a
// stored counter — see getMajorsCompleted.
majorsCompleted = await getMajorsCompleted(sportsSeasonId);
const standings = await getQPStandings(sportsSeasonId);
// Compute global ranks with tie handling across the full field before filtering,
// so the displayed rank numbers remain correct after undrafted/zero-QP rows are removed.
@ -346,7 +341,7 @@ export async function loader(args: Route.LoaderArgs) {
return {
league,
season,
sportsSeason: { ...sportsSeason, majorsCompleted },
sportsSeason,
scoringPattern,
playoffMatches,
playoffRounds,

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification, sendQualifyingPointsUpdateNotification } from "../discord";
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification } from "../discord";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
@ -186,41 +186,10 @@ 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.
// scoring-calculator.ts controls whether to include the match; discord.ts just renders.
it("shows winner's owner even when winner only advanced (loser was eliminated)", async () => {
// Mirrors the Brazil/Japan scenario: Brazil advanced without earning points,
// Japan was eliminated. winnerUsername is shown in the embed; winnerDiscordUserId
// is left unset (no ping) because the winner did not score this round.
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
@ -230,14 +199,14 @@ describe("sendStandingsUpdateNotification", () => {
{
winnerName: "Brazil",
loserName: "Japan",
winnerUsername: "aliceManager",
winnerUsername: "someManager",
loserUsername: "ikyn",
},
],
});
const desc = getDescription();
expect(desc).toContain("• **Brazil (aliceManager)** def. Japan (ikyn)");
expect(desc).toContain("• **Brazil (someManager)** def. Japan (ikyn)");
});
it("omits scored matches where neither side has a username", async () => {
@ -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,
@ -743,416 +675,6 @@ describe("sendDraftOrderNotification", () => {
});
});
// The caller now supplies each entry's rank in the FULL season field. This helper
// mirrors the production ranking (qualifying-points-discord.server.ts): sort by
// qpTotal desc, competition ranking with ties sharing the lower rank, so existing
// tests keep asserting ranks derived from qpTotal.
function withRanks<T extends { qpTotal: number }>(entries: T[]) {
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
const rankByTotal = new Map<number, number>();
let prevTotal = Number.NaN;
let prevRank = 0;
sorted.forEach((e, i) => {
const rank = i > 0 && Math.abs(e.qpTotal - prevTotal) < 0.001 ? prevRank : i + 1;
rankByTotal.set(e.qpTotal, rank);
prevTotal = e.qpTotal;
prevRank = rank;
});
const countByTotal = new Map<number, number>();
for (const e of entries) countByTotal.set(e.qpTotal, (countByTotal.get(e.qpTotal) ?? 0) + 1);
return entries.map((e) => ({
...e,
globalRank: rankByTotal.get(e.qpTotal) ?? 0,
globalRankTied: (countByTotal.get(e.qpTotal) ?? 0) > 1,
}));
}
describe("sendQualifyingPointsUpdateNotification", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch(204));
});
const BASE_ENTRIES = withRanks([
{ participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" },
{ participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" },
{ 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 () => {
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();
});
it("shows sport and event header", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
sportName: "ATP Tennis",
eventName: "Wimbledon 2025",
entries: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**ATP Tennis — Wimbledon 2025**");
});
it("shows Points Awarded section for entries with qpEarned > 0, sorted by QP desc", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**Points Awarded**");
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 () => {
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("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 () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: withRanks([
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
{ 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();
expect(desc).toContain("T1\\. Player A");
expect(desc).toContain("T1\\. Player B");
expect(desc).toContain("3\\. Player C");
});
it("does not round fractional QP — a 1.5 QP award shows as 1.5, not 2", async () => {
// Regression for the reported bug: a tennis Round-of-16 loser earns 1.5 QP
// (positions 916 split) but Discord rounded it to 2 via Math.round.
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
sportName: "Tennis - Men",
eventName: "Wimbledon",
entries: [
{
participantName: "Novak Djokovic",
qpEarned: 1.5,
qpTotal: 1.5,
globalRank: 9,
globalRankTied: true,
ownerUsername: "snarkymcgee",
},
],
});
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");
});
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",
},
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
sportName: "Tennis - Men",
eventName: "Wimbledon",
entries: both,
scoreboard: both,
});
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");
});
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: withRanks([
{
participantName: "Novak Djokovic",
qpEarned: 20,
qpTotal: 45,
ownerUsername: "alex",
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");
});
it("pings awarded owners but not non-scoring owners", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: withRanks([
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
]),
});
const payload = getPayload();
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("does not send when entries array is empty", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [],
});
expect(fetch).not.toHaveBeenCalled();
});
it("shows Knocked Out section for eliminated entries, tagging the manager", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
eliminated: [{ participantName: "Jakob Mensik", ownerUsername: "chris" }],
});
const desc = getDescription();
expect(desc).toContain("**Knocked Out**");
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 () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [],
eliminated: [{ participantName: "Jakob Mensik", ownerUsername: "chris" }],
});
expect(fetch).toHaveBeenCalledOnce();
const desc = getDescription();
expect(desc).toContain("**Knocked Out**");
expect(desc).not.toContain("**Drafted Participants**");
expect(desc).not.toContain("**Points Awarded**");
});
it("pings opted-in owners who appear only in the Knocked Out section", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [],
eliminated: [{ participantName: "Jakob Mensik", ownerDiscordUserId: "777" }],
});
const payload = getPayload();
expect(payload.content).toContain("<@777>");
expect(payload.allowed_mentions.users).toContain("777");
});
it("escapes markdown in participant names and usernames", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: withRanks([
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
]),
});
const desc = getDescription();
expect(desc).toContain("Player\\_One");
expect(desc).toContain("user\\_name");
});
it("truncates description at 4096 characters", async () => {
const longEntries = withRanks(
Array.from({ length: 200 }, (_, i) => ({
participantName: `Very Long Participant Name Number ${i}`,
qpEarned: i % 2 === 0 ? 5 : 0,
qpTotal: 200 - i,
ownerUsername: `owner_with_long_username_${i}`,
}))
);
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: longEntries,
scoreboard: longEntries,
});
const desc = getDescription();
expect(desc.length).toBeLessThanOrEqual(4096);
expect(desc.endsWith("...")).toBe(true);
});
});
describe("sendPickAnnouncementNotification", () => {
const BASE = {
webhookUrl: WEBHOOK_URL,

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

@ -1,498 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/services/discord", () => ({
sendQualifyingPointsUpdateNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/models/account", () => ({
findDiscordIdsByUserIds: vi.fn().mockResolvedValue(new Map()),
}));
vi.mock("~/models/user", () => ({
getUserDisplayName: vi.fn((u: { username?: string }) => u.username ?? null),
}));
import { notifyQualifyingPointsUpdate } from "../qualifying-points-discord.server";
import { sendQualifyingPointsUpdateNotification } from "~/services/discord";
import { findDiscordIdsByUserIds } from "~/models/account";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
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";
function makeEvent(overrides = {}) {
return {
id: SCORING_EVENT_ID,
name: "Roland Garros 2025",
sportsSeason: { sport: { name: "Tennis" } },
...overrides,
};
}
function makeDb(overrides: Record<string, unknown> = {}) {
return {
query: {
scoringEvents: {
findFirst: vi.fn().mockResolvedValue(makeEvent()),
},
seasonSports: {
findMany: vi.fn().mockResolvedValue([{ seasonId: SEASON_ID }]),
},
eventResults: {
findMany: vi.fn().mockResolvedValue([
{
seasonParticipantId: PARTICIPANT_ID,
qualifyingPointsAwarded: "10",
scoringEventId: SCORING_EVENT_ID,
},
]),
},
seasonParticipantQualifyingTotals: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, totalQualifyingPoints: "45", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
seasons: {
findMany: vi.fn().mockResolvedValue([
{
id: SEASON_ID,
year: 2025,
league: { id: LEAGUE_ID, name: "Slam League", discordWebhookUrl: WEBHOOK_URL },
},
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{
participantId: PARTICIPANT_ID,
seasonId: SEASON_ID,
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
},
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }]),
},
users: {
findMany: vi.fn().mockResolvedValue([
{ id: OWNER_ID, username: "chris", discordPingEnabled: false },
]),
},
...overrides,
},
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(findDiscordIdsByUserIds).mockResolvedValue(new Map());
});
describe("notifyQualifyingPointsUpdate", () => {
it("returns early when the scoring event is not found", async () => {
const db = makeDb({
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("returns early when no season sports exist for the sports season", async () => {
const db = makeDb({
seasonSports: { findMany: vi.fn().mockResolvedValue([]) },
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("returns early when no qualifying points were awarded in the event", async () => {
const db = makeDb({
eventResults: {
findMany: vi.fn().mockResolvedValue([
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: null },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("skips leagues without a Discord webhook URL", async () => {
const db = makeDb({
seasons: {
findMany: vi.fn().mockResolvedValue([
{ id: SEASON_ID, year: 2025, league: { name: "Slam League", discordWebhookUrl: null } },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("skips leagues where no QP participants were drafted", async () => {
const db = makeDb({
draftPicks: {
findMany: vi.fn().mockResolvedValue([]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("calls sendQualifyingPointsUpdateNotification with correct participant data", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledOnce();
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
webhookUrl: WEBHOOK_URL,
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",
qpEarned: 10,
qpTotal: 45,
ownerUsername: "chris",
ownerDiscordUserId: undefined,
}),
],
})
);
});
it("sends once per league, not once per participant", async () => {
const db = makeDb({
seasonSports: {
findMany: vi.fn().mockResolvedValue([{ seasonId: SEASON_ID }, { seasonId: "season-2" }]),
},
seasons: {
findMany: vi.fn().mockResolvedValue([
{ id: SEASON_ID, year: 2025, league: { name: "League A", discordWebhookUrl: WEBHOOK_URL } },
{ id: "season-2", year: 2025, league: { name: "League B", discordWebhookUrl: "https://discord.com/api/webhooks/456/def" } },
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: PARTICIPANT_ID, seasonId: "season-2", team: { name: "Beta FC", ownerId: null } },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledTimes(2);
});
it("resolves owner Discord ID and passes it when user has discordPingEnabled", async () => {
vi.mocked(findDiscordIdsByUserIds).mockResolvedValue(new Map([[OWNER_ID, "discord-999"]]));
const db = makeDb({
users: {
findMany: vi.fn().mockResolvedValue([
{ id: OWNER_ID, username: "chris", discordPingEnabled: true },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
entries: [expect.objectContaining({ ownerDiscordUserId: "discord-999" })],
})
);
});
it("omits ownerDiscordUserId when user has discordPingEnabled false", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(findDiscordIdsByUserIds).toHaveBeenCalledWith([]);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
entries: [expect.objectContaining({ ownerDiscordUserId: undefined })],
})
);
});
it("participantIdFilter limits notification to only specified participants", async () => {
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 } },
]),
},
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];
expect(call.entries).toHaveLength(1);
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();
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set(["p-nonexistent"])
);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
// A knocked-out drafted player (e.g. a 2nd-round loser) earns no QP and so has
// no event_results row — surfaced only via the eliminatedParticipantIds arg.
const MENSIK_ID = "p-2";
function makeDbWithEliminated() {
return makeDb({
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{
participantId: PARTICIPANT_ID,
seasonId: SEASON_ID,
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
},
{
participantId: MENSIK_ID,
seasonId: SEASON_ID,
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
},
]),
},
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 },
]),
},
});
}
it("announces a knocked-out drafted player who earned no QP", async () => {
const db = makeDbWithEliminated();
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID]),
new Set([MENSIK_ID])
);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
eliminated: [
expect.objectContaining({ participantName: "Jakob Mensik", ownerUsername: "chris" }),
],
})
);
});
it("does not announce a knocked-out player who is not drafted in the league", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID]),
new Set(["p-undrafted"])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.eliminated).toEqual([]);
});
it("does not double-list a QP earner that is also passed as eliminated", async () => {
const db = makeDb();
// PARTICIPANT_ID earned 10 QP this sync AND is passed as eliminated
// (e.g. a Round-of-16 loss). It should stay in entries, not the eliminated list.
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID]),
new Set([PARTICIPANT_ID])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.entries).toHaveLength(1);
expect(call.eliminated).toEqual([]);
});
it("short-circuits before the QP-total/season lookups when nothing drafted is involved", async () => {
// A knockout occurred but the player isn't drafted in any league. The notifier
// is invoked (the sync fires it on any elimination) but must not run the rest
// of its query battery just to send nothing.
const db = makeDb({
draftPicks: { findMany: vi.fn().mockResolvedValue([]) },
});
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set(["p-nonexistent"]),
new Set(["p-undrafted"])
);
expect(db.query.draftPicks.findMany).toHaveBeenCalledOnce();
expect(db.query.seasonParticipantQualifyingTotals.findMany).not.toHaveBeenCalled();
expect(db.query.seasons.findMany).not.toHaveBeenCalled();
expect(db.query.seasonParticipants.findMany).not.toHaveBeenCalled();
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("fires when a drafted player is knocked out even though no QP changed", async () => {
const db = makeDbWithEliminated();
// participantIdFilter matches nobody → no QP entries, but a knockout exists.
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set(["p-nonexistent"]),
new Set([MENSIK_ID])
);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledOnce();
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.entries).toEqual([]);
expect(call.eliminated).toEqual([
expect.objectContaining({ participantName: "Jakob Mensik" }),
]);
});
});

View file

@ -1,28 +1,14 @@
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,
};
});
}));
vi.mock("~/models/scoring-event", () => ({
completeScoringEvent: vi.fn(),
@ -103,21 +89,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 +141,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 +248,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 +281,6 @@ function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
scoringEvents: [],
seasonParticipants: [],
eventResults: [],
playoffMatches: [],
...overrides,
};
}
@ -393,10 +360,7 @@ describe("syncTournamentResults", () => {
expect(c?.qualifyingPointsAwarded).toBe("0");
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db);
});
// -------------------------------------------------------------------------
@ -448,14 +412,8 @@ describe("syncTournamentResults", () => {
expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db);
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db);
});
// -------------------------------------------------------------------------
@ -765,10 +723,7 @@ describe("syncTournamentResults", () => {
expect(report.windowsSynced).toBe(1);
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db);
// No event_results written for the skipped primary window.
expect(
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
@ -859,214 +814,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

@ -63,17 +63,6 @@ function escapeMarkdown(text: string): string {
return text.replace(/[_*~`|\\]/g, "\\$&");
}
/**
* 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.
*/
function formatQPValue(n: number): string {
return parseFloat(n.toFixed(2)).toString();
}
export interface StandingEntry {
teamId: string;
teamName: string;
@ -176,19 +165,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 +196,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 +215,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);
@ -248,7 +231,7 @@ export async function sendStandingsUpdateNotification({
{
title: `📊 Standings Update — ${seasonName}`,
description,
color: 0xffd700, // Gold
color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" },
},
],
@ -264,167 +247,6 @@ export async function sendStandingsUpdateNotification({
await sendDiscordWebhook(webhookUrl, payload);
}
export interface QPEventEntry {
participantName: string;
qpEarned: number;
qpTotal: number;
/**
* The participant's rank in the FULL season QP standings (all participants), not
* their position among this event's scorers. Computed by the caller so the "QP
* Standings" block reflects the whole sport season e.g. two R16 losers on 1.5 QP
* show as T9 (8 players ahead) rather than T1 among just the two of them.
*/
globalRank: number;
/** True when another participant in the full field shares this globalRank. */
globalRankTied: boolean;
ownerUsername?: string;
ownerDiscordUserId?: string;
}
/** A drafted player knocked out this sync in a non-scoring round (0 QP). */
export interface QPEliminatedEntry {
participantName: string;
ownerUsername?: string;
ownerDiscordUserId?: string;
}
export async function sendQualifyingPointsUpdateNotification({
webhookUrl,
seasonName,
sportName,
eventName,
entries,
eliminated = [],
scoreboard = [],
standingsUrl,
}: {
webhookUrl: string;
seasonName: string;
sportName?: string;
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;
const sections: string[] = [];
if (sportName || eventName) {
const parts = [sportName, eventName].filter(Boolean);
sections.push(`**${parts.join(" — ")}**`);
}
const awardedEntries = entries
.filter((e) => e.qpEarned > 0)
.toSorted((a, b) => b.qpEarned - a.qpEarned);
if (awardedEntries.length > 0) {
sections.push("\n**Points Awarded**");
for (const e of awardedEntries) {
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}** — ${formatQPValue(e.qpEarned)} QP`);
}
}
// Knocked-out section — drafted players eliminated this sync in a non-scoring
// round. They earn no QP, so they'd otherwise never be surfaced to their manager.
if (eliminated.length > 0) {
sections.push("\n**Knocked Out**");
for (const e of eliminated) {
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}`);
}
}
// 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);
// 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++;
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : "";
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel}${formatQPValue(e.qpTotal)} QP`);
}
}
const MAX_DESCRIPTION = 4096;
let description = sections.join("\n");
if (description.length > MAX_DESCRIPTION) {
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]) {
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
}
const pingIds = [...pingUserIds];
const payload: DiscordWebhookPayload = {
embeds: [
{
title: `🏅 Qualifying Points Update — ${seasonName}`,
url: standingsUrl,
description,
color: 0x5865f2, // Discord blurple
},
],
};
if (pingIds.length > 0) {
const cappedIds = pingIds.slice(0, 100);
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
payload.allowed_mentions = { parse: [], users: cappedIds };
}
await sendDiscordWebhook(webhookUrl, payload);
}
export async function sendPickAnnouncementNotification({
webhookUrl,
draftUrl,

View file

@ -1,120 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type * as QualifyingPointsModule from "~/models/qualifying-points";
// The helper under test re-derives QP via processQualifyingBracketEvent and
// recalculates dropped participants — both stubbed so the test drives the
// before/after event_results rows directly and asserts the change-detection diff.
vi.mock("~/models/scoring-calculator", () => ({
processMatchResult: vi.fn(),
autoCompleteRoundIfDone: vi.fn(),
processQualifyingBracketEvent: vi.fn().mockResolvedValue(undefined),
recalculateAffectedLeagues: vi.fn(),
}));
// Keep the real diffChangedQualifyingPoints (the change-detection primitive under
// test here); only stub the dropped-participant total recalc.
vi.mock("~/models/qualifying-points", async (importActual) => {
const actual = await importActual<typeof QualifyingPointsModule>();
return {
...actual,
recalculateParticipantQP: vi.fn().mockResolvedValue(undefined),
};
});
import { rescoreTennisBracketAndDetectChanges } from "../index";
import { processQualifyingBracketEvent } from "~/models/scoring-calculator";
import { recalculateParticipantQP } from "~/models/qualifying-points";
const EVENT_ID = "ev-1";
const SPORTS_SEASON_ID = "ss-1";
type Row = { id: string; qp: string | null };
/**
* Fake Drizzle db whose two `select().from().where()` calls resolve, in order, to
* the provided before-rows then after-rows. `transaction` runs its callback with
* the same fake, and `delete().where()` is a no-op.
*/
function makeDb(beforeRows: Row[], afterRows: Row[]) {
const results = [beforeRows, afterRows];
let selectCall = 0;
const db = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve(results[selectCall++] ?? [])),
})),
})),
delete: vi.fn(() => ({ where: vi.fn(() => Promise.resolve(undefined)) })),
transaction: vi.fn(async (fn: (tx: unknown) => Promise<void>) => fn(db)),
};
return db;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("rescoreTennisBracketAndDetectChanges", () => {
it("flags participants whose QP changed and newly-scored participants, ignoring unchanged ones", async () => {
const db = makeDb(
[
{ id: "A", qp: "10" },
{ id: "B", qp: "5" },
],
[
{ id: "A", qp: "10" }, // unchanged
{ id: "B", qp: "8" }, // changed 5 -> 8
{ id: "C", qp: "3" }, // newly scored
],
);
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
expect([...changed].toSorted()).toEqual(["B", "C"]);
expect(processQualifyingBracketEvent).toHaveBeenCalledWith(EVENT_ID, db);
// No participant dropped out, so no manual total recalc.
expect(recalculateParticipantQP).not.toHaveBeenCalled();
});
it("treats a null-before → value-after transition as a change", async () => {
const db = makeDb([{ id: "A", qp: null }], [{ id: "A", qp: "10" }]);
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
expect([...changed]).toEqual(["A"]);
});
it("announces nothing when a re-sync produces identical QP", async () => {
const db = makeDb(
[
{ id: "A", qp: "10" },
{ id: "B", qp: "5" },
],
[
{ id: "A", qp: "10" },
{ id: "B", qp: "5" },
],
);
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
expect(changed.size).toBe(0);
expect(recalculateParticipantQP).not.toHaveBeenCalled();
});
it("recalculates totals for participants dropped from the rewritten results", async () => {
const db = makeDb(
[
{ id: "A", qp: "10" },
{ id: "D", qp: "2" }, // present before, gone after (early-round loser)
],
[{ id: "A", qp: "10" }],
);
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
expect(changed.size).toBe(0); // A unchanged; D no longer in results
expect(recalculateParticipantQP).toHaveBeenCalledTimes(1);
expect(recalculateParticipantQP).toHaveBeenCalledWith("D", SPORTS_SEASON_ID, db);
});
});

View file

@ -22,13 +22,8 @@ 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 {
recalculateParticipantQP,
diffChangedQualifyingPoints,
} from "~/models/qualifying-points";
import { recalculateParticipantQP } from "~/models/qualifying-points";
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import { findMatchingTeamName, normalizeTeamName } from "~/lib/normalize-team-name";
import { buildUnmatchedTeamResolutionView } from "~/lib/unmatched-team-reconciliation";
@ -284,11 +279,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 +296,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 };
@ -608,93 +589,23 @@ export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
});
}
const { written, completed, newlyDecidedLoserIds } = await populateBracketFromDraw(
eventId,
resolvedMatches,
);
const { written, completed } = await populateBracketFromDraw(eventId, resolvedMatches);
// ---- Score (qualifying points) + fan out to siblings ----------------------
// Re-derive QP from the bracket and learn which participants' QP changed so the
// Discord notification below only announces new/changed results on a re-sync.
const changedParticipantIds = await rescoreTennisBracketAndDetectChanges(
eventId,
sportsSeasonId,
db,
);
await recalculateAffectedLeagues(sportsSeasonId, db, {
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 },
);
// Announce QP changes for the primary window. Sibling windows are announced by
// the fan-out (via processQualifyingEvent); the primary is scored directly by
// processQualifyingBracketEvent, which does NOT notify — so do it here. Run
// outside the rescore transaction so the webhook HTTP call neither holds the
// transaction open nor rolls back the score if Discord fails.
// The tennis bracket is the sole QP source for its event, so reconcile stale
// rows: snapshot existing result rows, clear them, re-derive from the bracket,
// then recalc QP totals for any participant who no longer earns points (e.g.
// early-round losers — only Round of 16+ losers score). writeEventResultsQP
// upserts but never deletes, so without this a previously mis-scored player
// would keep phantom QP across re-syncs.
//
// Fire even when no QP changed, so an early-round elimination is still surfaced.
if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) {
try {
await notifyQualifyingPointsUpdate(
sportsSeasonId,
eventId,
db,
changedParticipantIds,
newlyEliminatedIds,
);
} catch (error) {
logger.error(`[syncTennisDraw] QP Discord notification failed for event ${eventId}:`, error);
}
}
return { matchesWritten: written, completed, participantsCreated, unmatched };
}
/**
* Re-derive a tennis major's qualifying points from its bracket and report which
* season participants' awarded QP changed since the previous scoring.
*
* The synced draw is authoritative for tennis, so this clears ALL event_results
* for the event (including any manually-entered rows, e.g. a notParticipating
* withdrawal) and rebuilds them from the bracket. Change detection therefore
* snapshots each participant's awarded QP *before* the delete and diffs it against
* the freshly written rows: a value that differs, or a participant scored for the
* first time (null value), counts as changed. Wrapped in a transaction so a
* mid-rescore failure can't leave the event with results deleted-but-not-rebuilt.
*
* writeEventResultsQP (inside processQualifyingBracketEvent) upserts but never
* deletes, so QP totals for participants who no longer earn points (e.g. an
* early-round loser dropped from the rewritten results only Round of 16+ losers
* score) are recalculated by hand.
*
* Returns the set of season_participant ids whose QP changed, used to scope the
* QP Discord notification so a re-sync that changes nothing announces nothing.
*/
export async function rescoreTennisBracketAndDetectChanges(
eventId: string,
sportsSeasonId: string,
db: ReturnType<typeof database>,
): Promise<Set<string>> {
let changed = new Set<string>();
// Wrapped in a transaction so a mid-rescore failure can't leave the event with
// its results deleted and not rebuilt. NOTE: this clears ALL event_results for
// the event, including any manually-entered rows (e.g. a notParticipating
// withdrawal) — acceptable because the synced draw is authoritative for tennis.
await db.transaction(async (tx) => {
const beforeRows = await tx
.select({
id: schema.eventResults.seasonParticipantId,
qp: schema.eventResults.qualifyingPointsAwarded,
})
.select({ pid: schema.eventResults.seasonParticipantId })
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
await tx.delete(schema.eventResults).where(eq(schema.eventResults.scoringEventId, eventId));
@ -702,20 +613,23 @@ export async function rescoreTennisBracketAndDetectChanges(
await processQualifyingBracketEvent(eventId, tx);
const afterRows = await tx
.select({
id: schema.eventResults.seasonParticipantId,
qp: schema.eventResults.qualifyingPointsAwarded,
})
.select({ pid: schema.eventResults.seasonParticipantId })
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
const afterIds = new Set(afterRows.map((r) => r.id));
for (const { id } of beforeRows) {
if (!afterIds.has(id)) await recalculateParticipantQP(id, sportsSeasonId, tx);
const afterIds = new Set(afterRows.map((r) => r.pid));
for (const { pid } of beforeRows) {
if (!afterIds.has(pid)) await recalculateParticipantQP(pid, sportsSeasonId, tx);
}
changed = diffChangedQualifyingPoints(beforeRows, afterRows);
});
return changed;
await recalculateAffectedLeagues(sportsSeasonId, db, {
eventId,
eventName: event.name ?? undefined,
});
await fanOutMajorIfPrimary(
{ id: event.id, isPrimary: event.isPrimary, tournamentId: event.tournamentId },
{ markComplete: false },
);
return { matchesWritten: written, completed, participantsCreated, unmatched };
}

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

@ -1,251 +0,0 @@
import type { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, inArray } from "drizzle-orm";
import { findDiscordIdsByUserIds } from "~/models/account";
import { getUserDisplayName } from "~/models/user";
import {
sendQualifyingPointsUpdateNotification,
type QPEventEntry,
type QPEliminatedEntry,
} from "~/services/discord";
export async function notifyQualifyingPointsUpdate(
sportsSeasonId: string,
scoringEventId: string,
db: ReturnType<typeof database>,
participantIdFilter?: Set<string>,
/**
* Participants knocked out this sync in a non-scoring round: they earn no QP,
* so they never appear via qualifyingPointsAwarded, but a manager who drafted
* them should still be told their player is out. Surfaced in a "Knocked Out"
* section, deduped against QP earners.
*/
eliminatedParticipantIds?: Set<string>
): Promise<void> {
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, scoringEventId),
with: {
sportsSeason: {
with: { sport: true },
},
},
});
if (!event) return;
const eventName = event.name;
const sportName = event.sportsSeason?.sport?.name;
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
});
if (seasonSports.length === 0) return;
// QP earned in this specific event (optionally scoped to participants new/changed in this call)
const eventResultRows = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, scoringEventId),
});
const qpEarnedById = new Map<string, number>(
eventResultRows
.filter((r) => r.qualifyingPointsAwarded !== null)
.filter((r) => !participantIdFilter || participantIdFilter.has(r.seasonParticipantId))
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
);
// Knocked-out players with no QP change. Exclude anyone who also earned QP this
// sync (e.g. a Round-of-16 loser) so they aren't listed twice.
const eliminatedIds = new Set(
[...(eliminatedParticipantIds ?? [])].filter((id) => !qpEarnedById.has(id))
);
if (qpEarnedById.size === 0 && eliminatedIds.size === 0) return;
const seasonIds = seasonSports.map((s) => s.seasonId);
// Batch-fetch all draft picks for all leagues at once, grouped by season.
// Fetched before the remaining lookups so we can short-circuit when nothing
// that happened this sync was actually drafted: during a Grand Slam's early
// rounds most eliminated players are undrafted, and the notifier now fires on
// every sync that has any elimination — no point running the rest of the
// queries just to send nothing.
const allPicks = await db.query.draftPicks.findMany({
where: inArray(schema.draftPicks.seasonId, seasonIds),
with: { team: true },
});
const picksBySeasonId = new Map<string, (typeof allPicks)[number][]>();
const draftedParticipantIds = new Set<string>();
for (const pick of allPicks) {
draftedParticipantIds.add(pick.participantId);
const bucket = picksBySeasonId.get(pick.seasonId);
if (bucket) {
bucket.push(pick);
} else {
picksBySeasonId.set(pick.seasonId, [pick]);
}
}
const hasDraftedQP = [...qpEarnedById.keys()].some((id) => draftedParticipantIds.has(id));
const hasDraftedEliminated = [...eliminatedIds].some((id) => draftedParticipantIds.has(id));
if (!hasDraftedQP && !hasDraftedEliminated) return;
// Current running QP totals for the season
const qpTotals = await db.query.seasonParticipantQualifyingTotals.findMany({
where: eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId),
});
const qpTotalById = new Map<string, number>(
qpTotals.map((t) => [t.participantId, parseFloat(t.totalQualifyingPoints)])
);
// Rank every participant across the FULL season field (not just this event's
// scorers) so the notification's "QP Standings" block reads as a season-leaderboard
// slice and matches the website's globalRank. Standard competition ranking: ties
// share the lower rank (…, 9, 9, 11, …). Two R16 losers on 1.5 QP with 8 players
// ahead therefore render as T9, not T1 among just the two of them.
const rankedField = [...qpTotalById.entries()]
.map(([id, total]) => ({ id, total }))
.toSorted((a, b) => b.total - a.total);
const globalRankById = new Map<string, number>();
let prevTotal = Number.NaN;
let prevRank = 0;
rankedField.forEach((row, index) => {
const rank =
index > 0 && Math.abs(row.total - prevTotal) < 0.001 ? prevRank : index + 1;
globalRankById.set(row.id, rank);
prevTotal = row.total;
prevRank = rank;
});
const countByRank = new Map<number, number>();
for (const rank of globalRankById.values()) {
countByRank.set(rank, (countByRank.get(rank) ?? 0) + 1);
}
// Batch-fetch all season + league metadata in one query
const seasons = await db.query.seasons.findMany({
where: inArray(schema.seasons.id, seasonIds),
with: { league: true },
});
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]),
];
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>();
for (const pick of allPicks) {
if (pick.team.ownerId) allOwnerIds.add(pick.team.ownerId);
}
const allUsers =
allOwnerIds.size > 0
? await db.query.users.findMany({ where: inArray(schema.users.id, [...allOwnerIds]) })
: [];
const usernameByUserId = new Map(
allUsers
.map((u) => [u.id, getUserDisplayName(u)] as [string, string | null])
.filter((entry): entry is [string, string] => entry[1] !== null)
);
const optedInUserIds = allUsers.filter((u) => u.discordPingEnabled).map((u) => u.id);
const discordIdByUserId = await findDiscordIdsByUserIds(optedInUserIds);
for (const { seasonId } of seasonSports) {
const season = seasonMap.get(seasonId);
const league = season?.league;
const webhookUrl = league?.discordWebhookUrl;
if (!webhookUrl || !season || !league) continue;
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,
{ teamName: string; ownerId: string | null }
>();
for (const pick of picks) {
teamByParticipantId.set(pick.participantId, {
teamName: pick.team.name,
ownerId: pick.team.ownerId,
});
}
// Only include participants that appear in both the event and this league's draft
const relevantParticipantIds = [...qpEarnedById.keys()].filter((id) =>
teamByParticipantId.has(id)
);
// Knocked-out players drafted in this league (0 QP, non-scoring-round exits)
const eliminatedForLeague = [...eliminatedIds].filter((id) =>
teamByParticipantId.has(id)
);
if (relevantParticipantIds.length === 0 && eliminatedForLeague.length === 0) continue;
const entries: QPEventEntry[] = relevantParticipantIds.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,
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
};
});
const eliminated: QPEliminatedEntry[] = eliminatedForLeague.map((participantId) => {
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
return {
participantName: participantNameById.get(participantId) ?? participantId,
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
};
});
// 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,
sportName,
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/);
});
});
});

Some files were not shown because too many files have changed in this diff Show more