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
80 changed files with 1517 additions and 5989 deletions

View file

@ -9,14 +9,17 @@
"command": ".claude/hooks/lint-on-edit.sh", "command": ".claude/hooks/lint-on-edit.sh",
"timeout": 30, "timeout": 30,
"statusMessage": "Linting..." "statusMessage": "Linting..."
}, }
]
}
],
"Stop": [
{
"hooks": [
{ {
"type": "command", "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 '{\"systemMessage\":\"TypeCheck failed:\\n%s\"}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
"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
"timeout": 60,
"statusMessage": "Type-checking...",
"async": true
} }
] ]
} }

View file

@ -31,8 +31,3 @@ CLOUDINARY_API_SECRET=""
# Must match the CRON_SECRET repo secret in Forgejo. # Must match the CRON_SECRET repo secret in Forgejo.
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
CRON_SECRET="" 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", () => { describe("QualifyingPointsStandings", () => {
it("renders fractional QP to at most hundredths with trailing zeros trimmed", () => { it("renders fractional QP to hundredths", () => {
render( render(
<QualifyingPointsStandings <QualifyingPointsStandings
standings={[ standings={[
@ -52,8 +52,7 @@ describe("QualifyingPointsStandings", () => {
); );
expect(screen.getByText("14.67 QP")).toBeInTheDocument(); 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.50 QP")).toBeInTheDocument();
expect(screen.getByText("0.5 QP")).toBeInTheDocument();
expect(screen.getByText("0.43 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

@ -13,7 +13,7 @@ import { GradientIcon } from "~/components/ui/GradientIcon";
import { RankingsRow } from "./RankingsRow"; import { RankingsRow } from "./RankingsRow";
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView"; import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated"; import { BracketTreePaginated } from "./BracketTreePaginated";
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates"; import { getBracketTemplate } from "~/lib/bracket-templates";
import { NbaBracketLayout } from "./NbaBracketLayout"; import { NbaBracketLayout } from "./NbaBracketLayout";
import { TabbedBracketLayout } from "./TabbedBracketLayout"; import { TabbedBracketLayout } from "./TabbedBracketLayout";
@ -174,138 +174,6 @@ export function computeEliminatedByRound(
return result; 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.
* Exported for unit testing.
*/
export function findConsolationRound(
template: BracketTemplate | undefined
): ConsolationRound | undefined {
const feeder = template?.rounds.find((r) => r.loserFeedsInto);
if (!feeder?.loserFeedsInto) return undefined;
return { round: feeder.loserFeedsInto, feederRound: feeder.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>
): 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;
nextRank += matchesByRound.get(roundName)?.length ?? 0;
}
return rankedEntries;
}
/** Find the index of the first round that has scoring matches. */ /** Find the index of the first round that has scoring matches. */
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number { function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
for (let i = 0; i < rounds.length; i++) { for (let i = 0; i < rounds.length; i++) {
@ -348,10 +216,12 @@ export function PlayoffBracket({
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds); const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined; const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
const consolation = findConsolationRound(template); const thirdPlaceRound = template?.rounds
const thirdPlaceRound = consolation?.round; .find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
?.name;
// Build elimination rankings // Build elimination rankings
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
let bracketWinner: Participant | null = null; let bracketWinner: Participant | null = null;
const lastRound = rounds[rounds.length - 1]; const lastRound = rounds[rounds.length - 1];
@ -360,19 +230,43 @@ export function PlayoffBracket({
: null; : null;
if (finalMatch?.winner) bracketWinner = finalMatch.winner; 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>(); const allBracketParticipantIds = new Set<string>();
for (const match of matches) { for (const match of matches) {
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id); if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id); if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
} }
const rankedEntries: EliminatedEntry[] = [];
const rankedEntries = computeRankedEntries( let nextRank = 2;
matches, for (let ri = rounds.length - 1; ri >= 0; ri--) {
rounds, const roundName = rounds[ri];
matchesByRound, const roundLosers = losersByRound.get(roundName) || [];
consolation, const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
ownershipMap 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)); const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id); if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
@ -392,7 +286,7 @@ export function PlayoffBracket({
.map((id) => participantMap.get(id)) .map((id) => participantMap.get(id))
.filter((p): p is Participant => p !== undefined) .filter((p): p is Participant => p !== undefined)
// Owned-by-a-manager players first, then alphabetical by name. // Owned-by-a-manager players first, then alphabetical by name.
.toSorted((a, b) => { .sort((a, b) => {
const aOwned = ownershipMap.has(a.id); const aOwned = ownershipMap.has(a.id);
const bOwned = ownershipMap.has(b.id); const bOwned = ownershipMap.has(b.id);
if (aOwned !== bOwned) return aOwned ? -1 : 1; if (aOwned !== bOwned) return aOwned ? -1 : 1;

View file

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

View file

@ -1,15 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
import {
PlayoffBracket,
buildFeederMap,
groupMatchesByRound,
computeEliminatedByRound,
computeRankedEntries,
findConsolationRound,
type Match,
} from "../PlayoffBracket";
import { getBracketTemplate } from "~/lib/bracket-templates";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@ -301,392 +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;
}
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();
});
});
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

@ -6,7 +6,7 @@ vi.mock("~/database/context", () => ({
})); }));
import { database } from "~/database/context"; import { database } from "~/database/context";
import { batchUpsertParticipantSimulatorInputs } from "../simulator"; import { batchSaveFuturesOddsForSimulator } from "../simulator";
type SetPayload = Record<string, unknown>; type SetPayload = Record<string, unknown>;
@ -17,71 +17,36 @@ const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => {
return Promise.resolve(undefined); return Promise.resolve(undefined);
}); });
const tx = { const mockDb = {
insert: vi.fn(() => ({ insert: vi.fn(() => ({
values: vi.fn(() => ({ onConflictDoUpdate })), values: vi.fn(() => ({ onConflictDoUpdate })),
})), })),
}; };
const mockDb = {
transaction: vi.fn((cb: (t: typeof tx) => Promise<unknown>) => cb(tx)),
};
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
conflictSetCalls.length = 0; conflictSetCalls.length = 0;
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb); (database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
}); });
// Recursively flatten a Drizzle `sql` template into its static text so we can describe("batchSaveFuturesOddsForSimulator", () => {
// assert how a conflict-update column is built (e.g. wrapped in COALESCE). it("persists odds without destroying a stored Elo/rating", async () => {
function sqlToText(value: unknown): string { await batchSaveFuturesOddsForSimulator([
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.
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 }, { participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
]); ]);
// Runs in a transaction, writing the simulator-inputs row first then the // The upsert writes the odds and leaves Elo/rating untouched — whether the
// legacy EV bridge row. // odds override them is now decided by the configurable source priority,
expect(mockDb.transaction).toHaveBeenCalledTimes(1); // not by nulling stored values here.
expect(conflictSetCalls.length).toBeGreaterThanOrEqual(1); expect(mockDb.insert).toHaveBeenCalledTimes(1);
expect(conflictSetCalls).toHaveLength(1);
const simulatorSet = conflictSetCalls[0]; expect(conflictSetCalls[0]).toHaveProperty("sourceOdds");
// Every input column participates in the conflict update so partial pastes expect(conflictSetCalls[0]).not.toHaveProperty("sourceElo");
// can target any field... expect(conflictSetCalls[0]).not.toHaveProperty("rating");
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");
}); });
it("is a no-op when given no inputs", async () => { it("is a no-op when given no inputs", async () => {
await batchUpsertParticipantSimulatorInputs([]); await batchSaveFuturesOddsForSimulator([]);
expect(mockDb.transaction).not.toHaveBeenCalled(); expect(mockDb.insert).not.toHaveBeenCalled();
}); });
}); });

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

@ -2,47 +2,9 @@ import { describe, it, expect } from "vitest";
import { import {
calculateSplitQualifyingPoints, calculateSplitQualifyingPoints,
DEFAULT_QP_VALUES, DEFAULT_QP_VALUES,
diffChangedQualifyingPoints, hasProcessedQualifyingPlacement,
} from "../qualifying-points"; } 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("Qualifying Points Configuration", () => {
describe("DEFAULT_QP_VALUES", () => { describe("DEFAULT_QP_VALUES", () => {
it("should have 16 placement 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) 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", () => { 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", () => { describe("processQualifyingEvent", () => {
function makeProcessQPMockDb( function makeProcessQPMockDb(eventResults: Array<Record<string, unknown>>) {
eventResults: Array<Record<string, unknown>>,
options: {
tournamentId?: string | null;
canonicalPlacements?: number[];
bracketTemplateId?: string | null;
playoffMatchIds?: string[];
} = {}
) {
const {
tournamentId = null,
canonicalPlacements = [],
bracketTemplateId = null,
playoffMatchIds = [],
} = options;
const setCalls: unknown[] = []; const setCalls: unknown[] = [];
const updateChain = { const updateChain = {
set: (values: unknown) => { set: (values: unknown) => {
@ -231,8 +217,6 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
id: "event-1", id: "event-1",
sportsSeasonId: "sports-season-1", sportsSeasonId: "sports-season-1",
isQualifyingEvent: true, isQualifyingEvent: true,
tournamentId,
bracketTemplateId,
sportsSeason: { majorsCompleted: 1 }, 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, update: () => updateChain,
} as any; } 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 () => { it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
const { db, setCalls } = makeProcessQPMockDb([ const { db, setCalls } = makeProcessQPMockDb([
{ {

View file

@ -15,10 +15,7 @@ vi.mock("../qualifying-points", async (importOriginal) => {
import { deleteScoringEvent } from "../scoring-event"; import { deleteScoringEvent } from "../scoring-event";
describe("deleteScoringEvent", () => { describe("deleteScoringEvent", () => {
it("does not write majorsCompleted on delete (it is derived on read)", async () => { it("decrements majorsCompleted for a processed zero-QP qualifying event", 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.
const setCalls: unknown[] = []; const setCalls: unknown[] = [];
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) }; const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
const updateChain = { 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)), transaction: vi.fn(async (callback) => callback(db)),
delete: vi.fn(() => deleteChain), delete: vi.fn(() => deleteChain),
@ -54,125 +57,10 @@ describe("deleteScoringEvent", () => {
await deleteScoringEvent("event-1", db); await deleteScoringEvent("event-1", db);
expect(setCalls).not.toEqual( expect(setCalls).toEqual(
expect.arrayContaining([ 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

@ -27,11 +27,7 @@ vi.mock("drizzle-orm", () => ({
asc: (col: unknown) => ({ type: "asc", col }), asc: (col: unknown) => ({ type: "asc", col }),
})); }));
import { import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season";
findAllSportsSeasons,
findDraftableSportsSeasons,
findDraftScheduleForHorizon,
} from "../sports-season";
import { database } from "~/database/context"; import { database } from "~/database/context";
const today = new Date().toISOString().slice(0, 10); 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

@ -11,7 +11,6 @@ import {
findTournamentBySportNameYear, findTournamentBySportNameYear,
upsertTournament, upsertTournament,
updateTournamentStatus, updateTournamentStatus,
deleteTournament,
} from "../tournament"; } from "../tournament";
import { database } from "~/database/context"; import { database } from "~/database/context";
@ -173,26 +172,3 @@ describe("updateTournamentStatus", () => {
expect(result.status).toBe("in_progress"); 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

@ -17,8 +17,6 @@ import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/databa
import { eq, and, sql } from "drizzle-orm"; import { eq, and, sql } from "drizzle-orm";
import { getQPConfig, calculateSplitQualifyingPoints, writeEventResultsQP, recalculateParticipantQP } from "~/models/qualifying-points"; import { getQPConfig, calculateSplitQualifyingPoints, writeEventResultsQP, recalculateParticipantQP } from "~/models/qualifying-points";
import { deleteEventResults } from "~/models/event-result"; import { deleteEventResults } from "~/models/event-result";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import { logger } from "~/lib/logger";
export interface Cs2StageResult { export interface Cs2StageResult {
id: string; 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); 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

@ -6,11 +6,11 @@
*/ */
import { database } from "~/database/context"; import { database } from "~/database/context";
import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema"; import { seasonParticipantExpectedValues, seasonParticipants, seasonParticipantSimulatorInputs } from "~/database/schema";
import { eq, and, count, sql } from "drizzle-orm"; import { eq, and, count, inArray, sql } from "drizzle-orm";
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator"; import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } 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"; export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
@ -366,6 +366,100 @@ export async function batchUpsertParticipantEVs(
return results; 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. * Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants.
* Used by Elo-based simulators like snooker_bracket and darts_bracket. * Used by Elo-based simulators like snooker_bracket and darts_bracket.

View file

@ -164,16 +164,12 @@ export interface ResolvedDrawMatch {
* (e.g. Wikipedia) that reports the full draw including completed rounds. A row * (e.g. Wikipedia) that reports the full draw including completed rounds. A row
* is marked complete when both its winner and loser are known. * is marked complete when both its winner and loser are known.
* *
* @returns counts of rows written (inserted or updated), those carrying a result, * @returns counts of rows written (inserted or updated) and 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.
*/ */
export async function populateBracketFromDraw( export async function populateBracketFromDraw(
eventId: string, eventId: string,
matches: ResolvedDrawMatch[] matches: ResolvedDrawMatch[]
): Promise<{ written: number; completed: number; newlyDecidedLoserIds: string[] }> { ): Promise<{ written: number; completed: number }> {
const db = database(); const db = database();
const existing = await db.query.playoffMatches.findMany({ const existing = await db.query.playoffMatches.findMany({
@ -188,20 +184,12 @@ export async function populateBracketFromDraw(
const toInsert: NewPlayoffMatch[] = []; const toInsert: NewPlayoffMatch[] = [];
let written = 0; let written = 0;
let completed = 0; let completed = 0;
const newlyDecidedLoserIds: string[] = [];
for (const m of matches) { for (const m of matches) {
const isComplete = m.winnerId !== null && m.loserId !== null; const isComplete = m.winnerId !== null && m.loserId !== null;
if (isComplete) completed++; if (isComplete) completed++;
const existingRow = byExternalId.get(m.externalMatchId); 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) { if (existingRow) {
await db await db
.update(schema.playoffMatches) .update(schema.playoffMatches)
@ -241,7 +229,7 @@ export async function populateBracketFromDraw(
written += toInsert.length; written += toInsert.length;
} }
return { written, completed, newlyDecidedLoserIds }; return { written, completed };
} }
/** /**

View file

@ -33,33 +33,6 @@ export function roundQualifyingPoints(points: number): number {
return Math.round((points + Number.EPSILON) * 100) / 100; 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( export function calculateSplitQualifyingPoints(
placement: number, placement: number,
tieCount: number, tieCount: number,
@ -75,6 +48,21 @@ export function calculateSplitQualifyingPoints(
return roundQualifyingPoints(totalQP / tieCount); 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 * Initialize default qualifying point configuration for a sports season
*/ */

View file

@ -10,7 +10,6 @@ import {
import { getSeasonResults } from "./participant-season-result"; import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord"; 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 { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates";
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match"; import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
import { getUserDisplayName } from "~/models/user"; import { getUserDisplayName } from "~/models/user";
@ -21,8 +20,8 @@ import { logger } from "~/lib/logger";
import { getEventResults } from "./event-result"; import { getEventResults } from "./event-result";
import { import {
calculateSplitQualifyingPoints, calculateSplitQualifyingPoints,
diffChangedQualifyingPoints,
getQPConfig, getQPConfig,
hasProcessedQualifyingPlacement,
recalculateParticipantQP, recalculateParticipantQP,
writeEventResultsQP, writeEventResultsQP,
getQPStandings, getQPStandings,
@ -847,49 +846,13 @@ export async function processQualifyingBracketEvent(
await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db); 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. * Process a qualifying event completion and update QP totals.
* Ties in QP are handled by sharing placements (averaged points). * Ties in QP are handled by sharing placements (averaged points).
*/ */
export async function processQualifyingEvent( export async function processQualifyingEvent(
eventId: string, eventId: string,
providedDb?: ReturnType<typeof database>, 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>;
} = {}
): Promise<void> { ): Promise<void> {
const db = providedDb || database(); const db = providedDb || database();
@ -912,32 +875,10 @@ export async function processQualifyingEvent(
// Get all event results for this qualifying event // Get all event results for this qualifying event
const results = await getEventResults(eventId, db); const results = await getEventResults(eventId, db);
// Snapshot awarded QP before reprocessing so the Discord notification below can // Check if this was already processed (for majorsCompleted counter)
// announce only the participants whose QP actually changed. Without this, a const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
// 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,
}));
// 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) { 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-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the
// bracket/stage writers, which assign each placement its STRUCTURAL tie span. // bracket/stage writers, which assign each placement its STRUCTURAL tie span.
// Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows // Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows
@ -962,28 +903,6 @@ export async function processQualifyingEvent(
qpConfig.map((config) => [config.placement, parseFloat(config.points)]) 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 // Group results by placement to handle ties
const placementGroups = new Map<number, typeof results>(); const placementGroups = new Map<number, typeof results>();
for (const result of results) { for (const result of results) {
@ -996,7 +915,7 @@ export async function processQualifyingEvent(
// Process each placement group and update event_results with QP awarded // Process each placement group and update event_results with QP awarded
for (const [placement, group] of placementGroups) { for (const [placement, group] of placementGroups) {
const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length; const tieCount = group.length;
const qpPerParticipant = calculateSplitQualifyingPoints( const qpPerParticipant = calculateSplitQualifyingPoints(
placement, placement,
@ -1024,46 +943,21 @@ export async function processQualifyingEvent(
await recalculateParticipantQP(participantId, event.sportsSeasonId, db); await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
} }
// NOTE: majorsCompleted is NOT a stored counter. It is derived on read via // Increment majorsCompleted counter (only if this is the first time processing)
// getMajorsCompleted() (count of completed qualifying events). Incrementing here if (!wasAlreadyProcessed) {
// per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was const sportsSeason = event.sportsSeason;
// removed. See app/models/scoring-event.ts:getMajorsCompleted. await db
.update(schema.sportsSeasons)
.set({
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
updatedAt: new Date(),
})
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
}
logger.log( logger.log(
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants` `[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);
}
}
} }
/** /**
@ -1933,17 +1827,17 @@ export async function recalculateAffectedLeagues(
); );
// Build scored matches for the notification. // Build scored matches for the notification.
// A match is worth announcing when: // A match is included only when it is "notable": winner earned points this round OR
// • the winner is owned by a manager AND earned points this round, OR // the loser is notifiable (eliminated / scored). A drafted winner that merely advanced
// • the loser is owned by a manager (eliminated or scored). // without earning points is NOT enough on its own.
// A winning team that merely advanced through a non-scoring round (e.g. R32 → R16 // On any included match, the winning team's owner is shown in the embed (winnerUsername),
// in the World Cup) does not qualify on its own — their owner hasn't earned anything yet. // but Discord-pinged only when they actually scored (winnerDiscordUserId gated on
// When a match qualifies because the loser is owned, the winner's manager tag is still // winnerScoreChanged) — advancing-only wins don't warrant a ping.
// shown for context (who beat them), but the winner is not Discord-pinged. // Losers appear if their score changed or they were definitively eliminated (finalPosition
// Both managers' tags are always shown for context when their teams are drafted; the // set, non-partial); losers who advance to another match (loserAdvances=true, e.g. NBA
// showLoser flag (isLoserNotifiable) only gates whether the loser is @-pinged — a loser // 7v8 → PIR2) are correctly suppressed.
// who advanced rather than being eliminated (loserAdvances=true, e.g. NBA 7v8 → PIR2, or // Only entries with at least one displayable username are kept, so scoredMatches always
// a World Cup semifinal loser) is named but not pinged. // reflects exactly what will be shown in the embed.
let scoredMatches: ScoredMatch[] | undefined; let scoredMatches: ScoredMatch[] | undefined;
if (allCompletedMatches.length > 0) { if (allCompletedMatches.length > 0) {
const relevant = allCompletedMatches.filter( const relevant = allCompletedMatches.filter(
@ -1971,17 +1865,12 @@ export async function recalculateAffectedLeagues(
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds); const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId }; return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId };
}) })
.filter((x) => (x.winnerScoreChanged && !!x.winnerOwnerId) || (x.showLoser && !!x.loserOwnerId)) .filter((x) => x.winnerScoreChanged || x.showLoser)
.map((x) => ({ .map((x) => ({
winnerName: x.m.winnerName ?? "", winnerName: x.m.winnerName ?? "",
loserName: x.m.loserName ?? "", loserName: x.m.loserName ?? "",
winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined, winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined,
// Show the loser's manager tag whenever their team is drafted, mirroring the loserUsername: x.showLoser && x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
// 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,
winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined, winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined,
loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : 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 { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
import type { BracketRegion } from "~/lib/bracket-templates"; import type { BracketRegion } from "~/lib/bracket-templates";
import { recalculateAffectedLeagues } from "./scoring-calculator"; import { recalculateAffectedLeagues } from "./scoring-calculator";
import { recalculateParticipantQP } from "./qualifying-points"; import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
import { findParticipantNamesByIds } from "./season-participant"; import { findParticipantNamesByIds } from "./season-participant";
import { deleteTournament } from "./tournament";
import type { EventType } from "./scoring-event-types"; export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
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 { export interface CreateScoringEventData {
sportsSeasonId: string; 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 * 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 * sports season (placements are stored there with no FK back to the event) and
* recalculates league standings. * 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( export async function deleteScoringEvent(
eventId: string, eventId: string,
providedDb?: ReturnType<typeof database>, providedDb?: ReturnType<typeof database>
opts?: DeleteScoringEventOptions ) {
): Promise<DeleteScoringEventResult> {
const db = providedDb || database(); const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({ const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId), where: eq(schema.scoringEvents.id, eventId),
}); });
if (!event) { if (!event) return;
return {
tournamentId: null,
remainingWindows: 0,
promotedPrimaryId: null,
deletedTournament: false,
};
}
// For qualifying events: capture affected participant IDs before the cascade deletes // For qualifying events: capture affected participant IDs before the cascade deletes
// eventResults (which is how we know who had QP awarded from this event). // eventResults (which is how we know who had QP awarded from this event).
let affectedParticipantIds: string[] = []; let affectedParticipantIds: string[] = [];
let wasQPProcessed = false;
if (event.isQualifyingEvent) { if (event.isQualifyingEvent) {
const results = await db.query.eventResults.findMany({ const results = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, eventId), where: eq(schema.eventResults.scoringEventId, eventId),
}); });
affectedParticipantIds = results.map((r) => r.seasonParticipantId); affectedParticipantIds = results.map((r) => r.seasonParticipantId);
wasQPProcessed = hasProcessedQualifyingPlacement(results);
} }
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
@ -300,49 +255,21 @@ export async function deleteScoringEvent(
await recalculateParticipantQP(participantId, event.sportsSeasonId, db); await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
} }
// majorsCompleted is derived on read (getMajorsCompleted), so deleting the event // Decrement majorsCompleted if this event had already been processed
// — which removes it from the completed-qualifying-event count — self-corrects the if (wasQPProcessed) {
// number. No stored counter to decrement. 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); 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 * 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. * builds the bracket/stages and scoring happens; its results fan out to siblings.

View file

@ -307,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( export async function batchUpsertParticipantSimulatorInputs(
inputs: UpsertParticipantSimulatorInput[] inputs: UpsertParticipantSimulatorInput[]
): Promise<void> { ): Promise<void> {
@ -339,34 +426,16 @@ export async function batchUpsertParticipantSimulatorInputs(
schema.seasonParticipantSimulatorInputs.participantId, schema.seasonParticipantSimulatorInputs.participantId,
schema.seasonParticipantSimulatorInputs.sportsSeasonId, 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: { set: {
sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantSimulatorInputs.sourceOdds})`, sourceOdds: sql`excluded.source_odds`,
sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantSimulatorInputs.sourceElo})`, sourceElo: sql`excluded.source_elo`,
worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantSimulatorInputs.worldRanking})`, worldRanking: sql`excluded.world_ranking`,
rating: sql`COALESCE(excluded.rating, ${schema.seasonParticipantSimulatorInputs.rating})`, rating: sql`excluded.rating`,
projectedWins: sql`COALESCE(excluded.projected_wins, ${schema.seasonParticipantSimulatorInputs.projectedWins})`, projectedWins: sql`excluded.projected_wins`,
projectedTablePoints: sql`COALESCE(excluded.projected_table_points, ${schema.seasonParticipantSimulatorInputs.projectedTablePoints})`, projectedTablePoints: sql`excluded.projected_table_points`,
seed: sql`COALESCE(excluded.seed, ${schema.seasonParticipantSimulatorInputs.seed})`, seed: sql`excluded.seed`,
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`, region: sql`excluded.region`,
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that metadata: sql`excluded.metadata`,
// tell readers whether the stored Elo/rating is generated vs. a trusted
// direct value. When a caller supplies explicit metadata, use it as-is
// (prepareSimulatorInputsForRun and the projection importer set the
// correct flags). Otherwise preserve existing metadata, but drop the
// method flag for any column receiving a fresh direct value — otherwise a
// stale "generated" flag would cause that newly-entered Elo/rating to be
// filtered out as derived (see getParticipantSimulatorInputs).
metadata: sql`CASE
WHEN excluded.metadata IS NOT NULL THEN excluded.metadata
ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
END`,
updatedAt: sql`excluded.updated_at`, updatedAt: sql`excluded.updated_at`,
}, },
}); });

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( export async function updateSportsSeason(
id: string, id: string,
data: Partial<NewSportsSeason> data: Partial<NewSportsSeason>

View file

@ -136,17 +136,3 @@ export async function updateTournamentStatus(
.returning(); .returning();
return tournament; 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("simulators", "routes/admin.simulators.tsx"),
route("sports/new", "routes/admin.sports.new.tsx"), route("sports/new", "routes/admin.sports.new.tsx"),
route("sports/:id", "routes/admin.sports.$id.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", "routes/admin.sports-seasons.tsx"),
route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"), route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"),
route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"), route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"),

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,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 Setup
</Link> </Link>
</Button> </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> <Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link> <Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link>
</Button> </Button>

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

@ -65,10 +65,9 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server"; 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 { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis"; 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) { export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id); const sportsSeason = await findSportsSeasonById(params.id);
@ -136,23 +135,9 @@ async function scoreQualifyingBracket(
tournamentId: string | null; tournamentId: string | null;
}, },
db: ReturnType<typeof database>, db: ReturnType<typeof database>,
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2], 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>
): Promise<void> { ): Promise<void> {
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent), await processQualifyingBracketEvent(event.id, db);
// 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 recalculateAffectedLeagues( await recalculateAffectedLeagues(
event.sportsSeasonId, event.sportsSeasonId,
db, db,
@ -160,10 +145,7 @@ async function scoreQualifyingBracket(
); );
// If this is the shared major's primary window, propagate to siblings. // If this is the shared major's primary window, propagate to siblings.
// Mid-tournament (a single round): don't mark complete yet. // Mid-tournament (a single round): don't mark complete yet.
await fanOutMajorIfPrimary(event, { await fanOutMajorIfPrimary(event, { markComplete: false });
markComplete: false,
newlyEliminatedParticipantIds,
});
} }
/** /**
@ -416,13 +398,6 @@ export async function action({ request, params }: Route.ActionArgs) {
return { error: "Could not determine loser" }; 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 // Set the winner
await setMatchWinner(matchId, winnerId, loserId); await setMatchWinner(matchId, winnerId, loserId);
@ -449,16 +424,11 @@ export async function action({ request, params }: Route.ActionArgs) {
if (event.isQualifyingEvent) { if (event.isQualifyingEvent) {
// Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not // Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not
// fantasy points. matchIds scopes the Discord notification to just this match. // fantasy points. matchIds scopes the Discord notification to just this match.
await scoreQualifyingBracket( await scoreQualifyingBracket(event, db, {
event, eventId: event.id,
db, eventName: event.name ?? undefined,
{ matchIds: [matchId],
eventId: event.id, });
eventName: event.name ?? undefined,
matchIds: [matchId],
},
setWinnerNewlyEliminated
);
} else { } else {
// Immediately score this match: loser gets their final placement, // Immediately score this match: loser gets their final placement,
// winner gets provisional floor points (isPartialScore=true). // winner gets provisional floor points (isPartialScore=true).
@ -530,10 +500,6 @@ export async function action({ request, params }: Route.ActionArgs) {
let successCount = 0; let successCount = 0;
const errors: string[] = []; const errors: string[] = [];
const processedMatchIds: 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) { for (const { matchId, winnerId } of winnerAssignments) {
try { try {
@ -602,10 +568,6 @@ export async function action({ request, params }: Route.ActionArgs) {
successCount++; successCount++;
processedMatchIds.push(matchId); 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) { } catch (error) {
logger.error(`Error setting winner for match ${matchId}:`, error); logger.error(`Error setting winner for match ${matchId}:`, error);
errors.push( errors.push(
@ -619,14 +581,9 @@ export async function action({ request, params }: Route.ActionArgs) {
// previously completed matches in the event. // previously completed matches in the event.
if (successCount > 0) { if (successCount > 0) {
const db = database(); const db = database();
// Qualifying majors: derive QP from the full bracket once for the batch, and // Qualifying majors: derive QP from the full bracket once for the batch.
// 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.
if (event.isQualifyingEvent) { if (event.isQualifyingEvent) {
await processQualifyingEvent(event.id, db, { await processQualifyingBracketEvent(event.id, db);
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
});
} }
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs // Update probabilities first so recalculateAffectedLeagues reads fresh EVs
// when computing projected points. // when computing projected points.
@ -641,12 +598,8 @@ export async function action({ request, params }: Route.ActionArgs) {
if (!event.isQualifyingEvent) { if (!event.isQualifyingEvent) {
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
} else { } else {
// Shared major primary window: propagate this round to siblings, carrying // Shared major primary window: propagate this round to siblings.
// the batch's newly-decided knockouts so mirrors announce them too. await fanOutMajorIfPrimary(event, { markComplete: false });
await fanOutMajorIfPrimary(event, {
markComplete: false,
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
});
} }
} }
@ -810,32 +763,11 @@ export async function action({ request, params }: Route.ActionArgs) {
// skipDiscord: reprocess is a data-correction tool, not a result announcement. // skipDiscord: reprocess is a data-correction tool, not a result announcement.
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true }); await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
} }
// Re-propagate corrected QP to sibling/mirror windows (data-correction; not // Re-propagate corrected QP to sibling windows (data-correction; not final).
// final). Call syncMajorFromPrimaryEvent directly rather than the await fanOutMajorIfPrimary(event, { markComplete: false });
// swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the return {
// mirrors were re-scored — a silent failure here is exactly how mirrors got success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`,
// 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("; ");
return {
error: `${baseMessage} Synced ${report.windowsSynced} mirror window(s), but ${report.windowsFailed} failed (those windows may be stale): ${reasons}`,
};
}
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) { if (completed.length === 0) {
@ -950,11 +882,9 @@ export async function action({ request, params }: Route.ActionArgs) {
// fantasy placements come from finalizeQualifyingPoints across all of them. // fantasy placements come from finalizeQualifyingPoints across all of them.
if (event.isQualifyingEvent) { if (event.isQualifyingEvent) {
const db = database(); const db = database();
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent) // processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
// and recalcs participant QP totals. majorsCompleted is derived on read from // increments majorsCompleted, and recalcs participant QP totals. Season-wide
// completed qualifying events (see getMajorsCompleted) — marking this event // fantasy finalization stays with finalizeQualifyingPoints across all majors.
// complete below is what advances it. Season-wide fantasy finalization stays with
// finalizeQualifyingPoints across all majors.
await processQualifyingEvent(params.eventId, db); await processQualifyingEvent(params.eventId, db);
await db await db
.update(schema.scoringEvents) .update(schema.scoringEvents)

View file

@ -21,7 +21,7 @@ import {
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets, Save } from "lucide-react"; 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 { isBracketMajor } from "~/lib/event-utils";
import { import {
Table, Table,

View file

@ -8,8 +8,6 @@ import {
deleteScoringEvent, deleteScoringEvent,
bulkCreateScoringEvents, bulkCreateScoringEvents,
ensurePrimaryEvent, ensurePrimaryEvent,
countWindowsByTournament,
getMajorsCompleted,
type CreateScoringEventData, type CreateScoringEventData,
} from "~/models/scoring-event"; } from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils"; import { isBracketMajor } from "~/lib/event-utils";
@ -29,14 +27,10 @@ export async function loader({ params }: Route.LoaderArgs) {
const events = await getScoringEventsForSportsSeason(params.id); const events = await getScoringEventsForSportsSeason(params.id);
// For qualifying sports seasons, get QP standings with global ranks attached. // 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.
let qpStandings = null; let qpStandings = null;
const scoringRules = null; const scoringRules = null;
let majorsCompleted = 0;
if (sportsSeason.scoringPattern === "qualifying_points") { if (sportsSeason.scoringPattern === "qualifying_points") {
majorsCompleted = await getMajorsCompleted(params.id);
const standings = await getQPStandings(params.id); const standings = await getQPStandings(params.id);
let prevQP = -1; let prevQP = -1;
let prevRankStart = 1; let prevRankStart = 1;
@ -65,38 +59,11 @@ export async function loader({ params }: Route.LoaderArgs) {
); );
const availableTournaments = allTournamentsForSport.filter((t) => !linkedTournamentIds.has(t.id)); 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 { return {
sportsSeason: { ...sportsSeason, majorsCompleted } as typeof sportsSeason & { sportsSeason: sportsSeason as typeof sportsSeason & {
sport: { id: string; name: string; type: string; slug: string }; sport: { id: string; name: string; type: string; slug: string };
}, },
events: eventsWithSharing, events,
qpStandings, qpStandings,
scoringRules, scoringRules,
availableTournaments, availableTournaments,
@ -248,19 +215,8 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
try { try {
const result = await deleteScoringEvent(eventId, undefined, { await deleteScoringEvent(eventId);
deleteOrphanTournament: formData.get("deleteTournament") === "1", return { success: "Event deleted successfully" };
});
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 };
} catch (error) { } catch (error) {
logger.error("Error deleting event:", error); logger.error("Error deleting event:", error);
return { error: "Failed to delete event" }; return { error: "Failed to delete event" };

View file

@ -37,7 +37,7 @@ import {
import { Calendar, Trophy, ArrowLeft, Trash2, ListPlus } from "lucide-react"; import { Calendar, Trophy, ArrowLeft, Trash2, ListPlus } from "lucide-react";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings"; 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 { export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Events — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }]; return [{ title: `Events — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
@ -305,11 +305,7 @@ export default function SportsSeasonEvents({
</p> </p>
) : ( ) : (
<div className="space-y-3"> <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 }) => { {events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean }) => (
const isLinked = !!event.tournamentId;
const hasOtherWindows = event.otherWindowCount > 0;
const isLastWindow = isLinked && !hasOtherWindows;
return (
<Card key={event.id} className="hover:border-primary/50 transition-colors"> <Card key={event.id} className="hover:border-primary/50 transition-colors">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
@ -356,65 +352,28 @@ export default function SportsSeasonEvents({
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<Form method="post"> <AlertDialogHeader>
<input type="hidden" name="intent" value="delete-event" /> <AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
<input type="hidden" name="eventId" value={event.id} /> <AlertDialogDescription>
<AlertDialogHeader> This will also delete all results for this event. This action cannot be undone.
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle> </AlertDialogDescription>
<AlertDialogDescription asChild> </AlertDialogHeader>
{!isLinked ? ( <AlertDialogFooter>
<span> <AlertDialogCancel>Cancel</AlertDialogCancel>
This will also delete all results for this event. This action cannot be undone. <Form method="post">
</span> <input type="hidden" name="intent" value="delete-event" />
) : hasOtherWindows ? ( <input type="hidden" name="eventId" value={event.id} />
<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"> <AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </Form>
</Form> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); ))}
})}
</div> </div>
)} )}
</CardContent> </CardContent>

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'; import type { Route } from './+types/admin.sports-seasons.$id.futures-odds';
// Futures odds entry has been consolidated into the Bulk Simulator Inputs card on import { logger } from '~/lib/logger';
// the simulator setup page (paste sportsbook lines like `Chiefs +450` or a CSV with import { findSportsSeasonById } from '~/models/sports-season';
// a sourceOdds column). This route now just redirects old bookmarks/links there. import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
export async function loader({ params }: Route.LoaderArgs) { import { getAllParticipantEVsForSeason, batchSaveSourceOdds, clearSourceOddsForParticipants } from '~/models/participant-expected-value';
return redirect(`/admin/sports-seasons/${params.id}/simulator`); 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

@ -1,4 +1,3 @@
import { useMemo, useState } from "react";
import { Form, Link, redirect, useActionData, useNavigation } from "react-router"; import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.simulator"; import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
@ -26,10 +25,6 @@ import {
type UpsertParticipantSimulatorInput, type UpsertParticipantSimulatorInput,
} from "~/models/simulator"; } from "~/models/simulator";
import { normalizeName } from "~/lib/fuzzy-match"; import { normalizeName } from "~/lib/fuzzy-match";
import {
simulatorInputLabel,
type SimulatorInputKey,
} from "~/services/simulations/manifest";
import { import {
getSimulatorInputPolicy, getSimulatorInputPolicy,
type MissingEloStrategy, type MissingEloStrategy,
@ -66,22 +61,7 @@ export async function loader({ params }: Route.LoaderArgs) {
const inputPolicy = getSimulatorInputPolicy(config.config); const inputPolicy = getSimulatorInputPolicy(config.config);
// Sport-aware preview columns: the intersection of the displayable numeric keys return { sportsSeason, participants, config, inputRows, readiness, inputPolicy };
// 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,
]);
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
key,
label: simulatorInputLabel(key),
required: config.profile.requiredInputs.includes(key),
}));
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy, inputColumns };
} }
interface ActionData { interface ActionData {
@ -89,43 +69,6 @@ interface ActionData {
message: string; 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",
]);
function parseOptionalNumber(value: string | undefined): number | null { function parseOptionalNumber(value: string | undefined): number | null {
if (value === undefined || value.trim() === "") return null; if (value === undefined || value.trim() === "") return null;
const parsed = Number(value); const parsed = Number(value);
@ -167,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( function parseInputCsv(
text: string, text: string,
sportsSeasonId: string, sportsSeasonId: string,
@ -212,10 +122,7 @@ function parseInputCsv(
const indexes = new Map(header.map((value, index) => [value, index])); const indexes = new Map(header.map((value, index) => [value, index]));
const nameIndex = indexes.get("name"); const nameIndex = indexes.get("name");
if (nameIndex === undefined) { if (nameIndex === undefined) {
// No CSV header — treat the paste as sportsbook futures odds, one team per throw new Error("Bulk input CSV must include a `name` column.");
// 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);
} }
const inputs: UpsertParticipantSimulatorInput[] = []; const inputs: UpsertParticipantSimulatorInput[] = [];
@ -289,48 +196,33 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
} }
} }
if (intent === "save-configuration") { if (intent === "save-input-policy") {
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId); const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
if (!currentConfig) return { success: false, message: "Simulator config not found." }; if (!currentConfig) return { success: false, message: "Simulator config not found." };
// Start from the current merged config so keys not exposed as structured const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
// 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,
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax),
fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating),
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
};
}
await upsertSportsSeasonSimulatorConfig({ await upsertSportsSeasonSimulatorConfig({
sportsSeasonId, sportsSeasonId,
simulatorType: currentConfig.simulatorType, simulatorType: currentConfig.simulatorType,
config: next, config: {
...currentConfig.config,
inputPolicy: {
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax),
fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating),
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
},
},
}); });
return { success: true, message: "Simulator configuration saved." }; return { success: true, message: "Simulator input policy saved." };
} }
if (intent === "save-inputs") { if (intent === "save-inputs") {
@ -349,26 +241,7 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
const suffix = parsed.unmatched.length > 0 const suffix = parsed.unmatched.length > 0
? ` ${parsed.unmatched.length} row(s) were unmatched: ${parsed.unmatched.join(", ")}.` ? ` ${parsed.unmatched.length} row(s) were unmatched: ${parsed.unmatched.join(", ")}.`
: ""; : "";
const saved = `Saved ${parsed.inputs.length} simulator input row(s).${suffix}`; return { success: true, message: `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}` };
}
} catch (error) { } catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to save simulator inputs." }; return { success: false, message: error instanceof Error ? error.message : "Failed to save simulator inputs." };
} }
@ -388,49 +261,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const showsInputPolicy = const showsInputPolicy =
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating"); 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 } = 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;
const isRowIncomplete = (input: (typeof inputRows)[number]["input"]) =>
requiredInputs.some((key) => input?.[key] === null || input?.[key] === undefined);
const [search, setSearch] = useState("");
const [onlyMissing, setOnlyMissing] = useState(false);
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(input)) return false;
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputRows, search, onlyMissing, requiredInputs]);
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 ( return (
<div className="container mx-auto p-6 space-y-6"> <div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -504,7 +334,8 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
)} )}
<div className="flex flex-wrap gap-2"> <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("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("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>} {setupSections.includes("regularStandings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/regular-standings`}>Regular Standings</Link></Button>}
@ -522,164 +353,130 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</CardContent> </CardContent>
</Card> </Card>
<Card> {showsInputPolicy && (
<CardHeader> <Card>
<CardTitle>Simulator Configuration</CardTitle> <CardHeader>
<CardDescription> <CardTitle>Input Policy</CardTitle>
One place for this season's settings. <strong>Engine</strong> controls how the Monte Carlo <CardDescription>
runs; <strong>Input derivation</strong> controls how raw inputs become the single Elo/rating Direct inputs win. This simulator can derive Elo from{" "}
the engine consumes. Defaults come from the simulator profile; values set here override them {sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"}
for this season only. Both sections write the same stored config. {ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}.
</CardDescription> Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
</CardHeader> </CardDescription>
<CardContent className="space-y-6"> </CardHeader>
<Form method="post" className="space-y-6"> <CardContent>
<input type="hidden" name="intent" value="save-configuration" /> <Form method="post" className="grid gap-4 md:grid-cols-5">
{showsInputPolicy && <input type="hidden" name="hasInputPolicy" value="1" />} <input type="hidden" name="intent" value="save-input-policy" />
<div className="space-y-2 md:col-span-5">
<section className="space-y-3"> <Label htmlFor="oddsWeight">Futures vs. Elo Odds Blend Weight (01)</Label>
<div> <Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
<h3 className="text-sm font-semibold">Engine</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
How the simulation runs. A higher <code>parityFactor</code> flattens the finish-position Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into
distribution (favorites win less often); <code>iterations</code> trades speed for precision. 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).
</p> </p>
</div> </div>
{engineEntries.length > 0 ? ( {config.profile.requiredInputs.includes("sourceElo") && (
<div className="grid gap-4 md:grid-cols-3"> <>
{engineEntries.map(([key, value]) => ( <div className="space-y-2 md:col-span-2">
<div key={key} className="space-y-2"> <Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
<Label htmlFor={`engine.${key}`} className="font-mono text-xs">{key}</Label> <select
<Input id={`engine.${key}`} name={`engine.${key}`} type="number" step="any" defaultValue={value} /> id="missingEloStrategy"
</div> name="missingEloStrategy"
))} className="h-9 w-full rounded-md border bg-background px-3 text-sm"
</div> defaultValue={inputPolicy.missingEloStrategy}
) : ( >
<p className="text-xs text-muted-foreground">This simulator exposes no numeric engine knobs.</p> <option value="block">Block until complete</option>
<option value="fallbackElo">Use fixed fallback Elo</option>
<option value="averageKnown">Use average known Elo</option>
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackElo">Fallback Elo</Label>
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackEloDelta">Worst Elo Delta</Label>
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMin">Elo Floor</Label>
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMax">Elo Ceiling</Label>
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
</div>
</>
)} )}
</section> {config.profile.requiredInputs.includes("rating") && (
<>
{showsInputPolicy && ( <div className="space-y-2 md:col-span-2">
<section className="space-y-3"> <Label htmlFor="missingRatingStrategy">Missing Rating Strategy</Label>
<div> <select
<h3 className="text-sm font-semibold">Input derivation</h3> id="missingRatingStrategy"
<p className="text-xs text-muted-foreground"> name="missingRatingStrategy"
Direct inputs win. This simulator can derive Elo from{" "} className="h-9 w-full rounded-md border bg-background px-3 text-sm"
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"} defaultValue={inputPolicy.missingRatingStrategy}
{ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}. >
Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings. <option value="block">Block until complete</option>
</p> <option value="fallbackRating">Use fixed fallback rating</option>
</div> <option value="averageKnown">Use average known rating</option>
<div className="grid gap-4 md:grid-cols-5"> <option value="worstKnownMinus">Use worst known rating minus delta</option>
<div className="space-y-2 md:col-span-5"> </select>
<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} />
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
{config.profile.requiredInputs.includes("sourceElo") && (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
<select
id="missingEloStrategy"
name="missingEloStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={inputPolicy.missingEloStrategy}
>
<option value="block">Block until complete</option>
<option value="fallbackElo">Use fixed fallback Elo</option>
<option value="averageKnown">Use average known Elo</option>
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackElo">Fallback Elo</Label>
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackEloDelta">Worst Elo Delta</Label>
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMin">Elo Floor</Label>
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMax">Elo Ceiling</Label>
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
</div>
</>
)}
{config.profile.requiredInputs.includes("rating") && (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="missingRatingStrategy">Missing Rating Strategy</Label>
<select
id="missingRatingStrategy"
name="missingRatingStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={inputPolicy.missingRatingStrategy}
>
<option value="block">Block until complete</option>
<option value="fallbackRating">Use fixed fallback rating</option>
<option value="averageKnown">Use average known rating</option>
<option value="worstKnownMinus">Use worst known rating minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackRating">Fallback Rating</Label>
<Input id="fallbackRating" name="fallbackRating" type="number" step="0.01" defaultValue={inputPolicy.fallbackRating} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackRatingDelta">Worst Rating Delta</Label>
<Input id="fallbackRatingDelta" name="fallbackRatingDelta" type="number" step="0.01" defaultValue={inputPolicy.fallbackRatingDelta} />
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="ratingMin">Rating Floor</Label>
<Input id="ratingMin" name="ratingMin" type="number" step="0.01" defaultValue={inputPolicy.ratingMin} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ratingMax">Rating Ceiling</Label> <Label htmlFor="fallbackRating">Fallback Rating</Label>
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} /> <Input id="fallbackRating" name="fallbackRating" type="number" step="0.01" defaultValue={inputPolicy.fallbackRating} />
</div> </div>
</div> <div className="space-y-2">
</section> <Label htmlFor="fallbackRatingDelta">Worst Rating Delta</Label>
)} <Input id="fallbackRatingDelta" name="fallbackRatingDelta" type="number" step="0.01" defaultValue={inputPolicy.fallbackRatingDelta} />
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="ratingMin">Rating Floor</Label>
<Input id="ratingMin" name="ratingMin" type="number" step="0.01" defaultValue={inputPolicy.ratingMin} />
</div>
<div className="space-y-2">
<Label htmlFor="ratingMax">Rating Ceiling</Label>
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
</div>
<div className="md:col-span-5">
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
Save Input Policy
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
<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"
rows={10}
className="font-mono text-sm"
defaultValue={JSON.stringify(config.config, null, 2)}
/>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
Save Configuration Save Config
</Button> </Button>
</Form> </Form>
<details>
<summary className="cursor-pointer text-sm font-medium">Advanced: edit raw config JSON</summary>
<Form method="post" className="space-y-3 mt-3">
<input type="hidden" name="intent" value="save-config" />
<Textarea
name="config"
rows={10}
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}>
<Save className="mr-2 h-4 w-4" />
Save Raw JSON
</Button>
</Form>
</details>
</CardContent> </CardContent>
</Card> </Card>
@ -687,12 +484,8 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
<CardHeader> <CardHeader>
<CardTitle>Bulk Simulator Inputs</CardTitle> <CardTitle>Bulk Simulator Inputs</CardTitle>
<CardDescription> <CardDescription>
Two ways to paste, then the simulation re-runs automatically on save: Paste CSV with a header row. Supported columns: name, sourceElo, sourceOdds, worldRanking, rating,
<strong> CSV</strong> with a header row supported columns: name, sourceElo, sourceOdds, projectedWins, projectedTablePoints, seed, region. Values must not contain commas.
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.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@ -702,7 +495,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
name="bulkInputs" name="bulkInputs"
rows={8} rows={8}
className="font-mono text-sm" 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}> <Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
@ -710,124 +503,28 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</Button> </Button>
</Form> </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="rounded-md border">
<div <div className="grid grid-cols-6 gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
className="grid gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground" <div className="col-span-2">Participant</div>
style={{ gridTemplateColumns: gridTemplate }} <div>Elo</div>
> <div>Odds</div>
<div>Participant</div> <div>Rank</div>
{inputColumns.length > 0 ? ( <div>Rating</div>
inputColumns.map((column) => (
<div key={column.key} className="capitalize">
{column.label}
{column.required && <span className="text-amber-500"> *</span>}
</div>
))
) : (
<div></div>
)}
</div> </div>
{pageRows.length === 0 ? ( {inputRows.slice(0, 20).map(({ participant, input }) => (
<div className="px-3 py-6 text-center text-sm text-muted-foreground"> <div key={participant.id} className="grid grid-cols-6 gap-2 border-b last:border-b-0 px-3 py-2 text-sm">
No participants match. <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>
))}
{inputRows.length > 20 && (
<div className="px-3 py-2 text-xs text-muted-foreground">
Showing 20 of {inputRows.length} participants
</div> </div>
) : (
pageRows.map(({ participant, input }) => {
const incomplete = isRowIncomplete(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) => {
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>
</div>
)}
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -675,6 +675,14 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
<Calculator className="mr-2 h-4 w-4" /> <Calculator className="mr-2 h-4 w-4" />
Simulator Setup Simulator Setup
</Button> </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 <Button
size="sm" size="sm"
variant="outline" variant="outline"

View file

@ -21,7 +21,6 @@ import {
getSportsSeasonsByTournament, getSportsSeasonsByTournament,
getPrimaryEventForTournament, getPrimaryEventForTournament,
setPrimaryEvent, setPrimaryEvent,
deleteScoringEvent,
} from "~/models/scoring-event"; } from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils"; import { isBracketMajor } from "~/lib/event-utils";
import { import {
@ -31,17 +30,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } 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 { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { 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 { return {
success: false as const, success: false as const,
error: "Invalid intent", error: "Invalid intent",
@ -268,7 +236,6 @@ export default function AdminTournamentDetail({
} = loaderData; } = loaderData;
const retryFetcher = useFetcher<typeof action>(); const retryFetcher = useFetcher<typeof action>();
const primaryFetcher = 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. // For bracket majors, scoring lives on the primary window's bracket/stage UI.
const primaryScoringHref = const primaryScoringHref =
@ -452,51 +419,6 @@ export default function AdminTournamentDetail({
> >
{link.sportsSeason.status} {link.sportsSeason.status}
</Badge> </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>
</div> </div>
))} ))}

View file

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

View file

@ -14,7 +14,6 @@ import { getQPStandings } from "~/models/qualifying-points";
import { import {
getUpcomingScoringEvents, getUpcomingScoringEvents,
getRecentCompletedEvents, getRecentCompletedEvents,
getMajorsCompleted,
} from "~/models/scoring-event"; } from "~/models/scoring-event";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value"; import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
@ -158,7 +157,6 @@ export async function loader(args: Route.LoaderArgs) {
let seasonStandings: SeasonStanding[] = []; let seasonStandings: SeasonStanding[] = [];
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number] & { globalRank: number }; type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number] & { globalRank: number };
let qpStandings: QPStanding[] = []; let qpStandings: QPStanding[] = [];
let majorsCompleted = 0;
// Group standings for group-stage events (e.g. FIFA World Cup) // 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; 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") { } 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); const standings = await getQPStandings(sportsSeasonId);
// Compute global ranks with tie handling across the full field before filtering, // 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. // 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 { return {
league, league,
season, season,
sportsSeason: { ...sportsSeason, majorsCompleted }, sportsSeason,
scoringPattern, scoringPattern,
playoffMatches, playoffMatches,
playoffRounds, playoffRounds,

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; 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"; 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)"); expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
}); });
it("names a non-eliminated loser for context without @-pinging them", async () => { it("shows winner's owner even when winner only advanced (loser was eliminated)", async () => {
// Argentina beats England in the World Cup semifinal. Argentina scored, so it's // Mirrors the Brazil/Japan scenario: Brazil advanced without earning points,
// pinged; England advances to the 3rd-place playoff (not eliminated, no points // Japan was eliminated. winnerUsername is shown in the embed; winnerDiscordUserId
// change), so its manager is shown by plain username but NOT @-pinged. // is left unset (no ping) because the winner did not score this round.
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.
await sendStandingsUpdateNotification({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026", seasonName: "Rumble League 2026",
@ -230,14 +199,14 @@ describe("sendStandingsUpdateNotification", () => {
{ {
winnerName: "Brazil", winnerName: "Brazil",
loserName: "Japan", loserName: "Japan",
winnerUsername: "aliceManager", winnerUsername: "someManager",
loserUsername: "ikyn", loserUsername: "ikyn",
}, },
], ],
}); });
const desc = getDescription(); 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 () => { it("omits scored matches where neither side has a username", async () => {
@ -308,43 +277,6 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).not.toContain("Gamma"); 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 () => { it("omits rank delta when no previousRanks provided", async () => {
await sendStandingsUpdateNotification({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, 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", () => { describe("sendPickAnnouncementNotification", () => {
const BASE = { const BASE = {
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,

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 { describe, it, expect, vi, beforeEach } from "vitest";
import type { Mock } from "vitest";
import type * as DrizzleOrm from "drizzle-orm"; import type * as DrizzleOrm from "drizzle-orm";
import type * as ScoringCalculatorModule from "~/models/scoring-calculator";
vi.mock("~/database/context", () => ({ vi.mock("~/database/context", () => ({
database: vi.fn(), database: vi.fn(),
})); }));
vi.mock("~/models/scoring-calculator", async () => { vi.mock("~/models/scoring-calculator", () => ({
// Keep the real implementations of the PURE helpers so the tie-count map handed processQualifyingEvent: vi.fn(),
// to processQualifyingEvent reflects the mock canonical results AND the bracket's recalculateAffectedLeagues: vi.fn(),
// structural tie span (deriveBracketQualifyingStates / getRoundConfig). Only the }));
// DB-touching orchestrators are stubbed.
const actual = await vi.importActual<typeof ScoringCalculatorModule>(
"~/models/scoring-calculator"
);
return {
processQualifyingEvent: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
buildTieCountByPlacement: actual.buildTieCountByPlacement,
deriveBracketQualifyingStates: actual.deriveBracketQualifyingStates,
getRoundConfig: actual.getRoundConfig,
};
});
vi.mock("~/models/scoring-event", () => ({ vi.mock("~/models/scoring-event", () => ({
completeScoringEvent: vi.fn(), completeScoringEvent: vi.fn(),
@ -103,21 +89,11 @@ interface FakeEventResult {
// - event_results // - event_results
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface FakePlayoffMatch {
scoringEventId: string;
round: string;
winnerId: string | null;
loserId: string | null;
participant1Id: string | null;
participant2Id: string | null;
}
interface FakeDbState { interface FakeDbState {
tournamentResults: FakeTournamentResult[]; tournamentResults: FakeTournamentResult[];
scoringEvents: FakeScoringEvent[]; scoringEvents: FakeScoringEvent[];
seasonParticipants: FakeSeasonParticipant[]; seasonParticipants: FakeSeasonParticipant[];
eventResults: FakeEventResult[]; eventResults: FakeEventResult[];
playoffMatches: FakePlayoffMatch[];
} }
/** /**
@ -165,8 +141,6 @@ function makeFakeDb(state: FakeDbState) {
return state.seasonParticipants; return state.seasonParticipants;
case "event_results": case "event_results":
return state.eventResults; return state.eventResults;
case "playoff_matches":
return state.playoffMatches;
default: default:
throw new Error(`Unknown table in fake db: ${name}`); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
and: (...preds: any[]) => (row: any) => preds.every((p) => p(row)), 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: [], scoringEvents: [],
seasonParticipants: [], seasonParticipants: [],
eventResults: [], eventResults: [],
playoffMatches: [],
...overrides, ...overrides,
}; };
} }
@ -393,10 +360,7 @@ describe("syncTournamentResults", () => {
expect(c?.qualifyingPointsAwarded).toBe("0"); expect(c?.qualifyingPointsAwarded).toBe("0");
expect(processQualifyingEvent).toHaveBeenCalledTimes(1); expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db, { expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db);
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -448,14 +412,8 @@ describe("syncTournamentResults", () => {
expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3); expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);
expect(processQualifyingEvent).toHaveBeenCalledTimes(2); expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, { expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db);
skipNotifications: false, expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db);
canonicalTieCountByPlacement: expect.any(Map),
});
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -765,10 +723,7 @@ describe("syncTournamentResults", () => {
expect(report.windowsSynced).toBe(1); expect(report.windowsSynced).toBe(1);
expect(processQualifyingEvent).toHaveBeenCalledTimes(1); expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db, { expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db);
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
// No event_results written for the skipped primary window. // No event_results written for the skipped primary window.
expect( expect(
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY") state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
@ -859,214 +814,6 @@ describe("syncMajorFromPrimaryEvent", () => {
expect(completeScoringEvent).not.toHaveBeenCalled(); 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 () => { it("throws when the primary event is not linked to a tournament", async () => {
const db = makeFakeDb(seedBasicState()); const db = makeFakeDb(seedBasicState());
vi.mocked(database).mockReturnValue(db as never); vi.mocked(database).mockReturnValue(db as never);

View file

@ -63,17 +63,6 @@ function escapeMarkdown(text: string): string {
return text.replace(/[_*~`|\\]/g, "\\$&"); 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 { export interface StandingEntry {
teamId: string; teamId: string;
teamName: string; teamName: string;
@ -176,19 +165,13 @@ export async function sendStandingsUpdateNotification({
const isTied = buildTiedRankChecker(standings.map((s) => s.rank)); const isTied = buildTiedRankChecker(standings.map((s) => s.rank));
const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${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. // Standings changes section — show teams whose points or rank changed.
const changedTeams = standings.filter((s) => { 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 prevRank = previousRanks?.get(s.teamId);
const rankChanged = prevRank !== undefined && prevRank !== s.rank; const rankChanged = prevRank !== undefined && prevRank !== s.rank;
return pointsChanged(s) || rankChanged; return pointsChanged || rankChanged;
}); });
if (changedTeams.length > 0) { if (changedTeams.length > 0) {
@ -213,7 +196,7 @@ export async function sendStandingsUpdateNotification({
} }
const escapedName = escapeMarkdown(s.teamName); const escapedName = escapeMarkdown(s.teamName);
const managerLabel = s.discordUserId && pointsChanged(s) const managerLabel = s.discordUserId
? `<@${s.discordUserId}>` ? `<@${s.discordUserId}>`
: s.username : s.username
? escapeMarkdown(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. // Collect Discord user IDs of all opted-in managers appearing in this notification.
const pingUserIds = new Set<string>(); const pingUserIds = new Set<string>();
for (const s of changedTeams) { 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 ?? []) { for (const m of relevantMatches ?? []) {
if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId); if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
@ -248,7 +231,7 @@ export async function sendStandingsUpdateNotification({
{ {
title: `📊 Standings Update — ${seasonName}`, title: `📊 Standings Update — ${seasonName}`,
description, description,
color: 0xffd700, // Gold color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" }, footer: { text: "brackt.com" },
}, },
], ],
@ -264,167 +247,6 @@ export async function sendStandingsUpdateNotification({
await sendDiscordWebhook(webhookUrl, payload); 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({ export async function sendPickAnnouncementNotification({
webhookUrl, webhookUrl,
draftUrl, 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

@ -23,11 +23,7 @@ import {
recalculateAffectedLeagues, recalculateAffectedLeagues,
} from "~/models/scoring-calculator"; } from "~/models/scoring-calculator";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results"; import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server"; import { recalculateParticipantQP } from "~/models/qualifying-points";
import {
recalculateParticipantQP,
diffChangedQualifyingPoints,
} from "~/models/qualifying-points";
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event"; import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import { findMatchingTeamName, normalizeTeamName } from "~/lib/normalize-team-name"; import { findMatchingTeamName, normalizeTeamName } from "~/lib/normalize-team-name";
import { buildUnmatchedTeamResolutionView } from "~/lib/unmatched-team-reconciliation"; import { buildUnmatchedTeamResolutionView } from "~/lib/unmatched-team-reconciliation";
@ -593,93 +589,23 @@ export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
}); });
} }
const { written, completed, newlyDecidedLoserIds } = await populateBracketFromDraw( const { written, completed } = await populateBracketFromDraw(eventId, resolvedMatches);
eventId,
resolvedMatches,
);
// ---- Score (qualifying points) + fan out to siblings ---------------------- // ---- Score (qualifying points) + fan out to siblings ----------------------
// Re-derive QP from the bracket and learn which participants' QP changed so the // The tennis bracket is the sole QP source for its event, so reconcile stale
// Discord notification below only announces new/changed results on a re-sync. // rows: snapshot existing result rows, clear them, re-derive from the bracket,
const changedParticipantIds = await rescoreTennisBracketAndDetectChanges( // then recalc QP totals for any participant who no longer earns points (e.g.
eventId, // early-round losers — only Round of 16+ losers score). writeEventResultsQP
sportsSeasonId, // upserts but never deletes, so without this a previously mis-scored player
db, // would keep phantom QP across re-syncs.
);
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.
// //
// Fire even when no QP changed, so an early-round elimination is still surfaced. // Wrapped in a transaction so a mid-rescore failure can't leave the event with
if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) { // its results deleted and not rebuilt. NOTE: this clears ALL event_results for
try { // the event, including any manually-entered rows (e.g. a notParticipating
await notifyQualifyingPointsUpdate( // withdrawal) — acceptable because the synced draw is authoritative for tennis.
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>();
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const beforeRows = await tx const beforeRows = await tx
.select({ .select({ pid: schema.eventResults.seasonParticipantId })
id: schema.eventResults.seasonParticipantId,
qp: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults) .from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId)); .where(eq(schema.eventResults.scoringEventId, eventId));
await tx.delete(schema.eventResults).where(eq(schema.eventResults.scoringEventId, eventId)); await tx.delete(schema.eventResults).where(eq(schema.eventResults.scoringEventId, eventId));
@ -687,20 +613,23 @@ export async function rescoreTennisBracketAndDetectChanges(
await processQualifyingBracketEvent(eventId, tx); await processQualifyingBracketEvent(eventId, tx);
const afterRows = await tx const afterRows = await tx
.select({ .select({ pid: schema.eventResults.seasonParticipantId })
id: schema.eventResults.seasonParticipantId,
qp: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults) .from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId)); .where(eq(schema.eventResults.scoringEventId, eventId));
const afterIds = new Set(afterRows.map((r) => r.id)); const afterIds = new Set(afterRows.map((r) => r.pid));
for (const { pid } of beforeRows) {
for (const { id } of beforeRows) { if (!afterIds.has(pid)) await recalculateParticipantQP(pid, sportsSeasonId, tx);
if (!afterIds.has(id)) await recalculateParticipantQP(id, 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

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

@ -119,13 +119,6 @@ describe("eloWinProbability (PARITY_FACTOR = 1000)", () => {
const p = eloWinProbability(1700, 1500); const p = eloWinProbability(1700, 1500);
expect(p).toBeCloseTo(0.613, 2); expect(p).toBeCloseTo(0.613, 2);
}); });
it("a higher parity factor flattens the per-game win probability toward 0.5", () => {
const standard = eloWinProbability(1700, 1500); // default parity 1000
const flatter = eloWinProbability(1700, 1500, 2500); // season config override
expect(flatter).toBeLessThan(standard);
expect(flatter).toBeGreaterThan(0.5);
});
}); });
// ─── simulateProjectedPoints ────────────────────────────────────────────────── // ─── simulateProjectedPoints ──────────────────────────────────────────────────

View file

@ -69,22 +69,20 @@ import { normalizeTeamName } from "~/lib/normalize-team-name";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { eloWinProbabilityWithParity } from "~/services/probability-engine"; import { eloWinProbabilityWithParity } from "~/services/probability-engine";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ─────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 10_000; const NUM_SIMULATIONS = 10_000;
/** /**
* Elo parity factor for AFL single-game win probability. * Elo parity factor for AFL single-game win probability.
* 450 reflects moderate variance lower than NHL (1000) to account for * 450 reflects moderate variance lower than NHL (1000) to account for
* AFL's relatively predictable results vs. basketball's coin-flip tendencies. * AFL's relatively predictable results vs. basketball's coin-flip tendencies.
* Overridable via the season config's `parityFactor`.
*/ */
const DEFAULT_PARITY_FACTOR = 450; const PARITY_FACTOR = 450;
/** Approximate total regular season games per AFL team (2026 season). */ /** Approximate total regular season games per AFL team (2026 season). */
const DEFAULT_REGULAR_SEASON_GAMES = 23; const AFL_REGULAR_SEASON_GAMES = 23;
/** Average opponent Elo used for regular season projections. */ /** Average opponent Elo used for regular season projections. */
const AVERAGE_OPPONENT_ELO = 1500; const AVERAGE_OPPONENT_ELO = 1500;
@ -163,8 +161,8 @@ export function getTeamData(name: string): AflTeamData | undefined {
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR)) * P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
* Exported for unit testing. * Exported for unit testing.
*/ */
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number { export function eloWinProbability(eloA: number, eloB: number): number {
return eloWinProbabilityWithParity(eloA, eloB, parityFactor); return eloWinProbabilityWithParity(eloA, eloB, PARITY_FACTOR);
} }
// ─── Internal types ─────────────────────────────────────────────────────────── // ─── Internal types ───────────────────────────────────────────────────────────
@ -195,11 +193,8 @@ function simulateProjectedWins(entry: TeamEntry): number {
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class AFLSimulator implements Simulator { export class AFLSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
// 1. Load participants, DB Elo, and standings in parallel. // 1. Load participants, DB Elo, and standings in parallel.
const [participantRows, evRows, standings] = await Promise.all([ const [participantRows, evRows, standings] = await Promise.all([
@ -265,8 +260,8 @@ export class AFLSimulator implements Simulator {
name: r.name, name: r.name,
elo: resolvedElo, elo: resolvedElo,
currentWins: standing?.wins ?? 0, currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, seasonGames - gamesPlayed), remainingGames: Math.max(0, AFL_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO, parityFactor), winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO),
}; };
}); });
@ -274,7 +269,7 @@ export class AFLSimulator implements Simulator {
/** Simulate a single AFL game. Returns the winner. */ /** Simulate a single AFL game. Returns the winner. */
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry => const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b; Math.random() < eloWinProbability(a.elo, b.elo) ? a : b;
/** /**
* Project end-of-season ladder and return the top 10 finalists seeded 110. * Project end-of-season ladder and return the top 10 finalists seeded 110.
@ -369,7 +364,7 @@ export class AFLSimulator implements Simulator {
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 4. Monte Carlo simulation loop. // 4. Monte Carlo simulation loop.
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
const finalists = buildFinalsList(); const finalists = buildFinalsList();
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists); const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists);
@ -398,7 +393,7 @@ export class AFLSimulator implements Simulator {
// //
// Within each pair (3rd/4th, 5th/6th, 7th/8th), both positions receive the // Within each pair (3rd/4th, 5th/6th, 7th/8th), both positions receive the
// same probability — matching the AFL_10 bracket's averaged point values. // same probability — matching the AFL_10 bracket's averaged point values.
const N = numSimulations; const N = NUM_SIMULATIONS;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0;

View file

@ -26,11 +26,10 @@ import * as schema from "~/database/schema";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value"; import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
import { getSeasonResults } from "~/models/participant-season-result"; import { getSeasonResults } from "~/models/participant-season-result";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (mirrors Python constants) ──────────────────────── // ─── Simulation parameters (mirrors Python constants) ────────────────────────
const DEFAULT_NUM_SIMULATIONS = 10000; const NUM_SIMULATIONS = 10000;
/** Per-race performance variance. 0 = no noise, 1 = fully random each race. */ /** Per-race performance variance. 0 = no noise, 1 = fully random each race. */
const RACE_NOISE = 0.50; const RACE_NOISE = 0.50;
@ -108,8 +107,7 @@ export class AutoRacingSimulator implements Simulator {
private readonly source: string, private readonly source: string,
) {} ) {}
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database(); const db = database();
// 1. Load all participants for this sports season // 1. Load all participants for this sports season
@ -188,7 +186,7 @@ export class AutoRacingSimulator implements Simulator {
// Pre-season: no races to simulate, derive placement probabilities // Pre-season: no races to simulate, derive placement probabilities
// from sourceOdds via pure weighted draws. // from sourceOdds via pure weighted draws.
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb); const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
for (let sim = 0; sim < numSimulations; sim++) { for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
const finishOrder = weightedDrawWithoutReplacement(ids, weights); const finishOrder = weightedDrawWithoutReplacement(ids, weights);
for (let rank = 0; rank < Math.min(8, finishOrder.length); rank++) { for (let rank = 0; rank < Math.min(8, finishOrder.length); rank++) {
const counts = rankCounts.get(finishOrder[rank]); const counts = rankCounts.get(finishOrder[rank]);
@ -227,7 +225,7 @@ export class AutoRacingSimulator implements Simulator {
// Volatility shrinks as the season progresses — late-season standings are // Volatility shrinks as the season progresses — late-season standings are
// much more predictive than early-season odds. // much more predictive than early-season odds.
const effectiveVolatility = PARTICIPANT_VOLATILITY * (1 - seasonProgress * VOLATILITY_DECAY_FACTOR); const effectiveVolatility = PARTICIPANT_VOLATILITY * (1 - seasonProgress * VOLATILITY_DECAY_FACTOR);
for (let sim = 0; sim < numSimulations; sim++) { for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
// 7a. Season-long performance multiplier per driver (uses blended strength) // 7a. Season-long performance multiplier per driver (uses blended strength)
const seasonWeights = new Map<string, number>(); const seasonWeights = new Map<string, number>();
for (const id of ids) { for (const id of ids) {
@ -279,14 +277,14 @@ export class AutoRacingSimulator implements Simulator {
return { return {
participantId: p.id, participantId: p.id,
probabilities: { probabilities: {
probFirst: counts[0] / numSimulations, probFirst: counts[0] / NUM_SIMULATIONS,
probSecond: counts[1] / numSimulations, probSecond: counts[1] / NUM_SIMULATIONS,
probThird: counts[2] / numSimulations, probThird: counts[2] / NUM_SIMULATIONS,
probFourth: counts[3] / numSimulations, probFourth: counts[3] / NUM_SIMULATIONS,
probFifth: counts[4] / numSimulations, probFifth: counts[4] / NUM_SIMULATIONS,
probSixth: counts[5] / numSimulations, probSixth: counts[5] / NUM_SIMULATIONS,
probSeventh: counts[6] / numSimulations, probSeventh: counts[6] / NUM_SIMULATIONS,
probEighth: counts[7] / numSimulations, probEighth: counts[7] / NUM_SIMULATIONS,
}, },
source: this.source, source: this.source,
}; };

View file

@ -22,9 +22,8 @@ import {
eloWinProbabilityWithParity, eloWinProbabilityWithParity,
} from "~/services/probability-engine"; } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
const DEFAULT_NUM_SIMULATIONS = 50_000; const NUM_SIMULATIONS = 50_000;
const BRACKET_SIZE = 16; const BRACKET_SIZE = 16;
const HOCKEY_PARITY_FACTOR = 850; const HOCKEY_PARITY_FACTOR = 850;
const DEFAULT_ELO = 1500; const DEFAULT_ELO = 1500;
@ -157,8 +156,8 @@ export function buildCollegeHockeyTeams(
return teams; return teams;
} }
function simGame(team1: Team, team2: Team, parityFactor = HOCKEY_PARITY_FACTOR): { winner: Team; loser: Team } { function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor); const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, HOCKEY_PARITY_FACTOR);
return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 }; return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 };
} }
@ -190,27 +189,26 @@ function seedField(teams: Team[]): Team[] {
return [...teams].toSorted((a, b) => b.seedScore - a.seedScore || b.elo - a.elo); return [...teams].toSorted((a, b) => b.seedScore - a.seedScore || b.elo - a.elo);
} }
function simulateSeeded16TeamBracket(teams: Team[], counts: Map<string, PlacementCounts>, parityFactor = HOCKEY_PARITY_FACTOR): void { function simulateSeeded16TeamBracket(teams: Team[], counts: Map<string, PlacementCounts>): void {
const r16Pairs: Array<[number, number]> = [ const r16Pairs: Array<[number, number]> = [
[0, 15], [7, 8], [4, 11], [3, 12], [5, 10], [2, 13], [6, 9], [1, 14], [0, 15], [7, 8], [4, 11], [3, 12], [5, 10], [2, 13], [6, 9], [1, 14],
]; ];
const game = (a: Team, b: Team) => simGame(a, b, parityFactor);
const r16Winners = r16Pairs.map(([a, b]) => game(teams[a], teams[b]).winner); const r16Winners = r16Pairs.map(([a, b]) => simGame(teams[a], teams[b]).winner);
const qfWinners: Team[] = []; const qfWinners: Team[] = [];
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const result = game(r16Winners[i * 2], r16Winners[i * 2 + 1]); const result = simGame(r16Winners[i * 2], r16Winners[i * 2 + 1]);
qfWinners.push(result.winner); qfWinners.push(result.winner);
bump(counts, result.loser.participantId, "quarterfinalLoser"); bump(counts, result.loser.participantId, "quarterfinalLoser");
} }
const sf1 = game(qfWinners[0], qfWinners[1]); const sf1 = simGame(qfWinners[0], qfWinners[1]);
const sf2 = game(qfWinners[2], qfWinners[3]); const sf2 = simGame(qfWinners[2], qfWinners[3]);
bump(counts, sf1.loser.participantId, "semifinalLoser"); bump(counts, sf1.loser.participantId, "semifinalLoser");
bump(counts, sf2.loser.participantId, "semifinalLoser"); bump(counts, sf2.loser.participantId, "semifinalLoser");
const final = game(sf1.winner, sf2.winner); const final = simGame(sf1.winner, sf2.winner);
bump(counts, final.winner.participantId, "champion"); bump(counts, final.winner.participantId, "champion");
bump(counts, final.loser.participantId, "finalist"); bump(counts, final.loser.participantId, "finalist");
} }
@ -238,21 +236,19 @@ function simulateMatchFromIds(
p1: string, p1: string,
p2: string, p2: string,
match: PlayoffMatch | undefined, match: PlayoffMatch | undefined,
teamById: Map<string, Team>, teamById: Map<string, Team>
parityFactor = HOCKEY_PARITY_FACTOR
): { winner: Team; loser: Team } { ): { winner: Team; loser: Team } {
if (match?.isComplete && match.winnerId) { if (match?.isComplete && match.winnerId) {
const loserId = completedLoser(match); const loserId = completedLoser(match);
return { winner: getTeam(teamById, match.winnerId), loser: getTeam(teamById, loserId) }; return { winner: getTeam(teamById, match.winnerId), loser: getTeam(teamById, loserId) };
} }
return simGame(getTeam(teamById, p1), getTeam(teamById, p2), parityFactor); return simGame(getTeam(teamById, p1), getTeam(teamById, p2));
} }
function simulateExistingBracket( function simulateExistingBracket(
rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch }, rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch },
teamById: Map<string, Team>, teamById: Map<string, Team>,
counts: Map<string, PlacementCounts>, counts: Map<string, PlacementCounts>
parityFactor = HOCKEY_PARITY_FACTOR
): void { ): void {
const r16Winners: Team[] = []; const r16Winners: Team[] = [];
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
@ -260,7 +256,7 @@ function simulateExistingBracket(
if (!match.participant1Id || !match.participant2Id) { if (!match.participant1Id || !match.participant2Id) {
throw new Error(`${match.round} match ${match.matchNumber} is missing participants.`); throw new Error(`${match.round} match ${match.matchNumber} is missing participants.`);
} }
r16Winners.push(simulateMatchFromIds(match.participant1Id, match.participant2Id, match, teamById, parityFactor).winner); r16Winners.push(simulateMatchFromIds(match.participant1Id, match.participant2Id, match, teamById).winner);
} }
const qfWinners: Team[] = []; const qfWinners: Team[] = [];
@ -269,7 +265,7 @@ function simulateExistingBracket(
const p1 = r16Winners[i * 2]?.participantId; const p1 = r16Winners[i * 2]?.participantId;
const p2 = r16Winners[i * 2 + 1]?.participantId; const p2 = r16Winners[i * 2 + 1]?.participantId;
if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`); if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`);
const result = simulateMatchFromIds(p1, p2, match, teamById, parityFactor); const result = simulateMatchFromIds(p1, p2, match, teamById);
qfWinners.push(result.winner); qfWinners.push(result.winner);
bump(counts, result.loser.participantId, "quarterfinalLoser"); bump(counts, result.loser.participantId, "quarterfinalLoser");
} }
@ -280,7 +276,7 @@ function simulateExistingBracket(
const p1 = qfWinners[i * 2]?.participantId; const p1 = qfWinners[i * 2]?.participantId;
const p2 = qfWinners[i * 2 + 1]?.participantId; const p2 = qfWinners[i * 2 + 1]?.participantId;
if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`); if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`);
const result = simulateMatchFromIds(p1, p2, match, teamById, parityFactor); const result = simulateMatchFromIds(p1, p2, match, teamById);
sfWinners.push(result.winner); sfWinners.push(result.winner);
bump(counts, result.loser.participantId, "semifinalLoser"); bump(counts, result.loser.participantId, "semifinalLoser");
} }
@ -288,21 +284,21 @@ function simulateExistingBracket(
const p1 = sfWinners[0]?.participantId; const p1 = sfWinners[0]?.participantId;
const p2 = sfWinners[1]?.participantId; const p2 = sfWinners[1]?.participantId;
if (!p1 || !p2) throw new Error(`${rounds.final.round} match ${rounds.final.matchNumber} cannot resolve participants.`); if (!p1 || !p2) throw new Error(`${rounds.final.round} match ${rounds.final.matchNumber} cannot resolve participants.`);
const final = simulateMatchFromIds(p1, p2, rounds.final, teamById, parityFactor); const final = simulateMatchFromIds(p1, p2, rounds.final, teamById);
bump(counts, final.winner.participantId, "champion"); bump(counts, final.winner.participantId, "champion");
bump(counts, final.loser.participantId, "finalist"); bump(counts, final.loser.participantId, "finalist");
} }
function toResults(participantIds: string[], counts: Map<string, PlacementCounts>, numSimulations: number): SimulationResult[] { function toResults(participantIds: string[], counts: Map<string, PlacementCounts>): SimulationResult[] {
const results = participantIds.map((participantId) => { const results = participantIds.map((participantId) => {
const c = counts.get(participantId) ?? { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 }; const c = counts.get(participantId) ?? { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 };
const sf = c.semifinalLoser / (2 * numSimulations); const sf = c.semifinalLoser / (2 * NUM_SIMULATIONS);
const qf = c.quarterfinalLoser / (4 * numSimulations); const qf = c.quarterfinalLoser / (4 * NUM_SIMULATIONS);
return { return {
participantId, participantId,
probabilities: { probabilities: {
probFirst: c.champion / numSimulations, probFirst: c.champion / NUM_SIMULATIONS,
probSecond: c.finalist / numSimulations, probSecond: c.finalist / NUM_SIMULATIONS,
probThird: sf, probThird: sf,
probFourth: sf, probFourth: sf,
probFifth: qf, probFifth: qf,
@ -329,10 +325,8 @@ function toResults(participantIds: string[], counts: Map<string, PlacementCounts
} }
export class CollegeHockeySimulator implements Simulator { export class CollegeHockeySimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", HOCKEY_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const bracketEvent = await db.query.scoringEvents.findFirst({ const bracketEvent = await db.query.scoringEvents.findFirst({
where: and( where: and(
@ -379,19 +373,19 @@ export class CollegeHockeySimulator implements Simulator {
); );
if (existingBracket) { if (existingBracket) {
for (let i = 0; i < numSimulations; i++) { for (let i = 0; i < NUM_SIMULATIONS; i++) {
simulateExistingBracket(existingBracket, teamById, counts, parityFactor); simulateExistingBracket(existingBracket, teamById, counts);
} }
} else { } else {
const preBracketMode = participantIds.length > BRACKET_SIZE; const preBracketMode = participantIds.length > BRACKET_SIZE;
const deterministicField = preBracketMode ? null : seedField(teams); const deterministicField = preBracketMode ? null : seedField(teams);
for (let i = 0; i < numSimulations; i++) { for (let i = 0; i < NUM_SIMULATIONS; i++) {
const field = preBracketMode ? sampleField(teams, BRACKET_SIZE) : deterministicField ?? []; const field = preBracketMode ? sampleField(teams, BRACKET_SIZE) : deterministicField ?? [];
simulateSeeded16TeamBracket(field, counts, parityFactor); simulateSeeded16TeamBracket(field, counts);
} }
} }
return toResults(participantIds, counts, numSimulations); return toResults(participantIds, counts);
} }
private getExistingBracketRounds(matches: PlayoffMatch[]): { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch } | null { private getExistingBracketRounds(matches: PlayoffMatch[]): { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch } | null {

View file

@ -1,43 +0,0 @@
/**
* Shared accessors for reading numeric knobs out of a merged simulator config
* (`sports_season_simulator_configs.config` merged over the profile defaults).
*
* These were previously duplicated inside individual simulators (EPL, MLS,
* NCAA-M). Centralizing them keeps every simulator reading engine knobs the same
* way, so values set in the unified config screen reliably drive the math.
*
* Two zero-handling variants exist deliberately:
* - `configNumber` accepts 0 (`>= 0`) for knobs where 0 is meaningful
* (e.g. a draw decay or blend weight of 0).
* - `positiveConfigNumber` requires `> 0` for counts/divisors where 0 is
* nonsensical and should fall back to the default.
*/
type Config = Record<string, unknown> | undefined;
/** Read a finite, non-negative number from config, else the fallback. */
export function configNumber(config: Config, key: string, fallback: number): number {
const value = config?.[key];
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
}
/** Read a finite, strictly-positive number from config, else the fallback. */
export function positiveConfigNumber(config: Config, key: string, fallback: number): number {
const value = config?.[key];
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
/**
* Read a required finite, strictly-positive number from config. Throws when the
* key is missing or non-positive use only for knobs a profile always seeds.
*/
export function requiredConfigNumber(config: Config, key: string, label = "simulator"): number {
const value = config?.[key];
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
throw new Error(`${label} config is missing positive numeric ${key}.`);
}
/** Coerce a raw value (already extracted from config) to a positive number. */
export function optionalPositiveNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}

View file

@ -59,11 +59,10 @@ import {
type StructureSource, type StructureSource,
} from "./shared-major"; } from "./shared-major";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 10_000; const NUM_SIMULATIONS = 10_000;
/** Total field size per CS2 Major. */ /** Total field size per CS2 Major. */
const FIELD_SIZE = 32; const FIELD_SIZE = 32;
@ -546,8 +545,7 @@ export function simulateChampionsStage(
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class CSMajorSimulator implements Simulator { export class CSMajorSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database(); const db = database();
// 1. Load all participants for this sports season. // 1. Load all participants for this sports season.
@ -802,7 +800,7 @@ export class CSMajorSimulator implements Simulator {
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array(8).fill(0)); const counts: number[][] = Array.from({ length: participantIds.length }, () => Array(8).fill(0));
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i])); const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
for (let sim = 0; sim < numSimulations; sim++) { for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
const simQP = new Map<string, number>(actualQPMap); const simQP = new Map<string, number>(actualQPMap);
for (const event of incompleteEvents) { for (const event of incompleteEvents) {
@ -833,14 +831,14 @@ export class CSMajorSimulator implements Simulator {
return participantIds.map((participantId, i) => ({ return participantIds.map((participantId, i) => ({
participantId, participantId,
probabilities: { probabilities: {
probFirst: counts[i][0] / numSimulations, probFirst: counts[i][0] / NUM_SIMULATIONS,
probSecond: counts[i][1] / numSimulations, probSecond: counts[i][1] / NUM_SIMULATIONS,
probThird: counts[i][2] / numSimulations, probThird: counts[i][2] / NUM_SIMULATIONS,
probFourth: counts[i][3] / numSimulations, probFourth: counts[i][3] / NUM_SIMULATIONS,
probFifth: counts[i][4] / numSimulations, probFifth: counts[i][4] / NUM_SIMULATIONS,
probSixth: counts[i][5] / numSimulations, probSixth: counts[i][5] / NUM_SIMULATIONS,
probSeventh: counts[i][6] / numSimulations, probSeventh: counts[i][6] / NUM_SIMULATIONS,
probEighth: counts[i][7] / numSimulations, probEighth: counts[i][7] / NUM_SIMULATIONS,
}, },
source: "cs2_major_qualifying_points_monte_carlo", source: "cs2_major_qualifying_points_monte_carlo",
})); }));

View file

@ -48,7 +48,6 @@ import { database } from "~/database/context";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
@ -205,9 +204,8 @@ export class DartsSimulator implements Simulator {
this.numSimulations = numSimulations; this.numSimulations = numSimulations;
} }
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
// 1. Find the bracket scoring event (if it exists). // 1. Find the bracket scoring event (if it exists).
const bracketEvent = await db.query.scoringEvents.findFirst({ const bracketEvent = await db.query.scoringEvents.findFirst({
@ -250,9 +248,9 @@ export class DartsSimulator implements Simulator {
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id); const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
if (bracketPopulated) { if (bracketPopulated) {
return this.simulateBracket(allMatches, eloMap, numSimulations); return this.simulateBracket(allMatches, eloMap);
} else { } else {
return this.simulatePreBracket(sportsSeasonId, eloMap, rankingMap, db, numSimulations); return this.simulatePreBracket(sportsSeasonId, eloMap, rankingMap, db);
} }
} }
@ -260,8 +258,7 @@ export class DartsSimulator implements Simulator {
private async simulateBracket( private async simulateBracket(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>, allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, number>, eloMap: Map<string, number>
numSimulations: number
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
// Group matches by round, sorted by match count descending (R1 first = most matches). // Group matches by round, sorted by match count descending (R1 first = most matches).
const byRound = new Map<string, typeof allMatches>(); const byRound = new Map<string, typeof allMatches>();
@ -334,7 +331,7 @@ export class DartsSimulator implements Simulator {
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < this.numSimulations; s++) {
// R1 (64 matches) // R1 (64 matches)
const r1Winners: string[] = []; const r1Winners: string[] = [];
for (let i = 1; i <= 64; i++) { for (let i = 1; i <= 64; i++) {
@ -442,7 +439,7 @@ export class DartsSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
} }
return buildResults(participantIds, numSimulations, { return buildResults(participantIds, this.numSimulations, {
championCounts, championCounts,
finalistCounts, finalistCounts,
sfLoserCounts, sfLoserCounts,
@ -458,8 +455,7 @@ export class DartsSimulator implements Simulator {
sportsSeasonId: string, sportsSeasonId: string,
eloMap: Map<string, number>, eloMap: Map<string, number>,
rankingMap: Map<string, number>, rankingMap: Map<string, number>,
db: ReturnType<typeof database>, db: ReturnType<typeof database>
numSimulations: number
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
const allParticipants = await db const allParticipants = await db
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
@ -524,7 +520,7 @@ export class DartsSimulator implements Simulator {
unseededPool.push(`__bye_${unseededPool.length}`); unseededPool.push(`__bye_${unseededPool.length}`);
} }
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < this.numSimulations; s++) {
// Draw: shuffle the unseeded pool — seeded positions are pre-computed. // Draw: shuffle the unseeded pool — seeded positions are pre-computed.
const drawnUnseeded = shuffle([...unseededPool]); const drawnUnseeded = shuffle([...unseededPool]);
@ -592,7 +588,7 @@ export class DartsSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
} }
return buildResults(allParticipantIds, numSimulations, { return buildResults(allParticipantIds, this.numSimulations, {
championCounts, championCounts,
finalistCounts, finalistCounts,
sfLoserCounts, sfLoserCounts,

View file

@ -18,7 +18,6 @@ import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/pro
import { normalizeSimulationResultColumns } from "./simulation-probabilities"; import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers"; import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers";
import { getSportsSeasonSimulatorConfig } from "~/models/simulator"; import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
import { positiveConfigNumber, requiredConfigNumber } from "./config-access";
const DEFAULT_NUM_SIMULATIONS = 10_000; const DEFAULT_NUM_SIMULATIONS = 10_000;
const DEFAULT_EPL_REGULAR_SEASON_GAMES = 38; const DEFAULT_EPL_REGULAR_SEASON_GAMES = 38;
@ -45,6 +44,17 @@ interface SimulatedTableRow {
tiebreaker: number; tiebreaker: number;
} }
function configNumber(config: Record<string, unknown> | undefined, key: string, fallback: number): number {
const value = config?.[key];
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function requiredConfigNumber(config: Record<string, unknown> | undefined, key: string): number {
const value = config?.[key];
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
throw new Error(`EPL simulator config is missing positive numeric ${key}.`);
}
/** EPL single-match win probability using the sport-specific parity factor. */ /** EPL single-match win probability using the sport-specific parity factor. */
export function eplWinProbability( export function eplWinProbability(
eloA: number, eloA: number,
@ -89,13 +99,13 @@ export class EPLSimulator implements Simulator {
]); ]);
const config = simulatorConfig?.config; const config = simulatorConfig?.config;
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS)); const numSimulations = Math.round(configNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_EPL_REGULAR_SEASON_GAMES)); const seasonGames = Math.round(configNumber(config, "seasonGames", DEFAULT_EPL_REGULAR_SEASON_GAMES));
const averageOpponentElo = positiveConfigNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO); const averageOpponentElo = configNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO);
const matchOptions = { const matchOptions = {
baseDrawRate: positiveConfigNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE), baseDrawRate: configNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE),
drawDecay: positiveConfigNumber(config, "drawDecay", DEFAULT_DRAW_DECAY), drawDecay: configNumber(config, "drawDecay", DEFAULT_DRAW_DECAY),
matchParityFactor: requiredConfigNumber(config, "matchParityFactor", "EPL simulator"), matchParityFactor: requiredConfigNumber(config, "matchParityFactor"),
}; };
if (participantRows.length === 0) { if (participantRows.length === 0) {

View file

@ -38,11 +38,10 @@ import { getGolfSkillsMap, type GolfSkillsRecord } from "~/models/golf-skills";
import { getQPConfig } from "~/models/qualifying-points"; import { getQPConfig } from "~/models/qualifying-points";
import { getExcludedByEventMap } from "~/models/event-result"; import { getExcludedByEventMap } from "~/models/event-result";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 10_000; const NUM_SIMULATIONS = 10_000;
/** /**
* Simulated field size for each major. * Simulated field size for each major.
@ -181,8 +180,7 @@ export function simulateMajor(
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class GolfSimulator implements Simulator { export class GolfSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database(); const db = database();
// Load participants, skills, QP config, and scoring events in parallel. // Load participants, skills, QP config, and scoring events in parallel.
@ -271,7 +269,7 @@ export class GolfSimulator implements Simulator {
); );
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i])); const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
for (let sim = 0; sim < numSimulations; sim++) { for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
const simQP = new Map<string, number>(actualQPMap); const simQP = new Map<string, number>(actualQPMap);
for (const { players, restCount, restStrength } of majorConfigs) { for (const { players, restCount, restStrength } of majorConfigs) {
@ -294,14 +292,14 @@ export class GolfSimulator implements Simulator {
return participantIds.map((participantId, i) => ({ return participantIds.map((participantId, i) => ({
participantId, participantId,
probabilities: { probabilities: {
probFirst: counts[i][0] / numSimulations, probFirst: counts[i][0] / NUM_SIMULATIONS,
probSecond: counts[i][1] / numSimulations, probSecond: counts[i][1] / NUM_SIMULATIONS,
probThird: counts[i][2] / numSimulations, probThird: counts[i][2] / NUM_SIMULATIONS,
probFourth: counts[i][3] / numSimulations, probFourth: counts[i][3] / NUM_SIMULATIONS,
probFifth: counts[i][4] / numSimulations, probFifth: counts[i][4] / NUM_SIMULATIONS,
probSixth: counts[i][5] / numSimulations, probSixth: counts[i][5] / NUM_SIMULATIONS,
probSeventh: counts[i][6] / numSimulations, probSeventh: counts[i][6] / NUM_SIMULATIONS,
probEighth: counts[i][7] / numSimulations, probEighth: counts[i][7] / NUM_SIMULATIONS,
}, },
source: "golf_qualifying_points_monte_carlo", source: "golf_qualifying_points_monte_carlo",
})); }));

View file

@ -254,10 +254,6 @@ export function resolveSourceElos(
); );
} }
if (oddsInputs.length >= 2) { if (oddsInputs.length >= 2) {
// Odds map onto the calibrated band (probability-engine DEFAULT_CALIBRATION);
// the policy's eloMin/eloMax then clamp the result via clampDerived below, so
// a season's Elo floor/ceiling still narrows the odds-derived spread without
// widening it (and without clamping a high directly-entered base Elo's blend).
for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) { for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) {
oddsElo.set(participantId, elo); oddsElo.set(participantId, elo);
} }

View file

@ -61,7 +61,6 @@ import { eq } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { convertAmericanOddsToProbability } from "~/services/probability-engine"; import { convertAmericanOddsToProbability } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
@ -262,8 +261,7 @@ function determineRandomized(sideTeams: Team[], sideName: string): boolean {
export class LLWSSimulator implements Simulator { export class LLWSSimulator implements Simulator {
constructor(private numSimulations = NUM_SIMULATIONS) {} constructor(private numSimulations = NUM_SIMULATIONS) {}
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
const db = database(); const db = database();
// 1. Load all participants. // 1. Load all participants.
@ -349,7 +347,7 @@ export class LLWSSimulator implements Simulator {
}; };
// 6. Run Monte Carlo simulations. // 6. Run Monte Carlo simulations.
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < this.numSimulations; s++) {
// Assign pools for this simulation. // Assign pools for this simulation.
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized); const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized); const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
@ -380,7 +378,7 @@ export class LLWSSimulator implements Simulator {
// 7. Convert counts to probability distributions. // 7. Convert counts to probability distributions.
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly. // bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
const bracketLosersPerSim = 4; const bracketLosersPerSim = 4;
const bracketDivisor = bracketLosersPerSim * numSimulations; const bracketDivisor = bracketLosersPerSim * this.numSimulations;
const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }; const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 };
return allIds.map((id) => { return allIds.map((id) => {
@ -389,10 +387,10 @@ export class LLWSSimulator implements Simulator {
return { return {
participantId: id, participantId: id,
probabilities: { probabilities: {
probFirst: c.champion / numSimulations, probFirst: c.champion / this.numSimulations,
probSecond: c.finalist / numSimulations, probSecond: c.finalist / this.numSimulations,
probThird: c.thirdPlace / numSimulations, probThird: c.thirdPlace / this.numSimulations,
probFourth: c.fourthPlace / numSimulations, probFourth: c.fourthPlace / this.numSimulations,
probFifth: bracketProb, probFifth: bracketProb,
probSixth: bracketProb, probSixth: bracketProb,
probSeventh: bracketProb, probSeventh: bracketProb,

View file

@ -66,7 +66,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "futuresOdds", "bracket"], setupSections: ["participants", "futuresOdds", "bracket"],
}, },
ucl_bracket: { ucl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, inputPolicy: { oddsWeight: 0.3 } }, defaultConfig: { ...BASE_CONFIG, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds"], optionalInputs: ["sourceOdds"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },
@ -150,7 +150,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "regularStandings"], setupSections: ["participants", "eloRatings", "regularStandings"],
}, },
wnba_bracket: { wnba_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 44, srsEloScale: 30 }, defaultConfig: { ...BASE_CONFIG, seasonGames: 44, srsEloScale: 30 },
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"], optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] }, derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
@ -176,7 +176,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"], setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
}, },
ncaa_football_bracket: { ncaa_football_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } }, defaultConfig: { ...BASE_CONFIG, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"], optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },

View file

@ -74,13 +74,12 @@ import { database } from "~/database/context";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50_000; const NUM_SIMULATIONS = 50_000;
const TOTAL_SEASON_GAMES = 162; const TOTAL_SEASON_GAMES = 162;
/** /**
@ -441,8 +440,7 @@ function simLeagueBracket(
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class MLBSimulator implements Simulator { export class MLBSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database(); const db = database();
// 1. Load all participants for this sports season. // 1. Load all participants for this sports season.
@ -571,7 +569,7 @@ export class MLBSimulator implements Simulator {
let effectiveN = 0; let effectiveN = 0;
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
const alField = drawLeaguePlayoffField(alTeams, seedingWinRate); const alField = drawLeaguePlayoffField(alTeams, seedingWinRate);
const nlField = drawLeaguePlayoffField(nlTeams, seedingWinRate); const nlField = drawLeaguePlayoffField(nlTeams, seedingWinRate);
if (!alField || !nlField) continue; // degenerate draw — skip if (!alField || !nlField) continue; // degenerate draw — skip

View file

@ -50,7 +50,6 @@ import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-help
import type { EloSoccerMatchOptions } from "./soccer-helpers"; import type { EloSoccerMatchOptions } from "./soccer-helpers";
import { normalizeSimulationResultColumns } from "./simulation-probabilities"; import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { configNumber } from "./config-access";
// ─── Default constants (overridable via season simulator config) ─────────────── // ─── Default constants (overridable via season simulator config) ───────────────
@ -63,6 +62,12 @@ const DEFAULT_DRAW_DECAY = 0.002;
const MLS_PLAYOFF_TEAMS_PER_CONF = 9; const MLS_PLAYOFF_TEAMS_PER_CONF = 9;
// ─── Config helpers ────────────────────────────────────────────────────────────
function configNumber(config: Record<string, unknown> | undefined, key: string, fallback: number): number {
const value = config?.[key];
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
}
/** /**
* Infer MLS conference from seasonParticipant.externalId. * Infer MLS conference from seasonParticipant.externalId.

View file

@ -53,21 +53,19 @@ import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name"; import { normalizeTeamName } from "~/lib/normalize-team-name";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ─────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50_000; const NUM_SIMULATIONS = 50_000;
/** /**
* Elo parity factor. NBA defaults to 400 (standard formula). * Elo parity factor. NBA uses 400 (standard formula).
* A 400-point Elo difference ~90.9% win probability per game. * A 400-point Elo difference ~90.9% win probability per game.
* Raising it (via the season config's `parityFactor`) flattens the distribution.
*/ */
const DEFAULT_PARITY_FACTOR = 400; const PARITY_FACTOR = 400;
/** NBA regular season games per team. */ /** NBA regular season games per team. */
const DEFAULT_REGULAR_SEASON_GAMES = 82; const NBA_REGULAR_SEASON_GAMES = 82;
// ─── Team data (2025-26 season, as of March 2026) ───────────────────────────── // ─── Team data (2025-26 season, as of March 2026) ─────────────────────────────
@ -131,8 +129,8 @@ export function getTeamData(name: string): NbaTeamData | undefined {
* Elo win probability for team A over team B. * Elo win probability for team A over team B.
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR)) * P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
*/ */
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number { export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor)); return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
} }
// ─── Per-position normalization ─────────────────────────────────────────────── // ─── Per-position normalization ───────────────────────────────────────────────
@ -159,99 +157,90 @@ function normalizeColumns(results: SimulationResult[]): void {
type DbMatch = typeof schema.playoffMatches.$inferSelect; type DbMatch = typeof schema.playoffMatches.$inferSelect;
/** Simulate a single-game matchup. Returns winner and loser. */
function simGame(
eloMap: Map<string, number>,
a: string,
b: string
): { winner: string; loser: string } {
const eloA = eloMap.get(a) ?? 1400;
const eloB = eloMap.get(b) ?? 1400;
const winner = Math.random() < eloWinProbability(eloA, eloB) ? a : b;
return { winner, loser: winner === a ? b : a };
}
/** Simulate a best-of-7 series. Returns winner and loser. */
function simSeries(
eloMap: Map<string, number>,
a: string,
b: string
): { winner: string; loser: string } {
const winProb = eloWinProbability(eloMap.get(a) ?? 1400, eloMap.get(b) ?? 1400);
let wA = 0;
let wB = 0;
while (wA < 4 && wB < 4) {
if (Math.random() < winProb) wA++; else wB++;
}
return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
}
/** /**
* Build the bracket match helpers bound to a given `parityFactor`. Returning them * Resolve a play-in single game.
* from a factory keeps every call site unchanged while letting the season config * Uses the real result if the match is complete; otherwise simulates.
* tune per-game variance. * p1Override / p2Override are used when the DB participant slot is still null
* (i.e. filled in by the previous round's simulated result).
*/ */
function makeBracketHelpers(parityFactor: number) { function resolveGame(
/** Simulate a single-game matchup. Returns winner and loser. */ eloMap: Map<string, number>,
function simGame( match: DbMatch | undefined,
eloMap: Map<string, number>, p1Override?: string,
a: string, p2Override?: string
b: string ): { winner: string; loser: string } {
): { winner: string; loser: string } { if (match?.isComplete && match.winnerId && match.loserId) {
const eloA = eloMap.get(a) ?? 1400; return { winner: match.winnerId, loser: match.loserId };
const eloB = eloMap.get(b) ?? 1400;
const winner = Math.random() < eloWinProbability(eloA, eloB, parityFactor) ? a : b;
return { winner, loser: winner === a ? b : a };
} }
const p1 = match?.participant1Id ?? p1Override;
/** Simulate a best-of-7 series. Returns winner and loser. */ const p2 = match?.participant2Id ?? p2Override;
function simSeries( if (!p1 || !p2) {
eloMap: Map<string, number>, throw new Error(
a: string, `Cannot resolve game: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
b: string `(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
): { winner: string; loser: string } { `Ensure the bracket is fully generated before simulating.`
const winProb = eloWinProbability(eloMap.get(a) ?? 1400, eloMap.get(b) ?? 1400, parityFactor); );
let wA = 0;
let wB = 0;
while (wA < 4 && wB < 4) {
if (Math.random() < winProb) wA++; else wB++;
}
return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
} }
return simGame(eloMap, p1, p2);
}
/** /**
* Resolve a play-in single game. * Resolve a playoff series.
* Uses the real result if the match is complete; otherwise simulates. * Uses the real result if the match is complete; otherwise simulates best-of-7.
* p1Override / p2Override are used when the DB participant slot is still null * p1Override / p2Override fill null DB participant slots with the simulated seed.
* (i.e. filled in by the previous round's simulated result). */
*/ function resolveSeries(
function resolveGame( eloMap: Map<string, number>,
eloMap: Map<string, number>, match: DbMatch | undefined,
match: DbMatch | undefined, p1Override?: string,
p1Override?: string, p2Override?: string
p2Override?: string ): { winner: string; loser: string } {
): { winner: string; loser: string } { if (match?.isComplete && match.winnerId && match.loserId) {
if (match?.isComplete && match.winnerId && match.loserId) { return { winner: match.winnerId, loser: match.loserId };
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id ?? p1Override;
const p2 = match?.participant2Id ?? p2Override;
if (!p1 || !p2) {
throw new Error(
`Cannot resolve game: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
`(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
`Ensure the bracket is fully generated before simulating.`
);
}
return simGame(eloMap, p1, p2);
} }
const p1 = match?.participant1Id ?? p1Override;
/** const p2 = match?.participant2Id ?? p2Override;
* Resolve a playoff series. if (!p1 || !p2) {
* Uses the real result if the match is complete; otherwise simulates best-of-7. throw new Error(
* p1Override / p2Override fill null DB participant slots with the simulated seed. `Cannot resolve series: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
*/ `(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
function resolveSeries( `Ensure the bracket is fully generated before simulating.`
eloMap: Map<string, number>, );
match: DbMatch | undefined,
p1Override?: string,
p2Override?: string
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id ?? p1Override;
const p2 = match?.participant2Id ?? p2Override;
if (!p1 || !p2) {
throw new Error(
`Cannot resolve series: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
`(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
`Ensure the bracket is fully generated before simulating.`
);
}
return simSeries(eloMap, p1, p2);
} }
return simSeries(eloMap, p1, p2);
return { simGame, simSeries, resolveGame, resolveSeries };
} }
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class NBASimulator implements Simulator { export class NBASimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
// ── Mode detection: bracket-aware if a playoff event with matches exists ── // ── Mode detection: bracket-aware if a playoff event with matches exists ──
@ -269,25 +258,21 @@ export class NBASimulator implements Simulator {
}); });
if (bracketMatches.length > 0) { if (bracketMatches.length > 0) {
return this.simulateBracketAware(sportsSeasonId, bracketMatches, config); return this.simulateBracketAware(sportsSeasonId, bracketMatches);
} }
} }
// ── Fall back to season-projection mode ─────────────────────────────────── // ── Fall back to season-projection mode ───────────────────────────────────
return this.simulateSeasonProjection(sportsSeasonId, config); return this.simulateSeasonProjection(sportsSeasonId);
} }
// ── Mode 1: Bracket-Aware ────────────────────────────────────────────────── // ── Mode 1: Bracket-Aware ──────────────────────────────────────────────────
private async simulateBracketAware( private async simulateBracketAware(
sportsSeasonId: string, sportsSeasonId: string,
allMatches: DbMatch[], allMatches: DbMatch[]
config: Record<string, unknown> = {}
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const { resolveGame, resolveSeries } = makeBracketHelpers(parityFactor);
// Group matches by round. // Group matches by round.
const pir1 = allMatches.filter((m) => m.round === "Play-In Round 1") const pir1 = allMatches.filter((m) => m.round === "Play-In Round 1")
@ -370,7 +355,7 @@ export class NBASimulator implements Simulator {
const csLoserCounts = new Map(participantIds.map((id) => [id, 0])); const csLoserCounts = new Map(participantIds.map((id) => [id, 0]));
// ── Monte Carlo loop ────────────────────────────────────────────────────── // ── Monte Carlo loop ──────────────────────────────────────────────────────
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
// ── Play-In Round 1 (4 single games) ───────────────────────────────── // ── Play-In Round 1 (4 single games) ─────────────────────────────────
// M1: East 7 vs 8 → winner = E7 seed; loser enters PIR2 as p1 // M1: East 7 vs 8 → winner = E7 seed; loser enters PIR2 as p1
@ -454,7 +439,7 @@ export class NBASimulator implements Simulator {
// probFirst/Second → N total (1 per sim) // probFirst/Second → N total (1 per sim)
// probThird/Fourth → cfLoserCounts / (2×N) — 2 CF losers per sim // probThird/Fourth → cfLoserCounts / (2×N) — 2 CF losers per sim
// probFifthEighth → csLoserCounts / (4×N) — 4 CS losers per sim // probFifthEighth → csLoserCounts / (4×N) — 4 CS losers per sim
const N = numSimulations; const N = NUM_SIMULATIONS;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0;
@ -482,11 +467,8 @@ export class NBASimulator implements Simulator {
// ── Mode 2: Season Projection ────────────────────────────────────────────── // ── Mode 2: Season Projection ──────────────────────────────────────────────
private async simulateSeasonProjection(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { private async simulateSeasonProjection(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
const [participantRows, standings, evRows] = await Promise.all([ const [participantRows, standings, evRows] = await Promise.all([
db db
@ -547,8 +529,8 @@ export class NBASimulator implements Simulator {
data, data,
conference, conference,
currentWins: standing?.wins ?? 0, currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, seasonGames - gamesPlayed), remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(resolvedElo, 1500, parityFactor), winProb: eloWinProbability(resolvedElo, 1500),
resolvedElo, resolvedElo,
}; };
}); });
@ -564,10 +546,10 @@ export class NBASimulator implements Simulator {
} }
const simTeamGame = (a: TeamEntry, b: TeamEntry): TeamEntry => const simTeamGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
Math.random() < eloWinProbability(eloOfEntry(a), eloOfEntry(b), parityFactor) ? a : b; Math.random() < eloWinProbability(eloOfEntry(a), eloOfEntry(b)) ? a : b;
const simTeamSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => { const simTeamSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
const winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b), parityFactor); const winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b));
let wA = 0; let wA = 0;
let wB = 0; let wB = 0;
while (wA < 4 && wB < 4) { while (wA < 4 && wB < 4) {
@ -615,7 +597,7 @@ export class NBASimulator implements Simulator {
const confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const confSemiLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const confSemiLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
const eastBracket = buildConferenceBracket(easternTeams); const eastBracket = buildConferenceBracket(easternTeams);
const westBracket = buildConferenceBracket(westernTeams); const westBracket = buildConferenceBracket(westernTeams);
@ -636,7 +618,7 @@ export class NBASimulator implements Simulator {
} }
} }
const N = numSimulations; const N = NUM_SIMULATIONS;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0;

View file

@ -7,9 +7,9 @@
* 1. Load all participants for the sports season from DB * 1. Load all participants for the sports season from DB
* 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo * 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo
* (entered via Admin Elo Ratings page; use FPI, S&P+, or any Elo-scale rating) * (entered via Admin Elo Ratings page; use FPI, S&P+, or any Elo-scale rating)
* 3. Field-selection weight is a softmax over the resolved Elo. Futures odds are * 3. If sourceOdds (American format) are also stored, build a normalized selection
* not blended into per-game win probability the input policy already folded * weight from implied championship probability (used for field selection in step 4)
* them into sourceElo, so blending again would double-count them. * and blend into per-game win probability (ELO_WEIGHT=0.6 / ODDS_WEIGHT=0.4).
* 4. Per simulation, select 12 teams for the CFP field: * 4. Per simulation, select 12 teams for the CFP field:
* - If the pool has exactly 12 teams: use all of them (post-bracket mode). * - If the pool has exactly 12 teams: use all of them (post-bracket mode).
* - If the pool has >12 teams: weighted sample without replacement using each * - If the pool has >12 teams: weighted sample without replacement using each
@ -31,8 +31,8 @@
* fields, so its champion probability accounts for that. * fields, so its champion probability accounts for that.
* Post-bracket (exactly 12 participants): deterministic field, bracket-only sim. * Post-bracket (exactly 12 participants): deterministic field, bracket-only sim.
* *
* Win probability (per game): eloWinProbabilityWithParity(eloA, eloB, parityFactor), * Win probability (per game): eloWinProbability() from probability-engine (400-divisor).
* where parityFactor comes from the season config (default 400). * Blended win probability: ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb (when odds present).
* *
* Selection weight (pre-bracket mode): * Selection weight (pre-bracket mode):
* With sourceOdds: normalized implied championship probability (vig removed, sums to 1). * With sourceOdds: normalized implied championship probability (vig removed, sums to 1).
@ -59,15 +59,12 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eloWinProbabilityWithParity } from "~/services/probability-engine"; import { eloWinProbability } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ─────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50_000; const NUM_SIMULATIONS = 50_000;
/** Elo parity factor. Defaults to 400 (standard formula). */
const DEFAULT_PARITY_FACTOR = 400;
const BRACKET_SIZE = 12; const BRACKET_SIZE = 12;
/** /**
@ -90,12 +87,12 @@ interface Team {
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
/** Win probability for team1 vs team2 from the single resolved Elo. */ /** Win probability for team1 vs team2 from the single resolved Elo. */
function gameWinProb(team1: Team, team2: Team, parityFactor = DEFAULT_PARITY_FACTOR): number { function gameWinProb(team1: Team, team2: Team): number {
return eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor); return eloWinProbability(team1.elo, team2.elo);
} }
function simGame(team1: Team, team2: Team, parityFactor = DEFAULT_PARITY_FACTOR): { winner: Team; loser: Team } { function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
const p1Wins = Math.random() < gameWinProb(team1, team2, parityFactor); const p1Wins = Math.random() < gameWinProb(team1, team2);
return p1Wins return p1Wins
? { winner: team1, loser: team2 } ? { winner: team1, loser: team2 }
: { winner: team2, loser: team1 }; : { winner: team2, loser: team1 };
@ -144,19 +141,18 @@ interface PlacementCounts {
* Semifinals: qf1w vs qf2w, qf3w vs qf4w * Semifinals: qf1w vs qf2w, qf3w vs qf4w
* Championship: sf1w vs sf2w * Championship: sf1w vs sf2w
*/ */
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, parityFactor = DEFAULT_PARITY_FACTOR): void { function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): void {
const game = (a: Team, b: Team) => simGame(a, b, parityFactor);
// ── First Round (seeds 512) ─────────────────────────────────────────────── // ── First Round (seeds 512) ───────────────────────────────────────────────
const fr1 = game(teams[4], teams[11]); // 5 vs 12 const fr1 = simGame(teams[4], teams[11]); // 5 vs 12
const fr2 = game(teams[5], teams[10]); // 6 vs 11 const fr2 = simGame(teams[5], teams[10]); // 6 vs 11
const fr3 = game(teams[6], teams[9]); // 7 vs 10 const fr3 = simGame(teams[6], teams[9]); // 7 vs 10
const fr4 = game(teams[7], teams[8]); // 8 vs 9 const fr4 = simGame(teams[7], teams[8]); // 8 vs 9
// ── Quarterfinals (seeds 14 get byes) ──────────────────────────────────── // ── Quarterfinals (seeds 14 get byes) ────────────────────────────────────
const qf1 = game(teams[0], fr4.winner); // 1 vs 8/9 winner const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner
const qf2 = game(teams[3], fr1.winner); // 4 vs 5/12 winner const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner
const qf3 = game(teams[2], fr2.winner); // 3 vs 6/11 winner const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner
const qf4 = game(teams[1], fr3.winner); // 2 vs 7/10 winner const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner
const bump = (id: string, key: keyof PlacementCounts) => { const bump = (id: string, key: keyof PlacementCounts) => {
const entry = counts.get(id); const entry = counts.get(id);
@ -169,14 +165,14 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, pa
bump(qf4.loser.participantId, "qfLoser"); bump(qf4.loser.participantId, "qfLoser");
// ── Semifinals ──────────────────────────────────────────────────────────── // ── Semifinals ────────────────────────────────────────────────────────────
const sf1 = game(qf1.winner, qf2.winner); const sf1 = simGame(qf1.winner, qf2.winner);
const sf2 = game(qf3.winner, qf4.winner); const sf2 = simGame(qf3.winner, qf4.winner);
bump(sf1.loser.participantId, "sfLoser"); bump(sf1.loser.participantId, "sfLoser");
bump(sf2.loser.participantId, "sfLoser"); bump(sf2.loser.participantId, "sfLoser");
// ── National Championship ───────────────────────────────────────────────── // ── National Championship ─────────────────────────────────────────────────
const final = game(sf1.winner, sf2.winner); const final = simGame(sf1.winner, sf2.winner);
bump(final.winner.participantId, "champion"); bump(final.winner.participantId, "champion");
bump(final.loser.participantId, "finalist"); bump(final.loser.participantId, "finalist");
@ -185,10 +181,8 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, pa
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class NCAAFootballSimulator implements Simulator { export class NCAAFootballSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
// 1. Load all participants for this sports season. // 1. Load all participants for this sports season.
const participants = await db const participants = await db
.select({ id: schema.seasonParticipants.id }) .select({ id: schema.seasonParticipants.id })
@ -247,18 +241,18 @@ export class NCAAFootballSimulator implements Simulator {
); );
// 6. Run Monte Carlo simulations. // 6. Run Monte Carlo simulations.
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
const field = preBracketMode const field = preBracketMode
? sampleBracketField(allTeams, BRACKET_SIZE) ? sampleBracketField(allTeams, BRACKET_SIZE)
: (deterministicField ?? []); : (deterministicField ?? []);
simulateBracket(field, counts, parityFactor); simulateBracket(field, counts);
} }
// 7. Convert counts to probability distributions. // 7. Convert counts to probability distributions.
// SF losers: 2 per sim → each team's share = sfLoser / (2 * N). // SF losers: 2 per sim → each team's share = sfLoser / (2 * N).
// QF losers: 4 per sim → each team's share = qfLoser / (4 * N). // QF losers: 4 per sim → each team's share = qfLoser / (4 * N).
const sfDivisor = 2 * numSimulations; const sfDivisor = 2 * NUM_SIMULATIONS;
const qfDivisor = 4 * numSimulations; const qfDivisor = 4 * NUM_SIMULATIONS;
return allParticipantIds.map((id) => { return allParticipantIds.map((id) => {
const c = counts.get(id) ?? { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 }; const c = counts.get(id) ?? { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 };
@ -267,8 +261,8 @@ export class NCAAFootballSimulator implements Simulator {
return { return {
participantId: id, participantId: id,
probabilities: { probabilities: {
probFirst: c.champion / numSimulations, probFirst: c.champion / NUM_SIMULATIONS,
probSecond: c.finalist / numSimulations, probSecond: c.finalist / NUM_SIMULATIONS,
probThird: sfProb, probThird: sfProb,
probFourth: sfProb, probFourth: sfProb,
probFifth: qfProb, probFifth: qfProb,

View file

@ -1,6 +1,5 @@
import { NCAABasketballSimulator } from "./ncaa-basketball-simulator"; import { NCAABasketballSimulator } from "./ncaa-basketball-simulator";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { optionalPositiveNumber } from "./config-access";
const KENPOM_SCALE_FACTOR = 7.5; const KENPOM_SCALE_FACTOR = 7.5;
@ -12,6 +11,10 @@ export function kenpomWinProbability(netrtgA: number, netrtgB: number): number {
return 1 / (1 + Math.exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR)); return 1 / (1 + Math.exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR));
} }
function optionalPositiveNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function kenpomWinProbabilityWithScale(scaleFactor: number): (netrtgA: number, netrtgB: number) => number { function kenpomWinProbabilityWithScale(scaleFactor: number): (netrtgA: number, netrtgB: number) => number {
return (netrtgA, netrtgB) => 1 / (1 + Math.exp(-(netrtgA - netrtgB) / scaleFactor)); return (netrtgA, netrtgB) => 1 / (1 + Math.exp(-(netrtgA - netrtgB) / scaleFactor));
} }

View file

@ -46,19 +46,15 @@ import { eq } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name"; import { normalizeTeamName } from "~/lib/normalize-team-name";
import { eloWinProbabilityWithParity, convertFuturesToElo } from "~/services/probability-engine"; import { eloWinProbability, convertFuturesToElo } from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ─────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50_000; const NUM_SIMULATIONS = 50_000;
/** Elo parity factor. NFL defaults to 400 (standard formula). */
const DEFAULT_PARITY_FACTOR = 400;
/** NFL regular season games per team. */ /** NFL regular season games per team. */
const DEFAULT_REGULAR_SEASON_GAMES = 17; const NFL_REGULAR_SEASON_GAMES = 17;
/** Average opponent Elo for regular season game projection. */ /** Average opponent Elo for regular season game projection. */
const AVG_OPPONENT_ELO = 1500; const AVG_OPPONENT_ELO = 1500;
@ -177,10 +173,9 @@ export function getTeamData(name: string): NflTeamData | undefined {
function simulateProjectedWins( function simulateProjectedWins(
currentWins: number, currentWins: number,
gamesPlayed: number, gamesPlayed: number,
winProb: number, winProb: number
seasonGames = DEFAULT_REGULAR_SEASON_GAMES
): number { ): number {
const remaining = Math.max(0, seasonGames - gamesPlayed); const remaining = Math.max(0, NFL_REGULAR_SEASON_GAMES - gamesPlayed);
let additional = 0; let additional = 0;
for (let g = 0; g < remaining; g++) { for (let g = 0; g < remaining; g++) {
if (Math.random() < winProb) additional++; if (Math.random() < winProb) additional++;
@ -236,12 +231,11 @@ export function seedConference(
* Simulate a playoff game with home-field advantage for the higher seed. * Simulate a playoff game with home-field advantage for the higher seed.
* The team with the lower seed number (better seed) is the home team. * The team with the lower seed number (better seed) is the home team.
*/ */
function playGame(a: SeededTeam, b: SeededTeam, parityFactor = DEFAULT_PARITY_FACTOR): { winner: SeededTeam; loser: SeededTeam } { function playGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; loser: SeededTeam } {
const [home, away] = a.seed < b.seed ? [a, b] : [b, a]; const [home, away] = a.seed < b.seed ? [a, b] : [b, a];
const winProbHome = eloWinProbabilityWithParity( const winProbHome = eloWinProbability(
home.elo + NFL_HOME_FIELD_ELO_ADVANTAGE, home.elo + NFL_HOME_FIELD_ELO_ADVANTAGE,
away.elo, away.elo
parityFactor
); );
if (Math.random() < winProbHome) return { winner: home, loser: away }; if (Math.random() < winProbHome) return { winner: home, loser: away };
return { winner: away, loser: home }; return { winner: away, loser: home };
@ -251,8 +245,8 @@ function playGame(a: SeededTeam, b: SeededTeam, parityFactor = DEFAULT_PARITY_FA
* Simulate a game at a neutral site (no home-field adjustment). * Simulate a game at a neutral site (no home-field adjustment).
* Used for the Super Bowl. * Used for the Super Bowl.
*/ */
function playNeutralGame(a: SeededTeam, b: SeededTeam, parityFactor = DEFAULT_PARITY_FACTOR): { winner: SeededTeam; loser: SeededTeam } { function playNeutralGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; loser: SeededTeam } {
const winProbA = eloWinProbabilityWithParity(a.elo, b.elo, parityFactor); const winProbA = eloWinProbability(a.elo, b.elo);
if (Math.random() < winProbA) return { winner: a, loser: b }; if (Math.random() < winProbA) return { winner: a, loser: b };
return { winner: b, loser: a }; return { winner: b, loser: a };
} }
@ -267,15 +261,14 @@ function playNeutralGame(a: SeededTeam, b: SeededTeam, parityFactor = DEFAULT_PA
* Returns the finalist, the conference championship loser, and the two * Returns the finalist, the conference championship loser, and the two
* divisional-round losers (for 5th-8th place EV). * divisional-round losers (for 5th-8th place EV).
*/ */
export function simulateConferenceBracket(seeds: SeededTeam[], parityFactor = DEFAULT_PARITY_FACTOR): ConferenceBracketResult { export function simulateConferenceBracket(seeds: SeededTeam[]): ConferenceBracketResult {
const byIndex = new Map(seeds.map((t) => [t.seed, t])); const byIndex = new Map(seeds.map((t) => [t.seed, t]));
const s = (seed: number) => byIndex.get(seed) as SeededTeam; const s = (seed: number) => byIndex.get(seed) as SeededTeam;
const pg = (a: SeededTeam, b: SeededTeam) => playGame(a, b, parityFactor);
// Wild Card (seed 1 bye; lower seed = home) // Wild Card (seed 1 bye; lower seed = home)
const wc27 = pg(s(2), s(7)); const wc27 = playGame(s(2), s(7));
const wc36 = pg(s(3), s(6)); const wc36 = playGame(s(3), s(6));
const wc45 = pg(s(4), s(5)); const wc45 = playGame(s(4), s(5));
// Divisional: sort WC winners by original seed (ascending = better seed = home) // Divisional: sort WC winners by original seed (ascending = better seed = home)
// Seed 1 hosts the lowest-ranked (highest seed number) WC winner // Seed 1 hosts the lowest-ranked (highest seed number) WC winner
@ -283,11 +276,11 @@ export function simulateConferenceBracket(seeds: SeededTeam[], parityFactor = DE
(a, b) => a.seed - b.seed (a, b) => a.seed - b.seed
); );
const div1 = pg(s(1), wcWinners[wcWinners.length - 1]); const div1 = playGame(s(1), wcWinners[wcWinners.length - 1]);
const div2 = pg(wcWinners[0], wcWinners[1]); const div2 = playGame(wcWinners[0], wcWinners[1]);
// Conference Championship (higher remaining seed is home) // Conference Championship (higher remaining seed is home)
const conf = pg(div1.winner, div2.winner); const conf = playGame(div1.winner, div2.winner);
return { return {
finalist: conf.winner, finalist: conf.winner,
@ -299,11 +292,8 @@ export function simulateConferenceBracket(seeds: SeededTeam[], parityFactor = DE
// ─── Simulator class ────────────────────────────────────────────────────────── // ─── Simulator class ──────────────────────────────────────────────────────────
export class NFLSimulator implements Simulator { export class NFLSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
// Load participants // Load participants
const participants = await db.query.seasonParticipants.findMany({ const participants = await db.query.seasonParticipants.findMany({
@ -395,7 +385,7 @@ export class NFLSimulator implements Simulator {
// Pre-compute per-team win probability vs average opponent (constant across sims) // Pre-compute per-team win probability vs average opponent (constant across sims)
const winProbMap = new Map<string, number>(); const winProbMap = new Map<string, number>();
for (const t of teams) { for (const t of teams) {
winProbMap.set(t.participantId, eloWinProbabilityWithParity(t.elo, AVG_OPPONENT_ELO, parityFactor)); winProbMap.set(t.participantId, eloWinProbability(t.elo, AVG_OPPONENT_ELO));
} }
// Placement counters // Placement counters
@ -406,7 +396,7 @@ export class NFLSimulator implements Simulator {
// ─── Monte Carlo ────────────────────────────────────────────────────────── // ─── Monte Carlo ──────────────────────────────────────────────────────────
for (let sim = 0; sim < numSimulations; sim++) { for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
// Project wins for each team this simulation // Project wins for each team this simulation
const projected = teams.map((t) => ({ const projected = teams.map((t) => ({
participantId: t.participantId, participantId: t.participantId,
@ -416,8 +406,7 @@ export class NFLSimulator implements Simulator {
projectedWins: simulateProjectedWins( projectedWins: simulateProjectedWins(
t.currentWins, t.currentWins,
t.gamesPlayed, t.gamesPlayed,
winProbMap.get(t.participantId) as number, winProbMap.get(t.participantId) as number
seasonGames
), ),
})); }));
@ -427,11 +416,11 @@ export class NFLSimulator implements Simulator {
const nfcSeeds = seedConference(nfcTeams); const nfcSeeds = seedConference(nfcTeams);
// Simulate conference brackets // Simulate conference brackets
const afcResult = simulateConferenceBracket(afcSeeds, parityFactor); const afcResult = simulateConferenceBracket(afcSeeds);
const nfcResult = simulateConferenceBracket(nfcSeeds, parityFactor); const nfcResult = simulateConferenceBracket(nfcSeeds);
// Super Bowl (neutral site) // Super Bowl (neutral site)
const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist, parityFactor); const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist);
// 1st / 2nd // 1st / 2nd
counts[sb.winner.participantId][1] += 1; counts[sb.winner.participantId][1] += 1;
@ -456,7 +445,7 @@ export class NFLSimulator implements Simulator {
// Convert counts to probabilities // Convert counts to probabilities
return teams.map((t) => { return teams.map((t) => {
const c = counts[t.participantId]; const c = counts[t.participantId];
const N = numSimulations; const N = NUM_SIMULATIONS;
return { return {
participantId: t.participantId, participantId: t.participantId,
probabilities: { probabilities: {

View file

@ -64,34 +64,38 @@ import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import {
convertAmericanOddsToProbability,
normalizeProbabilities,
} from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { positiveConfigNumber, configNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ─────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50_000; const NUM_SIMULATIONS = 50_000;
/** NHL regular season games per team. */ /** NHL regular season games per team. */
const DEFAULT_REGULAR_SEASON_GAMES = 82; const NHL_REGULAR_SEASON_GAMES = 82;
/** /**
* Fraction of NHL games that go to overtime / shootout. * Fraction of NHL games that go to overtime / shootout.
* The losing team earns 1 point (instead of 0) in these games. * The losing team earns 1 point (instead of 0) in these games.
* Historical average: ~23% of games. * Historical average: ~23% of games.
*/ */
const DEFAULT_OT_RATE = 0.23; const NHL_OT_RATE = 0.23;
/** /**
* Elo parity factor. NHL defaults to 1000 (higher than the standard 400) to * Elo parity factor. NHL uses 1000 (higher than the standard 400) to reflect
* reflect the elevated game-to-game variance in hockey. Raising it flattens the * the elevated game-to-game variance in hockey.
* finish-position distribution; the season config's `parityFactor` overrides it.
*
* Futures odds influence the simulation only through each team's resolved Elo
* (odds Elo happens once, in the central input-policy resolver). The simulator
* does not blend raw odds into per-game probabilities doing so double-counted
* the same signal and over-sharpened favorites.
*/ */
const DEFAULT_PARITY_FACTOR = 1000; const PARITY_FACTOR = 1000;
/**
* Blend weights for Elo vs. Vegas futures odds when sourceOdds are available.
* Same calibration as the UCL simulator (0.7 / 0.3).
*/
const ELO_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
// ─── Team data (2025-26 season) ─────────────────────────────────────────────── // ─── Team data (2025-26 season) ───────────────────────────────────────────────
// //
@ -175,11 +179,11 @@ export function getTeamData(name: string): NhlTeamData | undefined {
/** /**
* Elo win probability for team A over team B. * Elo win probability for team A over team B.
* P(A) = 1 / (1 + 10^((eloB - eloA) / parityFactor)) * P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
* Exported for unit testing. * Exported for unit testing.
*/ */
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number { export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor)); return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
} }
// ─── Internal types ─────────────────────────────────────────────────────────── // ─── Internal types ───────────────────────────────────────────────────────────
@ -213,10 +217,10 @@ function elo(entry: TeamEntry): number {
* - Regulation loss (0 pts) otherwise * - Regulation loss (0 pts) otherwise
* Returns projected total points for the season. * Returns projected total points for the season.
*/ */
export function simulateProjectedPoints(entry: TeamEntry, otRate = DEFAULT_OT_RATE): number { export function simulateProjectedPoints(entry: TeamEntry): number {
let extra = 0; let extra = 0;
const { winProb, remainingGames } = entry; const { winProb, remainingGames } = entry;
const otlProb = (1 - winProb) * otRate; const otlProb = (1 - winProb) * NHL_OT_RATE;
for (let g = 0; g < remainingGames; g++) { for (let g = 0; g < remainingGames; g++) {
const r = Math.random(); const r = Math.random();
if (r < winProb) { if (r < winProb) {
@ -237,8 +241,8 @@ interface ProjectedTeam {
} }
/** Project one team's end-of-season point total with a random tiebreaker. */ /** Project one team's end-of-season point total with a random tiebreaker. */
function projectTeam(t: TeamEntry, otRate: number): ProjectedTeam { function projectTeam(t: TeamEntry): ProjectedTeam {
return { team: t, pts: simulateProjectedPoints(t, otRate), tb: Math.random() }; return { team: t, pts: simulateProjectedPoints(t), tb: Math.random() };
} }
/** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */ /** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */
@ -249,7 +253,7 @@ function sortProjected(arr: ProjectedTeam[]): ProjectedTeam[] {
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class NHLSimulator implements Simulator { export class NHLSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
// Check for a populated playoff bracket; if found, use bracket-aware simulation. // Check for a populated playoff bracket; if found, use bracket-aware simulation.
@ -267,7 +271,7 @@ export class NHLSimulator implements Simulator {
}); });
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id); const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
if (bracketPopulated) { if (bracketPopulated) {
return this.simulateBracket(sportsSeasonId, allMatches, config); return this.simulateBracket(sportsSeasonId, allMatches);
} }
} }
@ -282,6 +286,7 @@ export class NHLSimulator implements Simulator {
.select({ .select({
participantId: schema.seasonParticipantExpectedValues.participantId, participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo, sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
}) })
.from(schema.seasonParticipantExpectedValues) .from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)), .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
@ -294,12 +299,6 @@ export class NHLSimulator implements Simulator {
); );
} }
// Engine knobs (admin-overridable via the season config; default to NHL values).
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
const otRate = configNumber(config, "overtimeRate", DEFAULT_OT_RATE);
const participantIds = participantRows.map((r) => r.id); const participantIds = participantRows.map((r) => r.id);
// 2. Build standings lookup, sourceElo map, and construct team entries. // 2. Build standings lookup, sourceElo map, and construct team entries.
@ -337,7 +336,7 @@ export class NHLSimulator implements Simulator {
const wins = standing?.wins ?? 0; const wins = standing?.wins ?? 0;
const otLosses = standing?.otLosses ?? 0; const otLosses = standing?.otLosses ?? 0;
const currentPoints = wins * 2 + otLosses; const currentPoints = wins * 2 + otLosses;
const remainingGames = Math.max(0, seasonGames - gamesPlayed); const remainingGames = Math.max(0, NHL_REGULAR_SEASON_GAMES - gamesPlayed);
const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400; const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400;
return { return {
@ -348,7 +347,7 @@ export class NHLSimulator implements Simulator {
division, division,
currentPoints, currentPoints,
remainingGames, remainingGames,
winProb: eloWinProbability(resolvedElo, 1500, parityFactor), winProb: eloWinProbability(resolvedElo, 1500),
resolvedElo, resolvedElo,
}; };
}); });
@ -382,15 +381,40 @@ export class NHLSimulator implements Simulator {
); );
} }
// ─── Futures odds blending ─────────────────────────────────────────────────
const participantIdSet = new Set(participantIds);
const oddsRows = evRows.filter(
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
);
const hasOdds = oddsRows.length > 0;
const normalizedOddsMap = new Map<string, number>();
if (hasOdds) {
const rawProbs = oddsRows.map((r) =>
convertAmericanOddsToProbability(r.sourceOdds ?? 0)
);
const normalized = normalizeProbabilities(rawProbs);
oddsRows.forEach(({ participantId }, i) => {
normalizedOddsMap.set(participantId, normalized[i]);
});
}
// ─── Helpers (defined once, outside the hot loop) ───────────────────────── // ─── Helpers (defined once, outside the hot loop) ─────────────────────────
/** /**
* Per-game win probability for team A over team B, from resolved Elo. * Blended per-game win probability for team A over team B.
* Futures odds already influenced `elo()` via the central oddsElo resolver, * When odds are available: 70% Elo + 30% vig-removed futures head-to-head.
* so they are not blended in again here.
*/ */
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
eloWinProbability(elo(a), elo(b), parityFactor); const eloProb = eloWinProbability(elo(a), elo(b));
if (!hasOdds) return eloProb;
const o1 = normalizedOddsMap.get(a.id) ?? 0;
const o2 = normalizedOddsMap.get(b.id) ?? 0;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
};
/** Simulate a best-of-7 series. Returns winner and loser. */ /** Simulate a best-of-7 series. Returns winner and loser. */
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => { const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
@ -424,7 +448,7 @@ export class NHLSimulator implements Simulator {
// Project all conference teams exactly once (single simulateProjectedPoints call per team). // Project all conference teams exactly once (single simulateProjectedPoints call per team).
const divASet = new Set(divATeams.map((t) => t.id)); const divASet = new Set(divATeams.map((t) => t.id));
const allProjected = sortProjected([...divATeams, ...divBTeams].map((t) => projectTeam(t, otRate))); const allProjected = sortProjected([...divATeams, ...divBTeams].map(projectTeam));
const divAProjected = allProjected.filter((p) => divASet.has(p.team.id)); const divAProjected = allProjected.filter((p) => divASet.has(p.team.id));
const divBProjected = allProjected.filter((p) => !divASet.has(p.team.id)); const divBProjected = allProjected.filter((p) => !divASet.has(p.team.id));
@ -463,7 +487,7 @@ export class NHLSimulator implements Simulator {
let effectiveN = 0; let effectiveN = 0;
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro); const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro);
const westBracket = buildConferenceBracket(westernCentral, westernPacific); const westBracket = buildConferenceBracket(westernCentral, westernPacific);
if (!eastBracket || !westBracket) continue; if (!eastBracket || !westBracket) continue;
@ -550,8 +574,7 @@ export class NHLSimulator implements Simulator {
private async simulateBracket( private async simulateBracket(
sportsSeasonId: string, sportsSeasonId: string,
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>, allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>
config: Record<string, unknown> = {}
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
const db = database(); const db = database();
@ -603,15 +626,13 @@ export class NHLSimulator implements Simulator {
db db
.select({ .select({
participantId: schema.seasonParticipantExpectedValues.participantId, participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo, sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
}) })
.from(schema.seasonParticipantExpectedValues) .from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)), .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
]); ]);
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const allParticipantIds = participantRows.map((r) => r.id); const allParticipantIds = participantRows.map((r) => r.id);
const dbEloMap = new Map<string, number>(); const dbEloMap = new Map<string, number>();
@ -627,6 +648,21 @@ export class NHLSimulator implements Simulator {
eloMap.set(r.id, dbEloMap.get(r.id) ?? getTeamData(r.name)?.elo ?? 1400); eloMap.set(r.id, dbEloMap.get(r.id) ?? getTeamData(r.name)?.elo ?? 1400);
} }
const participantIdSet = new Set(allParticipantIds);
const oddsRows = evRows.filter(
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
);
const hasOdds = oddsRows.length > 0;
const normalizedOddsMap = new Map<string, number>();
if (hasOdds) {
const rawProbs = oddsRows.map((r) => convertAmericanOddsToProbability(r.sourceOdds ?? 0));
const normalized = normalizeProbabilities(rawProbs);
oddsRows.forEach(({ participantId }, i) => {
normalizedOddsMap.set(participantId, normalized[i]);
});
}
// Per-round lookup maps for O(1) access. // Per-round lookup maps for O(1) access.
const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m])); const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m]));
const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m])); const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m]));
@ -635,10 +671,14 @@ export class NHLSimulator implements Simulator {
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
// Futures odds already shaped each team's resolved Elo via the central const gameWinProb = (aId: string, bId: string): number => {
// odds→Elo resolver, so per-game probability is pure Elo (no odds re-blend). const eloProb = eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400);
const gameWinProb = (aId: string, bId: string): number => if (!hasOdds) return eloProb;
eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400, parityFactor); const o1 = normalizedOddsMap.get(aId) ?? 0;
const o2 = normalizedOddsMap.get(bId) ?? 0;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
};
const simSeries = (aId: string, bId: string): { winner: string; loser: string } => { const simSeries = (aId: string, bId: string): { winner: string; loser: string } => {
const winProb = gameWinProb(aId, bId); const winProb = gameWinProb(aId, bId);
@ -678,7 +718,7 @@ export class NHLSimulator implements Simulator {
// ── Monte Carlo simulation loop ─────────────────────────────────────────────── // ── Monte Carlo simulation loop ───────────────────────────────────────────────
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
// Round 1: R1 losers score 0 points — not tracked. // Round 1: R1 losers score 0 points — not tracked.
const r1Winners: string[] = []; const r1Winners: string[] = [];
for (let i = 1; i <= 8; i++) { for (let i = 1; i <= 8; i++) {
@ -718,7 +758,7 @@ export class NHLSimulator implements Simulator {
// ── Convert counts to probability distributions ─────────────────────────────── // ── Convert counts to probability distributions ───────────────────────────────
const N = numSimulations; const N = NUM_SIMULATIONS;
const results: SimulationResult[] = allParticipantIds.map((participantId) => { const results: SimulationResult[] = allParticipantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0;

View file

@ -43,11 +43,10 @@ import { eloWinProbabilityWithParity } from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getParticipantSimulatorInputs } from "~/models/simulator"; import { getParticipantSimulatorInputs } from "~/models/simulator";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ─────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50_000; const NUM_SIMULATIONS = 50_000;
const NLL_REGULAR_SEASON_GAMES = 18; const NLL_REGULAR_SEASON_GAMES = 18;
const NLL_PLAYOFF_TEAMS = 8; const NLL_PLAYOFF_TEAMS = 8;
const PARITY_FACTOR = 400; const PARITY_FACTOR = 400;
@ -217,8 +216,7 @@ export function simulateNllPlayoffs(
*/ */
export function resolveQfMatch( export function resolveQfMatch(
match: MatchSnapshot | undefined, match: MatchSnapshot | undefined,
eloMap: Map<string, number>, eloMap: Map<string, number>
parityFactor = PARITY_FACTOR
): { winner: string; loser: string } { ): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) { if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId }; return { winner: match.winnerId, loser: match.loserId };
@ -233,8 +231,7 @@ export function resolveQfMatch(
} }
const result = simulateNllGame( const result = simulateNllGame(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO }, { participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO }, { participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO }
parityFactor
); );
return { winner: result.winner.participantId, loser: result.loser.participantId }; return { winner: result.winner.participantId, loser: result.loser.participantId };
} }
@ -252,8 +249,7 @@ export function resolveBo3Match(
games: GameSnapshot[], games: GameSnapshot[],
eloMap: Map<string, number>, eloMap: Map<string, number>,
p1Override?: string, p1Override?: string,
p2Override?: string, p2Override?: string
parityFactor = PARITY_FACTOR
): { winner: string; loser: string } { ): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) { if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId }; return { winner: match.winnerId, loser: match.loserId };
@ -282,7 +278,7 @@ export function resolveBo3Match(
const { winner, loser } = simulateBestOfThree( const { winner, loser } = simulateBestOfThree(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO }, { participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO }, { participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
parityFactor, PARITY_FACTOR,
{ winsA: winsP1, winsB: winsP2 } { winsA: winsP1, winsB: winsP2 }
); );
return { winner: winner.participantId, loser: loser.participantId }; return { winner: winner.participantId, loser: loser.participantId };
@ -358,10 +354,8 @@ export function simulateRegularSeasonSeeds(
// ─── Simulator class ────────────────────────────────────────────────────────── // ─── Simulator class ──────────────────────────────────────────────────────────
export class NLLSimulator implements Simulator { export class NLLSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
// ── Load participants ───────────────────────────────────────────────────── // ── Load participants ─────────────────────────────────────────────────────
const participants = await db.query.seasonParticipants.findMany({ const participants = await db.query.seasonParticipants.findMany({
@ -427,7 +421,7 @@ export class NLLSimulator implements Simulator {
}); });
if (bracketMatches.length > 0) { if (bracketMatches.length > 0) {
return this.simulateBracketAware(allIds, eloMap, bracketMatches, parityFactor, numSimulations); return this.simulateBracketAware(allIds, eloMap, bracketMatches);
} }
} }
@ -445,7 +439,7 @@ export class NLLSimulator implements Simulator {
knownSeeds.length === NLL_PLAYOFF_TEAMS && knownSeeds.length === NLL_PLAYOFF_TEAMS &&
new Set(knownSeeds.map((s) => s.seed)).size === NLL_PLAYOFF_TEAMS new Set(knownSeeds.map((s) => s.seed)).size === NLL_PLAYOFF_TEAMS
) { ) {
return this.simulateKnownSeeds(allIds, knownSeeds, parityFactor, numSimulations); return this.simulateKnownSeeds(allIds, knownSeeds);
} }
// Mode 3: regular-season projection (default, including preseason). // Mode 3: regular-season projection (default, including preseason).
@ -472,7 +466,7 @@ export class NLLSimulator implements Simulator {
); );
} }
return this.simulateRegularSeason(allIds, teams, parityFactor, numSimulations); return this.simulateRegularSeason(allIds, teams);
} }
// ── Mode 1: Bracket-Aware ───────────────────────────────────────────────── // ── Mode 1: Bracket-Aware ─────────────────────────────────────────────────
@ -480,9 +474,7 @@ export class NLLSimulator implements Simulator {
private async simulateBracketAware( private async simulateBracketAware(
allIds: string[], allIds: string[],
eloMap: Map<string, number>, eloMap: Map<string, number>,
allMatches: typeof schema.playoffMatches.$inferSelect[], allMatches: typeof schema.playoffMatches.$inferSelect[]
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
const db = database(); const db = database();
@ -543,18 +535,18 @@ export class NLLSimulator implements Simulator {
const [sf1, sf2] = sfMatches; const [sf1, sf2] = sfMatches;
const finalMatch = finalMatches[0]; const finalMatch = finalMatches[0];
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
const r_qf1 = resolveQfMatch(qf1, eloMap, parityFactor); const r_qf1 = resolveQfMatch(qf1, eloMap);
const r_qf2 = resolveQfMatch(qf2, eloMap, parityFactor); const r_qf2 = resolveQfMatch(qf2, eloMap);
const r_qf3 = resolveQfMatch(qf3, eloMap, parityFactor); const r_qf3 = resolveQfMatch(qf3, eloMap);
const r_qf4 = resolveQfMatch(qf4, eloMap, parityFactor); const r_qf4 = resolveQfMatch(qf4, eloMap);
// SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5 bracket arm) // SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5 bracket arm)
const r_sf1 = resolveBo3Match(sf1, gamesByMatch.get(sf1.id) ?? [], eloMap, r_qf1.winner, r_qf2.winner, parityFactor); const r_sf1 = resolveBo3Match(sf1, gamesByMatch.get(sf1.id) ?? [], eloMap, r_qf1.winner, r_qf2.winner);
// SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6 bracket arm) // SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6 bracket arm)
const r_sf2 = resolveBo3Match(sf2, gamesByMatch.get(sf2.id) ?? [], eloMap, r_qf3.winner, r_qf4.winner, parityFactor); const r_sf2 = resolveBo3Match(sf2, gamesByMatch.get(sf2.id) ?? [], eloMap, r_qf3.winner, r_qf4.winner);
const r_final = resolveBo3Match(finalMatch, gamesByMatch.get(finalMatch.id) ?? [], eloMap, r_sf1.winner, r_sf2.winner, parityFactor); const r_final = resolveBo3Match(finalMatch, gamesByMatch.get(finalMatch.id) ?? [], eloMap, r_sf1.winner, r_sf2.winner);
championCounts.set(r_final.winner, (championCounts.get(r_final.winner) ?? 0) + 1); championCounts.set(r_final.winner, (championCounts.get(r_final.winner) ?? 0) + 1);
finalistCounts.set(r_final.loser, (finalistCounts.get(r_final.loser) ?? 0) + 1); finalistCounts.set(r_final.loser, (finalistCounts.get(r_final.loser) ?? 0) + 1);
@ -566,24 +558,22 @@ export class NLLSimulator implements Simulator {
qfLoserCounts.set(r_qf4.loser, (qfLoserCounts.get(r_qf4.loser) ?? 0) + 1); qfLoserCounts.set(r_qf4.loser, (qfLoserCounts.get(r_qf4.loser) ?? 0) + 1);
} }
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations); return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
} }
// ── Mode 2: Known-Seed ──────────────────────────────────────────────────── // ── Mode 2: Known-Seed ────────────────────────────────────────────────────
private simulateKnownSeeds( private simulateKnownSeeds(
allIds: string[], allIds: string[],
seeds: SeedEntry[], seeds: SeedEntry[]
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
): SimulationResult[] { ): SimulationResult[] {
const championCounts = new Map(allIds.map((id) => [id, 0])); const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0])); const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0])); const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0])); const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
const result = simulateNllPlayoffs(seeds, parityFactor); const result = simulateNllPlayoffs(seeds);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1); championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1); finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
for (const id of result.sfLosers) { for (const id of result.sfLosers) {
@ -594,25 +584,23 @@ export class NLLSimulator implements Simulator {
} }
} }
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations); return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
} }
// ── Mode 3: Regular-Season Projection ──────────────────────────────────── // ── Mode 3: Regular-Season Projection ────────────────────────────────────
private simulateRegularSeason( private simulateRegularSeason(
allIds: string[], allIds: string[],
teams: TeamProjection[], teams: TeamProjection[]
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
): SimulationResult[] { ): SimulationResult[] {
const championCounts = new Map(allIds.map((id) => [id, 0])); const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0])); const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0])); const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0])); const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
const seeds = simulateRegularSeasonSeeds(teams, parityFactor); const seeds = simulateRegularSeasonSeeds(teams);
const result = simulateNllPlayoffs(seeds, parityFactor); const result = simulateNllPlayoffs(seeds);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1); championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1); finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
for (const id of result.sfLosers) { for (const id of result.sfLosers) {
@ -623,7 +611,7 @@ export class NLLSimulator implements Simulator {
} }
} }
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations); return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
} }
// ── Shared result builder ───────────────────────────────────────────────── // ── Shared result builder ─────────────────────────────────────────────────
@ -633,10 +621,9 @@ export class NLLSimulator implements Simulator {
championCounts: Map<string, number>, championCounts: Map<string, number>,
finalistCounts: Map<string, number>, finalistCounts: Map<string, number>,
sfLoserCounts: Map<string, number>, sfLoserCounts: Map<string, number>,
qfLoserCounts: Map<string, number>, qfLoserCounts: Map<string, number>
numSimulations = DEFAULT_NUM_SIMULATIONS
): SimulationResult[] { ): SimulationResult[] {
const N = numSimulations; const N = NUM_SIMULATIONS;
return allIds.map((id) => ({ return allIds.map((id) => ({
participantId: id, participantId: id,
probabilities: { probabilities: {

View file

@ -105,7 +105,7 @@ export async function runSportsSeasonSimulation(
try { try {
const simulator = getSimulator(simulatorConfig.simulatorType); const simulator = getSimulator(simulatorConfig.simulatorType);
const results = await simulator.simulate(sportsSeasonId, simulatorConfig.config); const results = await simulator.simulate(sportsSeasonId);
if (results.length === 0) { if (results.length === 0) {
throw new Error("Simulation returned no results. Check that participants have simulator input data."); throw new Error("Simulation returned no results. Check that participants have simulator input data.");

View file

@ -41,11 +41,10 @@ import { database } from "~/database/context";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50000; const NUM_SIMULATIONS = 50000;
/** /**
* Controls how much Elo gaps affect per-frame win probability. * Controls how much Elo gaps affect per-frame win probability.
@ -230,8 +229,7 @@ function shuffle<T>(arr: T[]): T[] {
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class SnookerSimulator implements Simulator { export class SnookerSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database(); const db = database();
// 1. Find the bracket scoring event for this sports season. // 1. Find the bracket scoring event for this sports season.
@ -274,9 +272,9 @@ export class SnookerSimulator implements Simulator {
allMatches.some((m) => m.participant1Id && m.participant2Id); allMatches.some((m) => m.participant1Id && m.participant2Id);
if (bracketPopulated) { if (bracketPopulated) {
return this.simulateBracket(allMatches, eloMap, numSimulations); return this.simulateBracket(allMatches, eloMap);
} else { } else {
return this.simulatePreBracket(sportsSeasonId, eloMap, db, numSimulations); return this.simulatePreBracket(sportsSeasonId, eloMap, db);
} }
} }
@ -284,8 +282,7 @@ export class SnookerSimulator implements Simulator {
private async simulateBracket( private async simulateBracket(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>, allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, number>, eloMap: Map<string, number>
numSimulations: number
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
// Group matches by round, sorted by match count descending (R32 first). // Group matches by round, sorted by match count descending (R32 first).
const byRound = new Map<string, typeof allMatches>(); const byRound = new Map<string, typeof allMatches>();
@ -357,7 +354,7 @@ export class SnookerSimulator implements Simulator {
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
// ── First Round (R32) ────────────────────────────────────────────────── // ── First Round (R32) ──────────────────────────────────────────────────
const r32Winners: string[] = []; const r32Winners: string[] = [];
for (let i = 1; i <= 16; i++) { for (let i = 1; i <= 16; i++) {
@ -436,7 +433,7 @@ export class SnookerSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
} }
return buildResults(participantIds, numSimulations, { return buildResults(participantIds, NUM_SIMULATIONS, {
championCounts, championCounts,
finalistCounts, finalistCounts,
sfLoserCounts, sfLoserCounts,
@ -453,8 +450,7 @@ export class SnookerSimulator implements Simulator {
private async simulatePreBracket( private async simulatePreBracket(
sportsSeasonId: string, sportsSeasonId: string,
eloMap: Map<string, number>, eloMap: Map<string, number>,
db: ReturnType<typeof database>, db: ReturnType<typeof database>
numSimulations: number
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
const allParticipants = await db const allParticipants = await db
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
@ -504,7 +500,7 @@ export class SnookerSimulator implements Simulator {
// are in the DB), skip the qualifying simulation and use them directly as seeds 17-32. // are in the DB), skip the qualifying simulation and use them directly as seeds 17-32.
const needsQualifying = qualifiers.length > 16; const needsQualifying = qualifiers.length > 16;
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
// ── Qualifying: run elimination rounds until exactly 16 qualifiers remain ── // ── Qualifying: run elimination rounds until exactly 16 qualifiers remain ──
let drawnQualifiers: string[]; let drawnQualifiers: string[];
if (!needsQualifying) { if (!needsQualifying) {
@ -583,7 +579,7 @@ export class SnookerSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
} }
return buildResults(allParticipantIds, numSimulations, { return buildResults(allParticipantIds, NUM_SIMULATIONS, {
championCounts, championCounts,
finalistCounts, finalistCounts,
sfLoserCounts, sfLoserCounts,

View file

@ -48,11 +48,10 @@ import { getQPConfig, calculateSplitQualifyingPoints } from "~/models/qualifying
import { resolveStructureSource, type IdTranslator } from "./shared-major"; import { resolveStructureSource, type IdTranslator } from "./shared-major";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 10_000; const NUM_SIMULATIONS = 10_000;
/** /**
* Elo divisor for per-match win probability. * Elo divisor for per-match win probability.
@ -364,8 +363,7 @@ export function drawFromBracket(
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class TennisSimulator implements Simulator { export class TennisSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database(); const db = database();
// 1. Load all participants for this sports season. // 1. Load all participants for this sports season.
@ -507,7 +505,7 @@ export class TennisSimulator implements Simulator {
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array.from({ length: 8 }, () => 0)); const counts: number[][] = Array.from({ length: participantIds.length }, () => Array.from({ length: 8 }, () => 0));
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i])); const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
for (let sim = 0; sim < numSimulations; sim++) { for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
// Start each simulation from the locked actual QP. // Start each simulation from the locked actual QP.
const simQP = new Map<string, number>(actualQPMap); const simQP = new Map<string, number>(actualQPMap);
@ -553,14 +551,14 @@ export class TennisSimulator implements Simulator {
return participantIds.map((participantId, i) => ({ return participantIds.map((participantId, i) => ({
participantId, participantId,
probabilities: { probabilities: {
probFirst: counts[i][0] / numSimulations, probFirst: counts[i][0] / NUM_SIMULATIONS,
probSecond: counts[i][1] / numSimulations, probSecond: counts[i][1] / NUM_SIMULATIONS,
probThird: counts[i][2] / numSimulations, probThird: counts[i][2] / NUM_SIMULATIONS,
probFourth: counts[i][3] / numSimulations, probFourth: counts[i][3] / NUM_SIMULATIONS,
probFifth: counts[i][4] / numSimulations, probFifth: counts[i][4] / NUM_SIMULATIONS,
probSixth: counts[i][5] / numSimulations, probSixth: counts[i][5] / NUM_SIMULATIONS,
probSeventh: counts[i][6] / numSimulations, probSeventh: counts[i][6] / NUM_SIMULATIONS,
probEighth: counts[i][7] / numSimulations, probEighth: counts[i][7] / NUM_SIMULATIONS,
}, },
source: "tennis_grand_slam_monte_carlo", source: "tennis_grand_slam_monte_carlo",
})); }));

View file

@ -33,12 +33,7 @@ export interface SimulationResult {
* Simulators consume a single resolved Elo per participant (already blended from * Simulators consume a single resolved Elo per participant (already blended from
* its sources raw Elo, projections, futures odds by the input policy and * its sources raw Elo, projections, futures odds by the input policy and
* persisted before the run), so they need only the season id. * persisted before the run), so they need only the season id.
*
* `config` is the merged simulator config (profile defaults overlaid with the
* season's overrides) that the runner has already fetched. It carries the engine
* knobs (`iterations`, `parityFactor`, `seasonGames`, ). It is optional so unit
* tests can call `simulate(id)` and get each simulator's built-in defaults.
*/ */
export interface Simulator { export interface Simulator {
simulate(sportsSeasonId: string, config?: Record<string, unknown>): Promise<SimulationResult[]>; simulate(sportsSeasonId: string): Promise<SimulationResult[]>;
} }

View file

@ -5,12 +5,12 @@
* *
* Algorithm: * Algorithm:
* 1. Load the bracket scoring event and all playoff matches from DB * 1. Load the bracket scoring event and all playoff matches from DB
* 2. Load each team's resolved Elo (the input policy already blended any raw * 2. Load futures odds (American format) from participantExpectedValues
* Elo and futures odds into a single sourceElo before the run) * 3. Build two probability signals per team:
* 3. Per-match win probability = eloWinProbabilityWithParity(eloA, eloB, parityFactor). * a. Elo derived from futures via convertFuturesToElo() (long-run team strength)
* Odds are NOT blended in per-match here that would double-count the futures * b. Normalized odds vig-removed implied win probability from the same futures
* signal already baked into the Elo. * 4. Per-match win probability = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
* 5. Simulate `iterations` tournaments, respecting already-completed matches * 5. Simulate 50,000 tournaments, respecting already-completed matches
* 6. Track integer placement counts per tier (champion / finalist / SF loser / QF loser). * 6. Track integer placement counts per tier (champion / finalist / SF loser / QF loser).
* At conversion, exact denominators guarantee column sums of 1.0 by construction: * At conversion, exact denominators guarantee column sums of 1.0 by construction:
* - probFirst = champion / N * - probFirst = champion / N
@ -30,23 +30,19 @@
* *
* Notes: * Notes:
* - Requires futures odds in sourceOdds (American format) to be imported first. * - Requires futures odds in sourceOdds (American format) to be imported first.
* - Falls back to a 1500 Elo (coin flip vs. equals) when no Elo is resolved. * - Falls back to uniform probability (coin flip) when no odds are stored.
* - `parityFactor` (season config) tunes per-match variance; default 400. * - ELO_WEIGHT and ODDS_WEIGHT can be tuned here; 0.7/0.3 matches the Python calibration.
*/ */
import { database } from "~/database/context"; import { database } from "~/database/context";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eloWinProbabilityWithParity } from "~/services/probability-engine"; import { eloWinProbability } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ─────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50000; const NUM_SIMULATIONS = 50000;
/** Elo parity factor. Defaults to 400 (standard formula). */
const DEFAULT_PARITY_FACTOR = 400;
// ─── Odds helper ────────────────────────────────────────────────────────────── // ─── Odds helper ──────────────────────────────────────────────────────────────
@ -59,10 +55,8 @@ export function americanToImpliedProb(odds: number): number {
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class UCLSimulator implements Simulator { export class UCLSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
// 1. Find the bracket scoring event for this sports season. // 1. Find the bracket scoring event for this sports season.
// UCL has exactly one playoff_game event per season. // UCL has exactly one playoff_game event per season.
@ -172,7 +166,7 @@ export class UCLSimulator implements Simulator {
const blendedWinProb = (p1: string, p2: string): number => { const blendedWinProb = (p1: string, p2: string): number => {
const elo1 = eloMap.get(p1) ?? 1500; const elo1 = eloMap.get(p1) ?? 1500;
const elo2 = eloMap.get(p2) ?? 1500; const elo2 = eloMap.get(p2) ?? 1500;
return eloWinProbabilityWithParity(elo1, elo2, parityFactor); return eloWinProbability(elo1, elo2);
}; };
const simMatch = (p1: string, p2: string): { winner: string; loser: string } => { const simMatch = (p1: string, p2: string): { winner: string; loser: string } => {
@ -189,7 +183,7 @@ export class UCLSimulator implements Simulator {
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 10. Run Monte Carlo simulations. // 10. Run Monte Carlo simulations.
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
// ── Round of 16 ────────────────────────────────────────────────────── // ── Round of 16 ──────────────────────────────────────────────────────
// R16 losers: no count added (0 points per scoring rules) // R16 losers: no count added (0 points per scoring rules)
const r16Winners: string[] = []; const r16Winners: string[] = [];
@ -267,7 +261,7 @@ export class UCLSimulator implements Simulator {
// probFirst/Second → N total (1 per sim) // probFirst/Second → N total (1 per sim)
// probThird/Fourth → sfLoserCounts / (2*N) — 2 SF losers per sim // probThird/Fourth → sfLoserCounts / (2*N) — 2 SF losers per sim
// probFifthEighth → qfLoserCounts / (4*N) — 4 QF losers per sim // probFifthEighth → qfLoserCounts / (4*N) — 4 QF losers per sim
const N = numSimulations; const N = NUM_SIMULATIONS;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0;

View file

@ -53,18 +53,14 @@ import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name"; import { normalizeTeamName } from "~/lib/normalize-team-name";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/probability-engine"; import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ─────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50_000; const NUM_SIMULATIONS = 50_000;
/** Elo parity factor. WNBA defaults to 400 (standard formula). */
const DEFAULT_PARITY_FACTOR = 400;
/** WNBA regular season games per team. */ /** WNBA regular season games per team. */
const DEFAULT_REGULAR_SEASON_GAMES = 40; const WNBA_REGULAR_SEASON_GAMES = 40;
/** /**
* Multiplier converting SRS to Elo offset from 1500. * Multiplier converting SRS to Elo offset from 1500.
@ -133,10 +129,9 @@ interface TeamEntry {
export function simSeriesN( export function simSeriesN(
a: TeamEntry, a: TeamEntry,
b: TeamEntry, b: TeamEntry,
winTarget: number, winTarget: number
parityFactor = DEFAULT_PARITY_FACTOR
): { winner: TeamEntry; loser: TeamEntry } { ): { winner: TeamEntry; loser: TeamEntry } {
const winProb = eloWinProbabilityWithParity(a.elo, b.elo, parityFactor); const winProb = eloWinProbability(a.elo, b.elo);
let winsA = 0; let winsA = 0;
let winsB = 0; let winsB = 0;
while (winsA < winTarget && winsB < winTarget) { while (winsA < winTarget && winsB < winTarget) {
@ -158,11 +153,8 @@ function simulateProjectedWins(team: TeamEntry): number {
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class WNBASimulator implements Simulator { export class WNBASimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
// 1. Load participants, standings, and futures odds in parallel. // 1. Load participants, standings, and futures odds in parallel.
const [participantRows, standings, evRows] = await Promise.all([ const [participantRows, standings, evRows] = await Promise.all([
@ -236,8 +228,8 @@ export class WNBASimulator implements Simulator {
name: r.name, name: r.name,
elo, elo,
currentWins: standing?.wins ?? 0, currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, seasonGames - gamesPlayed), remainingGames: Math.max(0, WNBA_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbabilityWithParity(elo, 1500, parityFactor), winProb: eloWinProbability(elo, 1500),
}; };
}); });
@ -263,14 +255,14 @@ export class WNBASimulator implements Simulator {
const r1LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const r1LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 6. Monte Carlo simulation loop. // 6. Monte Carlo simulation loop.
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
const [s1, s2, s3, s4, s5, s6, s7, s8] = buildSeededBracket(); const [s1, s2, s3, s4, s5, s6, s7, s8] = buildSeededBracket();
// Round 1 (best-of-3): 1v8, 4v5, 2v7, 3v6 // Round 1 (best-of-3): 1v8, 4v5, 2v7, 3v6
const r1_a = simSeriesN(s1, s8, 2, parityFactor); const r1_a = simSeriesN(s1, s8, 2);
const r1_b = simSeriesN(s4, s5, 2, parityFactor); const r1_b = simSeriesN(s4, s5, 2);
const r1_c = simSeriesN(s2, s7, 2, parityFactor); const r1_c = simSeriesN(s2, s7, 2);
const r1_d = simSeriesN(s3, s6, 2, parityFactor); const r1_d = simSeriesN(s3, s6, 2);
r1LoserCounts.set(r1_a.loser.id, (r1LoserCounts.get(r1_a.loser.id) ?? 0) + 1); r1LoserCounts.set(r1_a.loser.id, (r1LoserCounts.get(r1_a.loser.id) ?? 0) + 1);
r1LoserCounts.set(r1_b.loser.id, (r1LoserCounts.get(r1_b.loser.id) ?? 0) + 1); r1LoserCounts.set(r1_b.loser.id, (r1LoserCounts.get(r1_b.loser.id) ?? 0) + 1);
@ -278,14 +270,14 @@ export class WNBASimulator implements Simulator {
r1LoserCounts.set(r1_d.loser.id, (r1LoserCounts.get(r1_d.loser.id) ?? 0) + 1); r1LoserCounts.set(r1_d.loser.id, (r1LoserCounts.get(r1_d.loser.id) ?? 0) + 1);
// Semifinals (best-of-5): winner(1/8) vs winner(4/5), winner(2/7) vs winner(3/6) // Semifinals (best-of-5): winner(1/8) vs winner(4/5), winner(2/7) vs winner(3/6)
const sf_a = simSeriesN(r1_a.winner, r1_b.winner, 3, parityFactor); const sf_a = simSeriesN(r1_a.winner, r1_b.winner, 3);
const sf_b = simSeriesN(r1_c.winner, r1_d.winner, 3, parityFactor); const sf_b = simSeriesN(r1_c.winner, r1_d.winner, 3);
semiLoserCounts.set(sf_a.loser.id, (semiLoserCounts.get(sf_a.loser.id) ?? 0) + 1); semiLoserCounts.set(sf_a.loser.id, (semiLoserCounts.get(sf_a.loser.id) ?? 0) + 1);
semiLoserCounts.set(sf_b.loser.id, (semiLoserCounts.get(sf_b.loser.id) ?? 0) + 1); semiLoserCounts.set(sf_b.loser.id, (semiLoserCounts.get(sf_b.loser.id) ?? 0) + 1);
// Finals (best-of-7) // Finals (best-of-7)
const final = simSeriesN(sf_a.winner, sf_b.winner, 4, parityFactor); const final = simSeriesN(sf_a.winner, sf_b.winner, 4);
championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1); championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1);
finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1); finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1);
@ -300,14 +292,14 @@ export class WNBASimulator implements Simulator {
return { return {
participantId, participantId,
probabilities: { probabilities: {
probFirst: c / numSimulations, probFirst: c / NUM_SIMULATIONS,
probSecond: f / numSimulations, probSecond: f / NUM_SIMULATIONS,
probThird: sl / (2 * numSimulations), probThird: sl / (2 * NUM_SIMULATIONS),
probFourth: sl / (2 * numSimulations), probFourth: sl / (2 * NUM_SIMULATIONS),
probFifth: r1 / (4 * numSimulations), probFifth: r1 / (4 * NUM_SIMULATIONS),
probSixth: r1 / (4 * numSimulations), probSixth: r1 / (4 * NUM_SIMULATIONS),
probSeventh: r1 / (4 * numSimulations), probSeventh: r1 / (4 * NUM_SIMULATIONS),
probEighth: r1 / (4 * numSimulations), probEighth: r1 / (4 * NUM_SIMULATIONS),
}, },
source, source,
}; };

View file

@ -39,7 +39,6 @@ import { database } from "~/database/context";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eloWinProbability } from "~/services/probability-engine"; import { eloWinProbability } from "~/services/probability-engine";
import { positiveConfigNumber } from "./config-access";
import { simulateEloSoccerMatch } from "./soccer-helpers"; import { simulateEloSoccerMatch } from "./soccer-helpers";
import { normalizeSimulationResultColumns } from "./simulation-probabilities"; import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
@ -441,9 +440,8 @@ export class WorldCupSimulator implements Simulator {
this.numSimulations = numSimulations; this.numSimulations = numSimulations;
} }
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
// 1. Load all participants for this season // 1. Load all participants for this season
const participantRows = await db.query.seasonParticipants.findMany({ const participantRows = await db.query.seasonParticipants.findMany({
@ -671,7 +669,7 @@ export class WorldCupSimulator implements Simulator {
return seeded; return seeded;
}; };
for (let sim = 0; sim < numSimulations; sim++) { for (let sim = 0; sim < this.numSimulations; sim++) {
// ── Knockout rounds ────────────────────────────────────────── // ── Knockout rounds ──────────────────────────────────────────
// R32 → R16 → QF → SF → Third Place Game + Final, advancing via the // R32 → R16 → QF → SF → Third Place Game + Final, advancing via the
// canonical ceil(n/2) tree. Completed matches lock in real results. // canonical ceil(n/2) tree. Completed matches lock in real results.
@ -721,7 +719,7 @@ export class WorldCupSimulator implements Simulator {
} }
// 9. Convert counts to probabilities // 9. Convert counts to probabilities
const N = numSimulations; const N = this.numSimulations;
const numQfLosers = 4; // 4 QF losers per sim const numQfLosers = 4; // 4 QF losers per sim
const source = const source =

View file

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

View file

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

View file

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

View file

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

View file

@ -1,136 +0,0 @@
/**
* Backfill: re-score qualifying majors so mis-split QP is corrected.
*
* Sibling/mirror windows used to compute a placement's tie span from the players
* present on THAT window's roster (a subset of the field), so a tied group split
* fewer ways and over-awarded tennis Round-of-16 losers landed at 2 QP instead of
* the correct 1.5 (positions 916: (2+2+2+2+1+1+1+1)/8). processQualifyingEvent now
* derives the tie span from the canonical full field, so re-running the fan-out
* rewrites the stored event_results QP, participant totals, and league standings.
*
* This is idempotent and SILENT: notifications are suppressed so re-scoring history
* (2 1.5 for many participants) does not re-ping every league's Discord. It does
* NOT re-link events or designate primaries run backfill-major-linking.ts first if
* the data predates the shared-major model. majorsCompleted needs no fix (it is now
* derived on read from completed qualifying events).
*
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
*
* npx tsx scripts/backfill-qp-resplit.ts # apply
* npx tsx scripts/backfill-qp-resplit.ts --dry # report only
*/
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { eq } from "drizzle-orm";
import * as schema from "../database/schema.js";
import { DatabaseContext, database } from "../database/context.js";
import { isBracketMajor } from "../app/lib/event-utils.js";
import {
syncTournamentResults,
syncMajorFromPrimaryEvent,
} from "../app/services/sync-tournament-results.js";
const DRY = process.argv.includes("--dry");
const log = (...a: unknown[]) => console.log(...a);
async function run() {
const db = database();
// Every tournament-linked qualifying event, grouped by canonical tournament.
const events = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.isQualifyingEvent, true),
with: { sportsSeason: { with: { sport: true } } },
});
const byTournament = new Map<string, typeof events>();
for (const ev of events) {
if (!ev.tournamentId) continue; // standalone/manual events: nothing to fan out
const arr = byTournament.get(ev.tournamentId) ?? [];
arr.push(ev);
byTournament.set(ev.tournamentId, arr);
}
log(`Tournaments with linked qualifying events: ${byTournament.size}`);
let ok = 0;
let failed = 0;
for (const [tournamentId, evs] of byTournament) {
const simulatorType = evs[0]?.sportsSeason?.sport?.simulatorType ?? null;
const bracketMajor = isBracketMajor(simulatorType);
const name = evs[0]?.name ?? tournamentId;
// Only re-score fully-scored majors so we don't mark in-progress ones complete.
const anyComplete = evs.some((e) => e.isComplete);
const markComplete = evs.every((e) => e.isComplete);
if (DRY) {
log(
` (dry) ${name}: ${evs.length} window(s), bracket=${bracketMajor}, ` +
`complete=${markComplete} — would re-score silently`
);
continue;
}
try {
if (bracketMajor) {
// Re-derive canonical from the primary, then fan out to siblings (which now
// split ties by the full field). Primary is scored in place and skipped.
const primary = evs.find((e) => e.isPrimary);
if (!primary) {
log(` ! ${name}: no primary window — skipping (run backfill-major-linking.ts)`);
failed += 1;
continue;
}
const report = await syncMajorFromPrimaryEvent(primary.id, {
markComplete,
skipNotifications: true,
});
ok += report.windowsSynced;
failed += report.windowsFailed;
log(
` ${name}: re-scored via primary — ${report.windowsSynced} ok, ${report.windowsFailed} failed`
);
} else {
// Golf/placement: canonical tournament_results already exist; just fan out.
if (!anyComplete) {
log(` ${name}: no completed window — skipping`);
continue;
}
const report = await syncTournamentResults(tournamentId, {
markComplete,
skipNotifications: true,
});
ok += report.windowsSynced;
failed += report.windowsFailed;
log(
` ${name}: re-scored — ${report.windowsSynced} ok, ${report.windowsFailed} failed`
);
}
} catch (e) {
failed += 1;
log(` ! ${name}: ${(e as Error).message}`);
}
}
log(`\nDone${DRY ? " (dry run — no writes)" : ""}. windows ok=${ok}, failed=${failed}.`);
}
async function main() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("ERROR: DATABASE_URL is required");
process.exit(1);
}
const client = postgres(dbUrl, { max: 1 });
const db = drizzle(client, { schema });
try {
await DatabaseContext.run(db, run);
} finally {
await client.end();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});