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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ import { GradientIcon } from "~/components/ui/GradientIcon";
import { RankingsRow } from "./RankingsRow";
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated";
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { NbaBracketLayout } from "./NbaBracketLayout";
import { TabbedBracketLayout } from "./TabbedBracketLayout";
@ -174,138 +174,6 @@ export function computeEliminatedByRound(
return result;
}
/** The score recorded for one participant in a match, or null if they didn't play in it. */
function participantScore(match: Match, participantId: string | null): string | null {
if (!participantId) return null;
if (participantId === match.participant1Id) return match.participant1Score;
if (participantId === match.participant2Id) return match.participant2Score;
return null;
}
/**
* A consolation final: a round contested by the losers of an earlier round, which
* splits the positions those losers would otherwise share. FIFA's "Third Place Game"
* (fed by the Semifinals) is the only one in the templates today.
*/
export interface ConsolationRound {
/** The consolation round itself, e.g. "Third Place Game". */
round: string;
/** The round whose losers contest it, e.g. "Semifinals". */
feederRound: string;
}
/**
* Find the template's consolation round, if it has one.
* 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. */
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
for (let i = 0; i < rounds.length; i++) {
@ -348,10 +216,12 @@ export function PlayoffBracket({
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
const consolation = findConsolationRound(template);
const thirdPlaceRound = consolation?.round;
const thirdPlaceRound = template?.rounds
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
?.name;
// Build elimination rankings
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
let bracketWinner: Participant | null = null;
const lastRound = rounds[rounds.length - 1];
@ -360,19 +230,43 @@ export function PlayoffBracket({
: null;
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
for (const match of matches) {
if (!match.isComplete || !match.loser) continue;
const eliminatedInRound = eliminatedByRound.get(match.round);
if (!eliminatedInRound?.includes(match.loser.id)) continue;
const loserScore =
match.loserId === match.participant1Id
? match.participant1Score
: match.participant2Score;
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
losersByRound.get(match.round)?.push({
participant: match.loser,
score: loserScore,
ownership: ownershipMap.get(match.loser.id) || null,
});
}
const allBracketParticipantIds = new Set<string>();
for (const match of matches) {
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
}
const rankedEntries = computeRankedEntries(
matches,
rounds,
matchesByRound,
consolation,
ownershipMap
);
const rankedEntries: EliminatedEntry[] = [];
let nextRank = 2;
for (let ri = rounds.length - 1; ri >= 0; ri--) {
const roundName = rounds[ri];
const roundLosers = losersByRound.get(roundName) || [];
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
if (roundLosers.length > 0) {
const rankLabel = `T${nextRank}`;
for (const loser of roundLosers) {
rankedEntries.push({ ...loser, rankLabel });
}
}
nextRank += totalMatchesInRound;
}
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
@ -392,7 +286,7 @@ export function PlayoffBracket({
.map((id) => participantMap.get(id))
.filter((p): p is Participant => p !== undefined)
// Owned-by-a-manager players first, then alphabetical by name.
.toSorted((a, b) => {
.sort((a, b) => {
const aOwned = ownershipMap.has(a.id);
const bOwned = ownershipMap.has(b.id);
if (aOwned !== bOwned) return aOwned ? -1 : 1;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -164,16 +164,12 @@ export interface ResolvedDrawMatch {
* (e.g. Wikipedia) that reports the full draw including completed rounds. A row
* is marked complete when both its winner and loser are known.
*
* @returns counts of rows written (inserted or updated), those carrying a result,
* and the participant ids of losers whose match *transitioned to complete on this
* run* (a row that was absent or not-yet-complete before and is complete now).
* That set is the newly-decided eliminations used to announce knockouts once,
* idempotently across re-syncs, since playoff_matches persist between syncs.
* @returns counts of rows written (inserted or updated) and those carrying a result.
*/
export async function populateBracketFromDraw(
eventId: string,
matches: ResolvedDrawMatch[]
): Promise<{ written: number; completed: number; newlyDecidedLoserIds: string[] }> {
): Promise<{ written: number; completed: number }> {
const db = database();
const existing = await db.query.playoffMatches.findMany({
@ -188,20 +184,12 @@ export async function populateBracketFromDraw(
const toInsert: NewPlayoffMatch[] = [];
let written = 0;
let completed = 0;
const newlyDecidedLoserIds: string[] = [];
for (const m of matches) {
const isComplete = m.winnerId !== null && m.loserId !== null;
if (isComplete) completed++;
const existingRow = byExternalId.get(m.externalMatchId);
// Loser is "newly decided" when this match reaches completion for the first
// time: either a brand-new complete row, or an existing row that was not
// complete before. Re-syncing an already-complete match yields nothing.
if (isComplete && m.loserId && !existingRow?.isComplete) {
newlyDecidedLoserIds.push(m.loserId);
}
if (existingRow) {
await db
.update(schema.playoffMatches)
@ -241,7 +229,7 @@ export async function populateBracketFromDraw(
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;
}
/**
* Given a participant's awarded QP before and after a re-score, return the ids
* whose QP changed. A row's `qp` is the raw decimal string (or null when no QP
* was awarded). A participant counts as changed when their new value differs from
* the old, including a first-time score (null value). Participants absent from
* `after` are not reported (their removal is handled by the caller's recalc).
*
* Shared by the two QP re-score paths processQualifyingEvent (sibling windows,
* CS2, manual admin) and rescoreTennisBracketAndDetectChanges (tennis primary)
* so the "only announce real changes" semantics live in one place.
*/
export function diffChangedQualifyingPoints(
before: Iterable<{ id: string; qp: string | null }>,
after: Iterable<{ id: string; qp: string | null }>
): Set<string> {
const beforeQP = new Map<string, number>();
for (const r of before) {
if (r.qp !== null) beforeQP.set(r.id, parseFloat(r.qp));
}
const changed = new Set<string>();
for (const r of after) {
if (r.qp === null) continue;
if (beforeQP.get(r.id) !== parseFloat(r.qp)) changed.add(r.id);
}
return changed;
}
export function calculateSplitQualifyingPoints(
placement: number,
tieCount: number,
@ -75,6 +48,21 @@ export function calculateSplitQualifyingPoints(
return roundQualifyingPoints(totalQP / tieCount);
}
export function hasProcessedQualifyingPlacement(
results: Array<{
placement?: number | null;
qualifyingPointsAwarded?: string | null;
}>
): boolean {
return results.some(
(result) =>
result.placement !== null &&
result.placement !== undefined &&
result.qualifyingPointsAwarded !== null &&
result.qualifyingPointsAwarded !== undefined
);
}
/**
* Initialize default qualifying point configuration for a sports season
*/

View file

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

View file

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

View file

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

View file

@ -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(
inputs: UpsertParticipantSimulatorInput[]
): Promise<void> {
@ -339,34 +426,16 @@ export async function batchUpsertParticipantSimulatorInputs(
schema.seasonParticipantSimulatorInputs.participantId,
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
],
// Non-destructive: only overwrite a column when the incoming value is
// non-null. This lets a partial import (e.g. odds-only) update just the
// columns it provides without clobbering previously stored inputs like a
// manually entered Elo or rating. Mirrors the COALESCE bridge used for the
// legacy EV table below.
set: {
sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantSimulatorInputs.sourceOdds})`,
sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantSimulatorInputs.sourceElo})`,
worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantSimulatorInputs.worldRanking})`,
rating: sql`COALESCE(excluded.rating, ${schema.seasonParticipantSimulatorInputs.rating})`,
projectedWins: sql`COALESCE(excluded.projected_wins, ${schema.seasonParticipantSimulatorInputs.projectedWins})`,
projectedTablePoints: sql`COALESCE(excluded.projected_table_points, ${schema.seasonParticipantSimulatorInputs.projectedTablePoints})`,
seed: sql`COALESCE(excluded.seed, ${schema.seasonParticipantSimulatorInputs.seed})`,
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
// tell readers whether the stored Elo/rating is generated vs. a trusted
// direct value. 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`,
sourceOdds: sql`excluded.source_odds`,
sourceElo: sql`excluded.source_elo`,
worldRanking: sql`excluded.world_ranking`,
rating: sql`excluded.rating`,
projectedWins: sql`excluded.projected_wins`,
projectedTablePoints: sql`excluded.projected_table_points`,
seed: sql`excluded.seed`,
region: sql`excluded.region`,
metadata: sql`excluded.metadata`,
updatedAt: sql`excluded.updated_at`,
},
});

View file

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

View file

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

View file

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

View file

@ -1,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
</Link>
</Button>
{sim.supportsFuturesOdds && (
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/futures-odds`}>Futures</Link>
</Button>
)}
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link>
</Button>

View file

@ -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 { eq } from "drizzle-orm";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis";
import { newlyDecidedLosers } from "./admin.sports-seasons.$id.events.$eventId.bracket.helpers";
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id);
@ -136,23 +135,9 @@ async function scoreQualifyingBracket(
tournamentId: string | null;
},
db: ReturnType<typeof database>,
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2],
/**
* Season_participant ids of players knocked out for the first time by this
* operation (losers of matches that just reached completion). Forwarded to the
* fan-out so every mirror window announces the "Knocked Out" section a
* non-scoring-round exit earns no QP and is otherwise invisible to the mirror.
* Empty for re-scores/reprocesses, which decide no new losers.
*/
newlyEliminatedParticipantIds?: Set<string>
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2]
): Promise<void> {
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
// recalcs participant QP totals, AND announces the QP change to this window's leagues.
// Calling processQualifyingBracketEvent directly here would score silently — the QP
// Discord notification only fires from processQualifyingEvent. The fan-out below skips
// this (primary) window via skipEventId, so mirror windows are announced separately
// with no double-post.
await processQualifyingEvent(event.id, db, { newlyEliminatedParticipantIds });
await processQualifyingBracketEvent(event.id, db);
await recalculateAffectedLeagues(
event.sportsSeasonId,
db,
@ -160,10 +145,7 @@ async function scoreQualifyingBracket(
);
// If this is the shared major's primary window, propagate to siblings.
// Mid-tournament (a single round): don't mark complete yet.
await fanOutMajorIfPrimary(event, {
markComplete: false,
newlyEliminatedParticipantIds,
});
await fanOutMajorIfPrimary(event, { markComplete: false });
}
/**
@ -416,13 +398,6 @@ export async function action({ request, params }: Route.ActionArgs) {
return { error: "Could not determine loser" };
}
// A knockout is "newly decided" only when the match wasn't already complete
// (mirrors populateBracketFromDraw's first-completion rule) — a re-score/
// correction of an already-finished match must not re-announce the exit.
const setWinnerNewlyEliminated = new Set(
newlyDecidedLosers([{ wasComplete: match.isComplete, loserId }])
);
// Set the winner
await setMatchWinner(matchId, winnerId, loserId);
@ -449,16 +424,11 @@ export async function action({ request, params }: Route.ActionArgs) {
if (event.isQualifyingEvent) {
// Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not
// fantasy points. matchIds scopes the Discord notification to just this match.
await scoreQualifyingBracket(
event,
db,
{
eventId: event.id,
eventName: event.name ?? undefined,
matchIds: [matchId],
},
setWinnerNewlyEliminated
);
await scoreQualifyingBracket(event, db, {
eventId: event.id,
eventName: event.name ?? undefined,
matchIds: [matchId],
});
} else {
// Immediately score this match: loser gets their final placement,
// winner gets provisional floor points (isPartialScore=true).
@ -530,10 +500,6 @@ export async function action({ request, params }: Route.ActionArgs) {
let successCount = 0;
const errors: string[] = [];
const processedMatchIds: string[] = [];
// Per-match completion + loser, collected so newlyDecidedLosers() can pick out
// the batch's first-time knockouts to fan out to mirror windows (a non-scoring-
// round exit earns no QP and is otherwise invisible to the mirror).
const decidedEntries: Array<{ wasComplete: boolean; loserId: string | null }> = [];
for (const { matchId, winnerId } of winnerAssignments) {
try {
@ -602,10 +568,6 @@ export async function action({ request, params }: Route.ActionArgs) {
successCount++;
processedMatchIds.push(matchId);
// Only after the match fully succeeded: record its prior completion so
// newlyDecidedLosers() announces this loser only if it's a first-time exit
// (and never for a match whose write failed above).
decidedEntries.push({ wasComplete: match.isComplete, loserId });
} catch (error) {
logger.error(`Error setting winner for match ${matchId}:`, error);
errors.push(
@ -619,14 +581,9 @@ export async function action({ request, params }: Route.ActionArgs) {
// previously completed matches in the event.
if (successCount > 0) {
const db = database();
// Qualifying majors: derive QP from the full bracket once for the batch, and
// announce the QP change to this window's leagues. processQualifyingEvent (not
// processQualifyingBracketEvent) is what sends the QP Discord notification; the
// fan-out below skips this window (skipEventId) so mirrors don't double-post.
// Qualifying majors: derive QP from the full bracket once for the batch.
if (event.isQualifyingEvent) {
await processQualifyingEvent(event.id, db, {
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
});
await processQualifyingBracketEvent(event.id, db);
}
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
// when computing projected points.
@ -641,12 +598,8 @@ export async function action({ request, params }: Route.ActionArgs) {
if (!event.isQualifyingEvent) {
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
} else {
// Shared major primary window: propagate this round to siblings, carrying
// the batch's newly-decided knockouts so mirrors announce them too.
await fanOutMajorIfPrimary(event, {
markComplete: false,
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
});
// Shared major primary window: propagate this round to siblings.
await fanOutMajorIfPrimary(event, { markComplete: false });
}
}
@ -810,32 +763,11 @@ export async function action({ request, params }: Route.ActionArgs) {
// skipDiscord: reprocess is a data-correction tool, not a result announcement.
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
}
// Re-propagate corrected QP to sibling/mirror windows (data-correction; not
// final). Call syncMajorFromPrimaryEvent directly rather than the
// swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the
// mirrors were re-scored — a silent failure here is exactly how mirrors got
// left showing stale QP behind a green "success".
const baseMessage = `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`;
if (event.isPrimary && event.tournamentId) {
try {
const report = await syncMajorFromPrimaryEvent(event.id, { markComplete: false });
// Surface a partial fan-out as an error so it renders as a warning
// banner, not a green success the admin might skim past while some
// mirror windows are left stale.
if (report.windowsFailed > 0) {
const reasons = report.failures.map((f) => f.error).join("; ");
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.` };
// Re-propagate corrected QP to sibling windows (data-correction; not final).
await fanOutMajorIfPrimary(event, { markComplete: false });
return {
success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`,
};
}
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.
if (event.isQualifyingEvent) {
const db = database();
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent)
// and recalcs participant QP totals. majorsCompleted is derived on read from
// completed qualifying events (see getMajorsCompleted) — marking this event
// complete below is what advances it. Season-wide fantasy finalization stays with
// finalizeQualifyingPoints across all majors.
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
// increments majorsCompleted, and recalcs participant QP totals. Season-wide
// fantasy finalization stays with finalizeQualifyingPoints across all majors.
await processQualifyingEvent(params.eventId, db);
await db
.update(schema.scoringEvents)

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
import { useMemo, useState } from "react";
import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
@ -26,10 +25,6 @@ import {
type UpsertParticipantSimulatorInput,
} from "~/models/simulator";
import { normalizeName } from "~/lib/fuzzy-match";
import {
simulatorInputLabel,
type SimulatorInputKey,
} from "~/services/simulations/manifest";
import {
getSimulatorInputPolicy,
type MissingEloStrategy,
@ -66,22 +61,7 @@ export async function loader({ params }: Route.LoaderArgs) {
const inputPolicy = getSimulatorInputPolicy(config.config);
// Sport-aware preview columns: the intersection of the displayable numeric keys
// with this simulator's required + optional inputs, so each season shows exactly
// the inputs its simulator consumes (F1 = odds, NBA = Elo, NCAA = rating, ...).
// Resolved here (server-only) so the client bundle never imports the simulator
// manifest/registry, which transitively pulls in `.server` modules.
const relevantInputs = new Set<SimulatorInputKey>([
...config.profile.requiredInputs,
...config.profile.optionalInputs,
]);
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 };
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy };
}
interface ActionData {
@ -89,43 +69,6 @@ interface ActionData {
message: string;
}
/**
* Input keys the participant preview can render as a numeric column, in the order
* they appear. Keys not listed (e.g. `region`, `metadata`) are not shown as
* columns; the visible columns for a season are the intersection of this order
* with the simulator's required + optional inputs.
*/
const DISPLAY_INPUT_ORDER: SimulatorInputKey[] = [
"sourceElo",
"sourceOdds",
"worldRanking",
"rating",
"projectedWins",
"projectedTablePoints",
"seed",
];
const PARTICIPANT_PAGE_SIZE = 50;
/**
* Engine knobs that a simulator (or the shared input-policy resolver) actually
* reads from config. The structured Engine fields are limited to these so the UI
* never shows a control that silently does nothing bespoke per-sim constants
* (e.g. homeFieldElo, eloDivisor, srsEloScale, raceNoise) that live in a profile
* but are not read from config stay editable only via the raw-JSON escape hatch.
*/
const HONORED_ENGINE_KNOBS = new Set([
"iterations",
"parityFactor",
"seasonGames",
"overtimeRate",
"matchParityFactor",
"averageOpponentElo",
"baseDrawRate",
"drawDecay",
"ratingScaleFactor",
]);
function parseOptionalNumber(value: string | undefined): number | null {
if (value === undefined || value.trim() === "") return null;
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(
text: string,
sportsSeasonId: string,
@ -212,10 +122,7 @@ function parseInputCsv(
const indexes = new Map(header.map((value, index) => [value, index]));
const nameIndex = indexes.get("name");
if (nameIndex === undefined) {
// No CSV header — treat the paste as sportsbook futures odds, one team per
// line ending in American odds (e.g. `Kansas City Chiefs +450`). This is the
// friendly bulk-futures path; team names are fuzzy-matched to participants.
return parseOddsLines(lines, sportsSeasonId, participants);
throw new Error("Bulk input CSV must include a `name` column.");
}
const inputs: UpsertParticipantSimulatorInput[] = [];
@ -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);
if (!currentConfig) return { success: false, message: "Simulator config not found." };
// Start from the current merged config so keys not exposed as structured
// fields (e.g. string knobs) are preserved untouched.
const next: Record<string, unknown> = { ...currentConfig.config };
// Engine knobs: every numeric field rendered as `engine.<key>`.
for (const [field, value] of formData.entries()) {
if (typeof value !== "string" || !field.startsWith("engine.")) continue;
const key = field.slice("engine.".length);
const parsed = Number(value);
if (value.trim() !== "" && Number.isFinite(parsed)) next[key] = parsed;
}
// Input-derivation policy (only when the simulator consumes Elo/ratings).
if (formData.get("hasInputPolicy") === "1") {
const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
next.inputPolicy = {
...currentPolicy,
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),
};
}
const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
await upsertSportsSeasonSimulatorConfig({
sportsSeasonId,
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") {
@ -349,26 +241,7 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
const suffix = parsed.unmatched.length > 0
? ` ${parsed.unmatched.length} row(s) were unmatched: ${parsed.unmatched.join(", ")}.`
: "";
const saved = `Saved ${parsed.inputs.length} simulator input row(s).${suffix}`;
// Auto-run the simulation so saved inputs immediately drive the standings.
// The save itself already succeeded, so a run that never started (e.g.
// participants missing required inputs) is reported as success with the
// readiness gap — never as a failed save.
try {
await runSportsSeasonSimulation(sportsSeasonId);
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
} catch (runError) {
const reason = runError instanceof Error ? runError.message : "could not run.";
// Distinguish "saved but never ran" (readiness/already-running) from
// "started running and failed mid-run": the runner only flips the season
// to status 'failed' once the simulation itself throws.
const season = await findSportsSeasonById(sportsSeasonId);
if (season?.simulationStatus === "failed") {
return { success: false, message: `${saved} The simulation failed: ${reason}` };
}
return { success: true, message: `${saved} Simulation not run yet: ${reason}` };
}
return { success: true, message: `Saved ${parsed.inputs.length} simulator input row(s).${suffix}` };
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to save simulator inputs." };
}
@ -388,49 +261,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const showsInputPolicy =
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
// Structured engine knobs: every top-level numeric config key (inputPolicy is a
// nested object edited in its own section). Driving the fields from the merged
// config means each simulator shows exactly the knobs it actually reads.
const engineEntries = Object.entries(config.config)
.filter(([key, value]) => HONORED_ENGINE_KNOBS.has(key) && typeof value === "number")
.map(([key, value]) => [key, value as unknown as number] as [string, number]);
// Preview columns are resolved server-side in the loader (see note there) and
// arrive as plain data, so this client component never imports the manifest.
const { inputColumns } = 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 (
<div className="container mx-auto p-6 space-y-6">
<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">
{setupSections.includes("eloRatings") &&<Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
{setupSections.includes("futuresOdds") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/futures-odds`}>Futures Odds</Link></Button>}
{setupSections.includes("eloRatings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
{setupSections.includes("surfaceElo") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/surface-elo`}>Surface Elo</Link></Button>}
{setupSections.includes("golfSkills") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/golf-skills`}>Golf Skills</Link></Button>}
{setupSections.includes("regularStandings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/regular-standings`}>Regular Standings</Link></Button>}
@ -522,164 +353,130 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Simulator Configuration</CardTitle>
<CardDescription>
One place for this season's settings. <strong>Engine</strong> controls how the Monte Carlo
runs; <strong>Input derivation</strong> controls how raw inputs become the single Elo/rating
the engine consumes. Defaults come from the simulator profile; values set here override them
for this season only. Both sections write the same stored config.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Form method="post" className="space-y-6">
<input type="hidden" name="intent" value="save-configuration" />
{showsInputPolicy && <input type="hidden" name="hasInputPolicy" value="1" />}
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold">Engine</h3>
{showsInputPolicy && (
<Card>
<CardHeader>
<CardTitle>Input Policy</CardTitle>
<CardDescription>
Direct inputs win. This simulator can derive Elo from{" "}
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"}
{ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}.
Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="grid gap-4 md:grid-cols-5">
<input type="hidden" name="intent" value="save-input-policy" />
<div className="space-y-2 md:col-span-5">
<Label htmlFor="oddsWeight">Futures vs. Elo Odds Blend Weight (01)</Label>
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
<p className="text-xs text-muted-foreground">
How the simulation runs. A higher <code>parityFactor</code> flattens the finish-position
distribution (favorites win less often); <code>iterations</code> trades speed for precision.
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).
</p>
</div>
{engineEntries.length > 0 ? (
<div className="grid gap-4 md:grid-cols-3">
{engineEntries.map(([key, value]) => (
<div key={key} className="space-y-2">
<Label htmlFor={`engine.${key}`} className="font-mono text-xs">{key}</Label>
<Input id={`engine.${key}`} name={`engine.${key}`} type="number" step="any" defaultValue={value} />
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">This simulator exposes no numeric engine knobs.</p>
{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>
</>
)}
</section>
{showsInputPolicy && (
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold">Input derivation</h3>
<p className="text-xs text-muted-foreground">
Direct inputs win. This simulator can derive Elo from{" "}
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"}
{ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}.
Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
</p>
</div>
<div className="grid gap-4 md:grid-cols-5">
<div className="space-y-2 md:col-span-5">
<Label htmlFor="oddsWeight">Futures vs. Elo Odds Blend Weight (01)</Label>
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
<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} />
{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="ratingMax">Rating Ceiling</Label>
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
<Label htmlFor="fallbackRating">Fallback Rating</Label>
<Input id="fallbackRating" name="fallbackRating" type="number" step="0.01" defaultValue={inputPolicy.fallbackRating} />
</div>
</div>
</section>
)}
<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 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}>
<Save className="mr-2 h-4 w-4" />
Save Configuration
Save Config
</Button>
</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>
</Card>
@ -687,12 +484,8 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
<CardHeader>
<CardTitle>Bulk Simulator Inputs</CardTitle>
<CardDescription>
Two ways to paste, then the simulation re-runs automatically on save:
<strong> CSV</strong> with a header row supported columns: name, sourceElo, sourceOdds,
worldRanking, rating, projectedWins, projectedTablePoints, seed, region (values must not
contain commas); or <strong>futures odds</strong>, one team per line ending in American
odds (e.g. <code>Kansas City Chiefs +450</code>). Team names are fuzzy-matched to
participants. A partial paste only updates the columns it includes other inputs are kept.
Paste CSV with a header row. Supported columns: name, sourceElo, sourceOdds, worldRanking, rating,
projectedWins, projectedTablePoints, seed, region. Values must not contain commas.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -702,7 +495,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
name="bulkInputs"
rows={8}
className="font-mono text-sm"
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5\n\n— or paste futures odds only —\n${inputRows[0]?.participant.name ?? "Team Name"} +2500`}
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5`}
/>
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
@ -710,124 +503,28 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</Button>
</Form>
{externalInputsSection && (
<div className="rounded-md border border-sky-500/30 bg-sky-500/10 p-3 text-sm flex items-center justify-between gap-4">
<span>
This simulator's participant inputs are managed on the{" "}
<strong>{externalInputsSection.label}</strong> page the list below is a roster only.
</span>
<Button variant="outline" size="sm" asChild>
<Link to={externalInputsSection.to}>Go to {externalInputsSection.label}</Link>
</Button>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<Input
type="search"
placeholder="Search participants…"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(0);
}}
className="max-w-xs"
/>
{requiredInputs.length > 0 && (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={onlyMissing}
onChange={(event) => {
setOnlyMissing(event.target.checked);
setPage(0);
}}
/>
Only show participants missing a required input
</label>
)}
</div>
<div className="rounded-md border">
<div
className="grid gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground"
style={{ gridTemplateColumns: gridTemplate }}
>
<div>Participant</div>
{inputColumns.length > 0 ? (
inputColumns.map((column) => (
<div key={column.key} className="capitalize">
{column.label}
{column.required && <span className="text-amber-500"> *</span>}
</div>
))
) : (
<div></div>
)}
<div className="grid grid-cols-6 gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
<div className="col-span-2">Participant</div>
<div>Elo</div>
<div>Odds</div>
<div>Rank</div>
<div>Rating</div>
</div>
{pageRows.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No participants match.
{inputRows.slice(0, 20).map(({ participant, input }) => (
<div key={participant.id} className="grid grid-cols-6 gap-2 border-b last:border-b-0 px-3 py-2 text-sm">
<div className="col-span-2 font-medium">{participant.name}</div>
<div>{input?.sourceElo ?? "—"}</div>
<div>{input?.sourceOdds ?? "—"}</div>
<div>{input?.worldRanking ?? "—"}</div>
<div>{input?.rating ?? "—"}</div>
</div>
))}
{inputRows.length > 20 && (
<div className="px-3 py-2 text-xs text-muted-foreground">
Showing 20 of {inputRows.length} participants
</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>
</CardContent>
</Card>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification, sendQualifyingPointsUpdateNotification } from "../discord";
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification } from "../discord";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
@ -186,41 +186,10 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
});
it("names a non-eliminated loser for context without @-pinging them", async () => {
// Argentina beats England in the World Cup semifinal. Argentina scored, so it's
// pinged; England advances to the 3rd-place playoff (not eliminated, no points
// change), so its manager is shown by plain username but NOT @-pinged.
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Diablo League 2026",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 160, rank: 7 }],
previousStandings: new Map([["a", 130]]),
scoredMatches: [
{
winnerName: "Argentina",
loserName: "England",
winnerUsername: "philosohraptors",
winnerDiscordUserId: "111",
loserUsername: "elementsoul",
// no loserDiscordUserId — still alive, no ping
},
],
});
const payload = getPayload();
const desc = payload.embeds[0].description as string;
// Winner scored → rendered as an @-mention; loser is named by plain username.
expect(desc).toContain("• **Argentina (<@111>)** def. England (elementsoul)");
// England's manager is named but not mentioned/pinged.
expect(desc).not.toContain("England (<@");
expect(payload.content ?? "").toContain("<@111>");
expect(payload.content ?? "").not.toContain("elementsoul");
});
it("shows winner's manager for context when the match fires due to an owned loser", async () => {
// Brazil beats Japan (R32, non-scoring). Japan's manager is the reason for the
// notification; Brazil's manager is shown for context even though they didn't score.
// scoring-calculator.ts controls whether to include the match; discord.ts just renders.
it("shows winner's owner even when winner only advanced (loser was eliminated)", async () => {
// Mirrors the Brazil/Japan scenario: Brazil advanced without earning points,
// Japan was eliminated. winnerUsername is shown in the embed; winnerDiscordUserId
// is left unset (no ping) because the winner did not score this round.
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
@ -230,14 +199,14 @@ describe("sendStandingsUpdateNotification", () => {
{
winnerName: "Brazil",
loserName: "Japan",
winnerUsername: "aliceManager",
winnerUsername: "someManager",
loserUsername: "ikyn",
},
],
});
const desc = getDescription();
expect(desc).toContain("• **Brazil (aliceManager)** def. Japan (ikyn)");
expect(desc).toContain("• **Brazil (someManager)** def. Japan (ikyn)");
});
it("omits scored matches where neither side has a username", async () => {
@ -308,43 +277,6 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).not.toContain("Gamma");
});
it("pings scorers but not rank-only shufflers", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [
// Alpha scored — points changed, moved up.
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1, discordUserId: "111" },
// Beta was displaced down without scoring — rank changed only.
{ teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2, username: "beta_owner", discordUserId: "222" },
],
previousStandings: new Map([
["a", 125],
["b", 125],
]),
previousRanks: new Map([
["a", 2],
["b", 1],
]),
});
const desc = getDescription();
// Beta is still displayed with its rank movement…
expect(desc).toContain("Beta");
expect(desc).toContain("↓1");
// …but by name, not as an @-mention.
expect(desc).not.toContain("<@222>");
// Alpha scored, so it keeps its mention.
expect(desc).toContain("<@111>");
const payload = getPayload();
// Only the scorer is pinged.
expect(payload.content).toContain("111");
expect(payload.content).not.toContain("222");
expect(payload.allowed_mentions.users).toContain("111");
expect(payload.allowed_mentions.users).not.toContain("222");
});
it("omits rank delta when no previousRanks provided", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
@ -743,416 +675,6 @@ describe("sendDraftOrderNotification", () => {
});
});
// The caller now supplies each entry's rank in the FULL season field. This helper
// mirrors the production ranking (qualifying-points-discord.server.ts): sort by
// qpTotal desc, competition ranking with ties sharing the lower rank, so existing
// tests keep asserting ranks derived from qpTotal.
function withRanks<T extends { qpTotal: number }>(entries: T[]) {
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
const rankByTotal = new Map<number, number>();
let prevTotal = Number.NaN;
let prevRank = 0;
sorted.forEach((e, i) => {
const rank = i > 0 && Math.abs(e.qpTotal - prevTotal) < 0.001 ? prevRank : i + 1;
rankByTotal.set(e.qpTotal, rank);
prevTotal = e.qpTotal;
prevRank = rank;
});
const countByTotal = new Map<number, number>();
for (const e of entries) countByTotal.set(e.qpTotal, (countByTotal.get(e.qpTotal) ?? 0) + 1);
return entries.map((e) => ({
...e,
globalRank: rankByTotal.get(e.qpTotal) ?? 0,
globalRankTied: (countByTotal.get(e.qpTotal) ?? 0) > 1,
}));
}
describe("sendQualifyingPointsUpdateNotification", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch(204));
});
const BASE_ENTRIES = withRanks([
{ participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" },
{ participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" },
{ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" },
]);
it("sends an embed with gold color, QP title, no footer, and links the title to the standings page", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
standingsUrl: "https://brackt.com/leagues/abc/sports-seasons/ss-1",
});
const payload = getPayload();
expect(payload.embeds[0].title).toBe("🏅 Qualifying Points Update — Slam League 2025");
expect(payload.embeds[0].color).toBe(0x5865f2);
expect(payload.embeds[0].url).toBe("https://brackt.com/leagues/abc/sports-seasons/ss-1");
expect(payload.embeds[0].footer).toBeUndefined();
});
it("shows sport and event header", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
sportName: "ATP Tennis",
eventName: "Wimbledon 2025",
entries: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**ATP Tennis — Wimbledon 2025**");
});
it("shows Points Awarded section for entries with qpEarned > 0, sorted by QP desc", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**Points Awarded**");
expect(desc).toContain("• **Novak Djokovic (alex)** — 20 QP");
expect(desc).toContain("• **Carlos Alcaraz (chris)** — 14 QP");
// Djokovic (20 QP) should appear before Alcaraz (14 QP)
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
});
it("omits zero-QP drafted participants entirely from the Drafted Participants section", async () => {
// Only participants who have actually scored (qpTotal > 0) are listed; a 0-QP drafted
// player no longer appears anywhere in the standings section.
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
],
scoreboard: [
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
{ participantName: "Also Ran", qpEarned: 0, qpTotal: 5, globalRank: 9, globalRankTied: false, ownerUsername: "chris" },
{ participantName: "Winless Wonder", qpEarned: 0, qpTotal: 0, globalRank: 0, globalRankTied: false, ownerUsername: "sam" },
],
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
// Both scorers appear as ranked rows...
expect(desc).toContain("1\\. Champ (alex) — 100 QP");
expect(desc).toContain("9\\. Also Ran (chris) — 5 QP");
// ...with a Points Bubble divider separating the rank-9 scorer.
expect(desc).toContain("**═══ Points Bubble ═══**");
// The 0-QP player is omitted entirely.
expect(desc).not.toContain("Winless Wonder");
// The old Non-scoring section is gone.
expect(desc).not.toContain("Non-scoring Participants");
});
it("shows the Drafted Participants section for scored participants sorted by rank", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
scoreboard: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP");
expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP");
// Djokovic should rank above Alcaraz
expect(desc.indexOf("1\\. Novak")).toBeLessThan(desc.indexOf("2\\. Carlos"));
// Everyone is rank <= 8, so no divider is emitted.
expect(desc).not.toContain("Points Bubble");
});
it("inserts a Points Bubble divider between the rank-8 and rank-9 scorers", async () => {
const scoreboard = [
{ participantName: "Player Eight", qpEarned: 5, qpTotal: 12, globalRank: 8, globalRankTied: false, ownerUsername: "chris" },
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: scoreboard,
scoreboard,
});
const desc = getDescription();
const eightIdx = desc.indexOf("8\\. Player Eight");
const bubbleIdx = desc.indexOf("**═══ Points Bubble ═══**");
const nineIdx = desc.indexOf("9\\. Player Nine");
expect(eightIdx).toBeGreaterThan(-1);
expect(bubbleIdx).toBeGreaterThan(-1);
expect(nineIdx).toBeGreaterThan(-1);
// Divider sits between the rank-8 and rank-9 rows.
expect(eightIdx).toBeLessThan(bubbleIdx);
expect(bubbleIdx).toBeLessThan(nineIdx);
});
it("omits the Points Bubble divider when every scorer is below the cutoff", async () => {
// globalRank is a season-wide rank but the scoreboard is scoped to one league's drafts,
// so a league can have drafted nobody in the global top 8. The divider must not lead the
// section with nothing above it.
const scoreboard = [
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
{ participantName: "Player Ten", qpEarned: 2, qpTotal: 5, globalRank: 10, globalRankTied: false, ownerUsername: "chris" },
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: scoreboard,
scoreboard,
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
expect(desc).toContain("9\\. Player Nine (alex) — 8 QP");
expect(desc).toContain("10\\. Player Ten (chris) — 5 QP");
// No rank <= 8 row exists, so the divider must not appear.
expect(desc).not.toContain("Points Bubble");
});
it("uses T-prefix for tied QP totals in standings", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: withRanks([
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
]),
scoreboard: withRanks([
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
]),
});
const desc = getDescription();
expect(desc).toContain("T1\\. Player A");
expect(desc).toContain("T1\\. Player B");
expect(desc).toContain("3\\. Player C");
});
it("does not round fractional QP — a 1.5 QP award shows as 1.5, not 2", async () => {
// Regression for the reported bug: a tennis Round-of-16 loser earns 1.5 QP
// (positions 916 split) but Discord rounded it to 2 via Math.round.
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
sportName: "Tennis - Men",
eventName: "Wimbledon",
entries: [
{
participantName: "Novak Djokovic",
qpEarned: 1.5,
qpTotal: 1.5,
globalRank: 9,
globalRankTied: true,
ownerUsername: "snarkymcgee",
},
],
});
const desc = getDescription();
// Mirrors the web UI's formatQP: fractional QP renders with up to 2 decimals and
// trailing zeros trimmed ("1.5"), never rounded to an integer. The Points Awarded
// line no longer carries a "+".
expect(desc).toContain("• **Novak Djokovic (snarkymcgee)** — 1.5 QP");
expect(desc).not.toContain("+");
expect(desc).not.toContain("2 QP");
expect(desc).not.toContain("1.50");
});
it("lists a rank-9 scorer below the Points Bubble but omits a 0-QP participant", async () => {
// Rank-9 scorers now appear in the standings section (below the bubble), while a drafted
// participant with no points is dropped entirely.
const both = [
{
participantName: "Player Eight",
qpEarned: 5,
qpTotal: 10,
globalRank: 8,
globalRankTied: false,
ownerUsername: "eighthowner",
},
{
participantName: "Player Nine",
qpEarned: 3,
qpTotal: 8,
globalRank: 9,
globalRankTied: false,
ownerUsername: "ninthowner",
},
{
participantName: "Player Winless",
qpEarned: 0,
qpTotal: 0,
globalRank: 10,
globalRankTied: false,
ownerUsername: "winlessowner",
},
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
sportName: "Tennis - Men",
eventName: "Wimbledon",
entries: both,
scoreboard: both,
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP");
// The rank-9 scorer now appears as a ranked row below the bubble.
expect(desc).toContain("**═══ Points Bubble ═══**");
expect(desc).toContain("9\\. Player Nine (ninthowner) — 8 QP");
// The 0-QP player is omitted from the standings section entirely.
expect(desc).not.toContain("Player Winless");
// ...but both scorers still earned points, so both remain in Points Awarded.
expect(desc).toContain("• **Player Nine (ninthowner)** — 3 QP");
});
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: withRanks([
{
participantName: "Novak Djokovic",
qpEarned: 20,
qpTotal: 45,
ownerUsername: "alex",
ownerDiscordUserId: "111222333",
},
]),
scoreboard: withRanks([
{
participantName: "Novak Djokovic",
qpEarned: 20,
qpTotal: 45,
ownerUsername: "alex",
},
]),
});
const desc = getDescription();
// Points Awarded (a pinged section) uses the Discord mention...
expect(desc).toContain("• **Novak Djokovic (<@111222333>)** — 20 QP");
// ...while the (non-pinged) Drafted Participants standings section uses the plain username.
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
});
it("pings awarded owners but not non-scoring owners", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: withRanks([
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
]),
});
const payload = getPayload();
expect(payload.content).toContain("<@111>");
expect(payload.content).not.toContain("<@222>");
expect(payload.allowed_mentions.users).toContain("111");
expect(payload.allowed_mentions.users).not.toContain("222");
});
it("does not send when entries array is empty", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [],
});
expect(fetch).not.toHaveBeenCalled();
});
it("shows Knocked Out section for eliminated entries, tagging the manager", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
eliminated: [{ participantName: "Jakob Mensik", ownerUsername: "chris" }],
});
const desc = getDescription();
expect(desc).toContain("**Knocked Out**");
expect(desc).toContain("• Jakob Mensik (chris)");
});
it("sends with only a Knocked Out section when there are no QP entries, omitting the standings section", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [],
eliminated: [{ participantName: "Jakob Mensik", ownerUsername: "chris" }],
});
expect(fetch).toHaveBeenCalledOnce();
const desc = getDescription();
expect(desc).toContain("**Knocked Out**");
expect(desc).not.toContain("**Drafted Participants**");
expect(desc).not.toContain("**Points Awarded**");
});
it("pings opted-in owners who appear only in the Knocked Out section", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [],
eliminated: [{ participantName: "Jakob Mensik", ownerDiscordUserId: "777" }],
});
const payload = getPayload();
expect(payload.content).toContain("<@777>");
expect(payload.allowed_mentions.users).toContain("777");
});
it("escapes markdown in participant names and usernames", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: withRanks([
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
]),
});
const desc = getDescription();
expect(desc).toContain("Player\\_One");
expect(desc).toContain("user\\_name");
});
it("truncates description at 4096 characters", async () => {
const longEntries = withRanks(
Array.from({ length: 200 }, (_, i) => ({
participantName: `Very Long Participant Name Number ${i}`,
qpEarned: i % 2 === 0 ? 5 : 0,
qpTotal: 200 - i,
ownerUsername: `owner_with_long_username_${i}`,
}))
);
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: longEntries,
scoreboard: longEntries,
});
const desc = getDescription();
expect(desc.length).toBeLessThanOrEqual(4096);
expect(desc.endsWith("...")).toBe(true);
});
});
describe("sendPickAnnouncementNotification", () => {
const BASE = {
webhookUrl: WEBHOOK_URL,

View file

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

View file

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

View file

@ -63,17 +63,6 @@ function escapeMarkdown(text: string): string {
return text.replace(/[_*~`|\\]/g, "\\$&");
}
/**
* Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser
* earns 1.5 QP from the 916 split), so we must NOT round: show whole numbers plainly
* and fractional values to at most 2 decimals with trailing zeros trimmed (1.501.5).
* Mirrors the web UI's formatQP (app/components/scoring/QualifyingPointsStandings.tsx)
* so Discord and the site agree.
*/
function formatQPValue(n: number): string {
return parseFloat(n.toFixed(2)).toString();
}
export interface StandingEntry {
teamId: string;
teamName: string;
@ -176,19 +165,13 @@ export async function sendStandingsUpdateNotification({
const isTied = buildTiedRankChecker(standings.map((s) => s.rank));
const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${rank}`);
// A team's points genuinely changed this event (vs. merely being displaced in
// rank because someone else scored). Only real scorers are pinged; rank-only
// shufflers are still displayed, but by name and without an @-mention.
const pointsChanged = (s: StandingEntry) => {
const prevPoints = previousStandings.get(s.teamId);
return prevPoints !== undefined && prevPoints !== s.totalPoints;
};
// Standings changes section — show teams whose points or rank changed.
const changedTeams = standings.filter((s) => {
const prevPoints = previousStandings.get(s.teamId);
const pointsChanged = prevPoints !== undefined && prevPoints !== s.totalPoints;
const prevRank = previousRanks?.get(s.teamId);
const rankChanged = prevRank !== undefined && prevRank !== s.rank;
return pointsChanged(s) || rankChanged;
return pointsChanged || rankChanged;
});
if (changedTeams.length > 0) {
@ -213,7 +196,7 @@ export async function sendStandingsUpdateNotification({
}
const escapedName = escapeMarkdown(s.teamName);
const managerLabel = s.discordUserId && pointsChanged(s)
const managerLabel = s.discordUserId
? `<@${s.discordUserId}>`
: s.username
? escapeMarkdown(s.username)
@ -232,7 +215,7 @@ export async function sendStandingsUpdateNotification({
// Collect Discord user IDs of all opted-in managers appearing in this notification.
const pingUserIds = new Set<string>();
for (const s of changedTeams) {
if (s.discordUserId && pointsChanged(s)) pingUserIds.add(s.discordUserId);
if (s.discordUserId) pingUserIds.add(s.discordUserId);
}
for (const m of relevantMatches ?? []) {
if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
@ -248,7 +231,7 @@ export async function sendStandingsUpdateNotification({
{
title: `📊 Standings Update — ${seasonName}`,
description,
color: 0xffd700, // Gold
color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" },
},
],
@ -264,167 +247,6 @@ export async function sendStandingsUpdateNotification({
await sendDiscordWebhook(webhookUrl, payload);
}
export interface QPEventEntry {
participantName: string;
qpEarned: number;
qpTotal: number;
/**
* The participant's rank in the FULL season QP standings (all participants), not
* their position among this event's scorers. Computed by the caller so the "QP
* Standings" block reflects the whole sport season e.g. two R16 losers on 1.5 QP
* show as T9 (8 players ahead) rather than T1 among just the two of them.
*/
globalRank: number;
/** True when another participant in the full field shares this globalRank. */
globalRankTied: boolean;
ownerUsername?: string;
ownerDiscordUserId?: string;
}
/** A drafted player knocked out this sync in a non-scoring round (0 QP). */
export interface QPEliminatedEntry {
participantName: string;
ownerUsername?: string;
ownerDiscordUserId?: string;
}
export async function sendQualifyingPointsUpdateNotification({
webhookUrl,
seasonName,
sportName,
eventName,
entries,
eliminated = [],
scoreboard = [],
standingsUrl,
}: {
webhookUrl: string;
seasonName: string;
sportName?: string;
eventName?: string;
entries: QPEventEntry[];
eliminated?: QPEliminatedEntry[];
/**
* The full current scoreboard for the league every drafted participant, not just
* those whose QP changed this sync. Drives the "Drafted Participants" standings section.
* `entries`/`eliminated` remain scoped to this sync's changes and drive the
* "Points Awarded"/"Knocked Out" sections and the ping list.
*/
scoreboard?: QPEventEntry[];
standingsUrl?: string;
}): Promise<void> {
if (entries.length === 0 && eliminated.length === 0) return;
const sections: string[] = [];
if (sportName || eventName) {
const parts = [sportName, eventName].filter(Boolean);
sections.push(`**${parts.join(" — ")}**`);
}
const awardedEntries = entries
.filter((e) => e.qpEarned > 0)
.toSorted((a, b) => b.qpEarned - a.qpEarned);
if (awardedEntries.length > 0) {
sections.push("\n**Points Awarded**");
for (const e of awardedEntries) {
const managerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`• **${label}** — ${formatQPValue(e.qpEarned)} QP`);
}
}
// Knocked-out section — drafted players eliminated this sync in a non-scoring
// round. They earn no QP, so they'd otherwise never be surfaced to their manager.
if (eliminated.length > 0) {
sections.push("\n**Knocked Out**");
for (const e of eliminated) {
const managerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`${label}`);
}
}
// Drafted Participants: every drafted participant that has actually scored (qpTotal > 0),
// drawn from the FULL drafted field (`scoreboard`) not just this sync's movers, so it reads
// as a live standings snapshot. Rendered as ranked lines ordered by full-season standing. A
// "Points Bubble" divider marks the cutoff between those currently in the points (rank <= 8)
// and those below it (rank >= 9). Participants with 0 QP are omitted entirely. Never pinged,
// so managers are shown by plain username, never as a <@id> mention.
const scored = [...scoreboard]
.filter((e) => e.qpTotal > 0)
.toSorted((a, b) => a.globalRank - b.globalRank);
// Skip the section entirely when no drafted participant has scored (e.g. a sync that only
// reported knockouts) so we don't emit an empty header.
if (scored.length > 0) {
sections.push("\n**Drafted Participants**");
// Insert the divider once, before the first below-the-cutoff (rank >= 9) row. `>= 9`
// (not `> 8`) keeps a tie AT rank 8 above the bubble ("top 8 plus ties"). Only emit it
// after at least one above-the-bubble row exists: globalRank is a season-wide rank while
// this list is scoped to one league's drafts, so a league can have drafted nobody in the
// global top 8 — guarding on rowsAbove avoids a leading divider with nothing above it.
let bubbleInserted = false;
let rowsAbove = 0;
for (const e of scored) {
if (!bubbleInserted && rowsAbove > 0 && e.globalRank >= 9) {
sections.push("**═══ Points Bubble ═══**");
bubbleInserted = true;
}
if (e.globalRank <= 8) rowsAbove++;
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : "";
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel}${formatQPValue(e.qpTotal)} QP`);
}
}
const MAX_DESCRIPTION = 4096;
let description = sections.join("\n");
if (description.length > MAX_DESCRIPTION) {
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
}
// Ping only managers who earned points or lost a drafted player this sync —
// never the non-scoring (zeroEntries) managers.
const pingUserIds = new Set<string>();
for (const e of [...awardedEntries, ...eliminated]) {
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
}
const pingIds = [...pingUserIds];
const payload: DiscordWebhookPayload = {
embeds: [
{
title: `🏅 Qualifying Points Update — ${seasonName}`,
url: standingsUrl,
description,
color: 0x5865f2, // Discord blurple
},
],
};
if (pingIds.length > 0) {
const cappedIds = pingIds.slice(0, 100);
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
payload.allowed_mentions = { parse: [], users: cappedIds };
}
await sendDiscordWebhook(webhookUrl, payload);
}
export async function sendPickAnnouncementNotification({
webhookUrl,
draftUrl,

View file

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

View file

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

View file

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

View file

@ -69,22 +69,20 @@ import { normalizeTeamName } from "~/lib/normalize-team-name";
import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
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.
* 450 reflects moderate variance lower than NHL (1000) to account for
* 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). */
const DEFAULT_REGULAR_SEASON_GAMES = 23;
const AFL_REGULAR_SEASON_GAMES = 23;
/** Average opponent Elo used for regular season projections. */
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))
* Exported for unit testing.
*/
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
export function eloWinProbability(eloA: number, eloB: number): number {
return eloWinProbabilityWithParity(eloA, eloB, PARITY_FACTOR);
}
// ─── Internal types ───────────────────────────────────────────────────────────
@ -195,11 +193,8 @@ function simulateProjectedWins(entry: TeamEntry): number {
// ─── 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 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.
const [participantRows, evRows, standings] = await Promise.all([
@ -265,8 +260,8 @@ export class AFLSimulator implements Simulator {
name: r.name,
elo: resolvedElo,
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, seasonGames - gamesPlayed),
winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO, parityFactor),
remainingGames: Math.max(0, AFL_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO),
};
});
@ -274,7 +269,7 @@ export class AFLSimulator implements Simulator {
/** Simulate a single AFL game. Returns the winner. */
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b;
Math.random() < eloWinProbability(a.elo, b.elo) ? a : b;
/**
* 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]));
// 4. Monte Carlo simulation loop.
for (let s = 0; s < numSimulations; s++) {
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const finalists = buildFinalsList();
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
// 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 c = championCounts.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 { getSeasonResults } from "~/models/participant-season-result";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── 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. */
const RACE_NOISE = 0.50;
@ -108,8 +107,7 @@ export class AutoRacingSimulator implements Simulator {
private readonly source: string,
) {}
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 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
// from sourceOdds via pure weighted draws.
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);
for (let rank = 0; rank < Math.min(8, finishOrder.length); 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
// much more predictive than early-season odds.
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)
const seasonWeights = new Map<string, number>();
for (const id of ids) {
@ -279,14 +277,14 @@ export class AutoRacingSimulator implements Simulator {
return {
participantId: p.id,
probabilities: {
probFirst: counts[0] / numSimulations,
probSecond: counts[1] / numSimulations,
probThird: counts[2] / numSimulations,
probFourth: counts[3] / numSimulations,
probFifth: counts[4] / numSimulations,
probSixth: counts[5] / numSimulations,
probSeventh: counts[6] / numSimulations,
probEighth: counts[7] / numSimulations,
probFirst: counts[0] / NUM_SIMULATIONS,
probSecond: counts[1] / NUM_SIMULATIONS,
probThird: counts[2] / NUM_SIMULATIONS,
probFourth: counts[3] / NUM_SIMULATIONS,
probFifth: counts[4] / NUM_SIMULATIONS,
probSixth: counts[5] / NUM_SIMULATIONS,
probSeventh: counts[6] / NUM_SIMULATIONS,
probEighth: counts[7] / NUM_SIMULATIONS,
},
source: this.source,
};

View file

@ -22,9 +22,8 @@ import {
eloWinProbabilityWithParity,
} from "~/services/probability-engine";
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 HOCKEY_PARITY_FACTOR = 850;
const DEFAULT_ELO = 1500;
@ -157,8 +156,8 @@ export function buildCollegeHockeyTeams(
return teams;
}
function simGame(team1: Team, team2: Team, parityFactor = HOCKEY_PARITY_FACTOR): { winner: Team; loser: Team } {
const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor);
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, HOCKEY_PARITY_FACTOR);
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);
}
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]> = [
[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[] = [];
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);
bump(counts, result.loser.participantId, "quarterfinalLoser");
}
const sf1 = game(qfWinners[0], qfWinners[1]);
const sf2 = game(qfWinners[2], qfWinners[3]);
const sf1 = simGame(qfWinners[0], qfWinners[1]);
const sf2 = simGame(qfWinners[2], qfWinners[3]);
bump(counts, sf1.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.loser.participantId, "finalist");
}
@ -238,21 +236,19 @@ function simulateMatchFromIds(
p1: string,
p2: string,
match: PlayoffMatch | undefined,
teamById: Map<string, Team>,
parityFactor = HOCKEY_PARITY_FACTOR
teamById: Map<string, Team>
): { winner: Team; loser: Team } {
if (match?.isComplete && match.winnerId) {
const loserId = completedLoser(match);
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(
rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch },
teamById: Map<string, Team>,
counts: Map<string, PlacementCounts>,
parityFactor = HOCKEY_PARITY_FACTOR
counts: Map<string, PlacementCounts>
): void {
const r16Winners: Team[] = [];
for (let i = 0; i < 8; i++) {
@ -260,7 +256,7 @@ function simulateExistingBracket(
if (!match.participant1Id || !match.participant2Id) {
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[] = [];
@ -269,7 +265,7 @@ function simulateExistingBracket(
const p1 = r16Winners[i * 2]?.participantId;
const p2 = r16Winners[i * 2 + 1]?.participantId;
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);
bump(counts, result.loser.participantId, "quarterfinalLoser");
}
@ -280,7 +276,7 @@ function simulateExistingBracket(
const p1 = qfWinners[i * 2]?.participantId;
const p2 = qfWinners[i * 2 + 1]?.participantId;
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);
bump(counts, result.loser.participantId, "semifinalLoser");
}
@ -288,21 +284,21 @@ function simulateExistingBracket(
const p1 = sfWinners[0]?.participantId;
const p2 = sfWinners[1]?.participantId;
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.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 c = counts.get(participantId) ?? { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 };
const sf = c.semifinalLoser / (2 * numSimulations);
const qf = c.quarterfinalLoser / (4 * numSimulations);
const sf = c.semifinalLoser / (2 * NUM_SIMULATIONS);
const qf = c.quarterfinalLoser / (4 * NUM_SIMULATIONS);
return {
participantId,
probabilities: {
probFirst: c.champion / numSimulations,
probSecond: c.finalist / numSimulations,
probFirst: c.champion / NUM_SIMULATIONS,
probSecond: c.finalist / NUM_SIMULATIONS,
probThird: sf,
probFourth: sf,
probFifth: qf,
@ -329,10 +325,8 @@ function toResults(participantIds: string[], counts: Map<string, PlacementCounts
}
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 parityFactor = positiveConfigNumber(config, "parityFactor", HOCKEY_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const bracketEvent = await db.query.scoringEvents.findFirst({
where: and(
@ -379,19 +373,19 @@ export class CollegeHockeySimulator implements Simulator {
);
if (existingBracket) {
for (let i = 0; i < numSimulations; i++) {
simulateExistingBracket(existingBracket, teamById, counts, parityFactor);
for (let i = 0; i < NUM_SIMULATIONS; i++) {
simulateExistingBracket(existingBracket, teamById, counts);
}
} else {
const preBracketMode = participantIds.length > BRACKET_SIZE;
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 ?? [];
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 {

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,
} from "./shared-major";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 10_000;
const NUM_SIMULATIONS = 10_000;
/** Total field size per CS2 Major. */
const FIELD_SIZE = 32;
@ -546,8 +545,7 @@ export function simulateChampionsStage(
// ─── Simulator ────────────────────────────────────────────────────────────────
export class CSMajorSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 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 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);
for (const event of incompleteEvents) {
@ -833,14 +831,14 @@ export class CSMajorSimulator implements Simulator {
return participantIds.map((participantId, i) => ({
participantId,
probabilities: {
probFirst: counts[i][0] / numSimulations,
probSecond: counts[i][1] / numSimulations,
probThird: counts[i][2] / numSimulations,
probFourth: counts[i][3] / numSimulations,
probFifth: counts[i][4] / numSimulations,
probSixth: counts[i][5] / numSimulations,
probSeventh: counts[i][6] / numSimulations,
probEighth: counts[i][7] / numSimulations,
probFirst: counts[i][0] / NUM_SIMULATIONS,
probSecond: counts[i][1] / NUM_SIMULATIONS,
probThird: counts[i][2] / NUM_SIMULATIONS,
probFourth: counts[i][3] / NUM_SIMULATIONS,
probFifth: counts[i][4] / NUM_SIMULATIONS,
probSixth: counts[i][5] / NUM_SIMULATIONS,
probSeventh: counts[i][6] / NUM_SIMULATIONS,
probEighth: counts[i][7] / NUM_SIMULATIONS,
},
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 * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
@ -205,9 +204,8 @@ export class DartsSimulator implements Simulator {
this.numSimulations = numSimulations;
}
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
// 1. Find the bracket scoring event (if it exists).
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);
if (bracketPopulated) {
return this.simulateBracket(allMatches, eloMap, numSimulations);
return this.simulateBracket(allMatches, eloMap);
} 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(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, number>,
numSimulations: number
eloMap: Map<string, number>
): Promise<SimulationResult[]> {
// Group matches by round, sorted by match count descending (R1 first = most matches).
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 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)
const r1Winners: string[] = [];
for (let i = 1; i <= 64; i++) {
@ -442,7 +439,7 @@ export class DartsSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(participantIds, numSimulations, {
return buildResults(participantIds, this.numSimulations, {
championCounts,
finalistCounts,
sfLoserCounts,
@ -458,8 +455,7 @@ export class DartsSimulator implements Simulator {
sportsSeasonId: string,
eloMap: Map<string, number>,
rankingMap: Map<string, number>,
db: ReturnType<typeof database>,
numSimulations: number
db: ReturnType<typeof database>
): Promise<SimulationResult[]> {
const allParticipants = await db
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
@ -524,7 +520,7 @@ export class DartsSimulator implements Simulator {
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.
const drawnUnseeded = shuffle([...unseededPool]);
@ -592,7 +588,7 @@ export class DartsSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(allParticipantIds, numSimulations, {
return buildResults(allParticipantIds, this.numSimulations, {
championCounts,
finalistCounts,
sfLoserCounts,

View file

@ -18,7 +18,6 @@ import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/pro
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers";
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
import { positiveConfigNumber, requiredConfigNumber } from "./config-access";
const DEFAULT_NUM_SIMULATIONS = 10_000;
const DEFAULT_EPL_REGULAR_SEASON_GAMES = 38;
@ -45,6 +44,17 @@ interface SimulatedTableRow {
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. */
export function eplWinProbability(
eloA: number,
@ -89,13 +99,13 @@ export class EPLSimulator implements Simulator {
]);
const config = simulatorConfig?.config;
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_EPL_REGULAR_SEASON_GAMES));
const averageOpponentElo = positiveConfigNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO);
const numSimulations = Math.round(configNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(configNumber(config, "seasonGames", DEFAULT_EPL_REGULAR_SEASON_GAMES));
const averageOpponentElo = configNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO);
const matchOptions = {
baseDrawRate: positiveConfigNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE),
drawDecay: positiveConfigNumber(config, "drawDecay", DEFAULT_DRAW_DECAY),
matchParityFactor: requiredConfigNumber(config, "matchParityFactor", "EPL simulator"),
baseDrawRate: configNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE),
drawDecay: configNumber(config, "drawDecay", DEFAULT_DRAW_DECAY),
matchParityFactor: requiredConfigNumber(config, "matchParityFactor"),
};
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 { getExcludedByEventMap } from "~/models/event-result";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 10_000;
const NUM_SIMULATIONS = 10_000;
/**
* Simulated field size for each major.
@ -181,8 +180,7 @@ export function simulateMajor(
// ─── Simulator ────────────────────────────────────────────────────────────────
export class GolfSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 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]));
for (let sim = 0; sim < numSimulations; sim++) {
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
const simQP = new Map<string, number>(actualQPMap);
for (const { players, restCount, restStrength } of majorConfigs) {
@ -294,14 +292,14 @@ export class GolfSimulator implements Simulator {
return participantIds.map((participantId, i) => ({
participantId,
probabilities: {
probFirst: counts[i][0] / numSimulations,
probSecond: counts[i][1] / numSimulations,
probThird: counts[i][2] / numSimulations,
probFourth: counts[i][3] / numSimulations,
probFifth: counts[i][4] / numSimulations,
probSixth: counts[i][5] / numSimulations,
probSeventh: counts[i][6] / numSimulations,
probEighth: counts[i][7] / numSimulations,
probFirst: counts[i][0] / NUM_SIMULATIONS,
probSecond: counts[i][1] / NUM_SIMULATIONS,
probThird: counts[i][2] / NUM_SIMULATIONS,
probFourth: counts[i][3] / NUM_SIMULATIONS,
probFifth: counts[i][4] / NUM_SIMULATIONS,
probSixth: counts[i][5] / NUM_SIMULATIONS,
probSeventh: counts[i][6] / NUM_SIMULATIONS,
probEighth: counts[i][7] / NUM_SIMULATIONS,
},
source: "golf_qualifying_points_monte_carlo",
}));

View file

@ -254,10 +254,6 @@ export function resolveSourceElos(
);
}
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)) {
oddsElo.set(participantId, elo);
}

View file

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

View file

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

View file

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

View file

@ -53,21 +53,19 @@ import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name";
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.
* 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. */
const DEFAULT_REGULAR_SEASON_GAMES = 82;
const NBA_REGULAR_SEASON_GAMES = 82;
// ─── 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.
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
*/
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor));
export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
}
// ─── Per-position normalization ───────────────────────────────────────────────
@ -159,99 +157,90 @@ function normalizeColumns(results: SimulationResult[]): void {
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
* from a factory keeps every call site unchanged while letting the season config
* tune per-game variance.
* Resolve a play-in single game.
* Uses the real result if the match is complete; otherwise simulates.
* 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) {
/** 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, parityFactor) ? a : b;
return { winner, loser: winner === a ? b : a };
function resolveGame(
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 };
}
/** 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, 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 };
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);
}
/**
* Resolve a play-in single game.
* Uses the real result if the match is complete; otherwise simulates.
* p1Override / p2Override are used when the DB participant slot is still null
* (i.e. filled in by the previous round's simulated result).
*/
function resolveGame(
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 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);
/**
* Resolve a playoff series.
* Uses the real result if the match is complete; otherwise simulates best-of-7.
* p1Override / p2Override fill null DB participant slots with the simulated seed.
*/
function resolveSeries(
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 };
}
/**
* Resolve a playoff series.
* Uses the real result if the match is complete; otherwise simulates best-of-7.
* p1Override / p2Override fill null DB participant slots with the simulated seed.
*/
function resolveSeries(
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);
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 { simGame, simSeries, resolveGame, resolveSeries };
return simSeries(eloMap, p1, p2);
}
// ─── 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();
// ── Mode detection: bracket-aware if a playoff event with matches exists ──
@ -269,25 +258,21 @@ export class NBASimulator implements Simulator {
});
if (bracketMatches.length > 0) {
return this.simulateBracketAware(sportsSeasonId, bracketMatches, config);
return this.simulateBracketAware(sportsSeasonId, bracketMatches);
}
}
// ── Fall back to season-projection mode ───────────────────────────────────
return this.simulateSeasonProjection(sportsSeasonId, config);
return this.simulateSeasonProjection(sportsSeasonId);
}
// ── Mode 1: Bracket-Aware ──────────────────────────────────────────────────
private async simulateBracketAware(
sportsSeasonId: string,
allMatches: DbMatch[],
config: Record<string, unknown> = {}
allMatches: DbMatch[]
): Promise<SimulationResult[]> {
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.
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]));
// ── 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) ─────────────────────────────────
// 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)
// probThird/Fourth → cfLoserCounts / (2×N) — 2 CF 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 c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
@ -482,11 +467,8 @@ export class NBASimulator implements Simulator {
// ── 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 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([
db
@ -547,8 +529,8 @@ export class NBASimulator implements Simulator {
data,
conference,
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, seasonGames - gamesPlayed),
winProb: eloWinProbability(resolvedElo, 1500, parityFactor),
remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(resolvedElo, 1500),
resolvedElo,
};
});
@ -564,10 +546,10 @@ export class NBASimulator implements Simulator {
}
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 winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b), parityFactor);
const winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b));
let wA = 0;
let wB = 0;
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 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 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 c = championCounts.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
* 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo
* (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
* not blended into per-game win probability the input policy already folded
* them into sourceElo, so blending again would double-count them.
* 3. If sourceOdds (American format) are also stored, build a normalized selection
* weight from implied championship probability (used for field selection in step 4)
* 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:
* - 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
@ -31,8 +31,8 @@
* fields, so its champion probability accounts for that.
* Post-bracket (exactly 12 participants): deterministic field, bracket-only sim.
*
* Win probability (per game): eloWinProbabilityWithParity(eloA, eloB, parityFactor),
* where parityFactor comes from the season config (default 400).
* Win probability (per game): eloWinProbability() from probability-engine (400-divisor).
* Blended win probability: ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb (when odds present).
*
* Selection weight (pre-bracket mode):
* With sourceOdds: normalized implied championship probability (vig removed, sums to 1).
@ -59,15 +59,12 @@
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
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 { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ───────────
// ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50_000;
/** Elo parity factor. Defaults to 400 (standard formula). */
const DEFAULT_PARITY_FACTOR = 400;
const NUM_SIMULATIONS = 50_000;
const BRACKET_SIZE = 12;
/**
@ -90,12 +87,12 @@ interface Team {
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Win probability for team1 vs team2 from the single resolved Elo. */
function gameWinProb(team1: Team, team2: Team, parityFactor = DEFAULT_PARITY_FACTOR): number {
return eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor);
function gameWinProb(team1: Team, team2: Team): number {
return eloWinProbability(team1.elo, team2.elo);
}
function simGame(team1: Team, team2: Team, parityFactor = DEFAULT_PARITY_FACTOR): { winner: Team; loser: Team } {
const p1Wins = Math.random() < gameWinProb(team1, team2, parityFactor);
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
const p1Wins = Math.random() < gameWinProb(team1, team2);
return p1Wins
? { winner: team1, loser: team2 }
: { winner: team2, loser: team1 };
@ -144,19 +141,18 @@ interface PlacementCounts {
* Semifinals: qf1w vs qf2w, qf3w vs qf4w
* Championship: sf1w vs sf2w
*/
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, parityFactor = DEFAULT_PARITY_FACTOR): void {
const game = (a: Team, b: Team) => simGame(a, b, parityFactor);
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): void {
// ── First Round (seeds 512) ───────────────────────────────────────────────
const fr1 = game(teams[4], teams[11]); // 5 vs 12
const fr2 = game(teams[5], teams[10]); // 6 vs 11
const fr3 = game(teams[6], teams[9]); // 7 vs 10
const fr4 = game(teams[7], teams[8]); // 8 vs 9
const fr1 = simGame(teams[4], teams[11]); // 5 vs 12
const fr2 = simGame(teams[5], teams[10]); // 6 vs 11
const fr3 = simGame(teams[6], teams[9]); // 7 vs 10
const fr4 = simGame(teams[7], teams[8]); // 8 vs 9
// ── Quarterfinals (seeds 14 get byes) ────────────────────────────────────
const qf1 = game(teams[0], fr4.winner); // 1 vs 8/9 winner
const qf2 = game(teams[3], fr1.winner); // 4 vs 5/12 winner
const qf3 = game(teams[2], fr2.winner); // 3 vs 6/11 winner
const qf4 = game(teams[1], fr3.winner); // 2 vs 7/10 winner
const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner
const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner
const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner
const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner
const bump = (id: string, key: keyof PlacementCounts) => {
const entry = counts.get(id);
@ -169,14 +165,14 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, pa
bump(qf4.loser.participantId, "qfLoser");
// ── Semifinals ────────────────────────────────────────────────────────────
const sf1 = game(qf1.winner, qf2.winner);
const sf2 = game(qf3.winner, qf4.winner);
const sf1 = simGame(qf1.winner, qf2.winner);
const sf2 = simGame(qf3.winner, qf4.winner);
bump(sf1.loser.participantId, "sfLoser");
bump(sf2.loser.participantId, "sfLoser");
// ── National Championship ─────────────────────────────────────────────────
const final = game(sf1.winner, sf2.winner);
const final = simGame(sf1.winner, sf2.winner);
bump(final.winner.participantId, "champion");
bump(final.loser.participantId, "finalist");
@ -185,10 +181,8 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, pa
// ─── 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 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.
const participants = await db
.select({ id: schema.seasonParticipants.id })
@ -247,18 +241,18 @@ export class NCAAFootballSimulator implements Simulator {
);
// 6. Run Monte Carlo simulations.
for (let s = 0; s < numSimulations; s++) {
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const field = preBracketMode
? sampleBracketField(allTeams, BRACKET_SIZE)
: (deterministicField ?? []);
simulateBracket(field, counts, parityFactor);
simulateBracket(field, counts);
}
// 7. Convert counts to probability distributions.
// SF losers: 2 per sim → each team's share = sfLoser / (2 * N).
// QF losers: 4 per sim → each team's share = qfLoser / (4 * N).
const sfDivisor = 2 * numSimulations;
const qfDivisor = 4 * numSimulations;
const sfDivisor = 2 * NUM_SIMULATIONS;
const qfDivisor = 4 * NUM_SIMULATIONS;
return allParticipantIds.map((id) => {
const c = counts.get(id) ?? { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 };
@ -267,8 +261,8 @@ export class NCAAFootballSimulator implements Simulator {
return {
participantId: id,
probabilities: {
probFirst: c.champion / numSimulations,
probSecond: c.finalist / numSimulations,
probFirst: c.champion / NUM_SIMULATIONS,
probSecond: c.finalist / NUM_SIMULATIONS,
probThird: sfProb,
probFourth: sfProb,
probFifth: qfProb,

View file

@ -1,6 +1,5 @@
import { NCAABasketballSimulator } from "./ncaa-basketball-simulator";
import type { Simulator, SimulationResult } from "./types";
import { optionalPositiveNumber } from "./config-access";
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));
}
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 {
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 type { Simulator, SimulationResult } from "./types";
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 { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ───────────
// ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50_000;
/** Elo parity factor. NFL defaults to 400 (standard formula). */
const DEFAULT_PARITY_FACTOR = 400;
const NUM_SIMULATIONS = 50_000;
/** 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. */
const AVG_OPPONENT_ELO = 1500;
@ -177,10 +173,9 @@ export function getTeamData(name: string): NflTeamData | undefined {
function simulateProjectedWins(
currentWins: number,
gamesPlayed: number,
winProb: number,
seasonGames = DEFAULT_REGULAR_SEASON_GAMES
winProb: number
): number {
const remaining = Math.max(0, seasonGames - gamesPlayed);
const remaining = Math.max(0, NFL_REGULAR_SEASON_GAMES - gamesPlayed);
let additional = 0;
for (let g = 0; g < remaining; g++) {
if (Math.random() < winProb) additional++;
@ -236,12 +231,11 @@ export function seedConference(
* 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.
*/
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 winProbHome = eloWinProbabilityWithParity(
const winProbHome = eloWinProbability(
home.elo + NFL_HOME_FIELD_ELO_ADVANTAGE,
away.elo,
parityFactor
away.elo
);
if (Math.random() < winProbHome) return { winner: home, loser: away };
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).
* Used for the Super Bowl.
*/
function playNeutralGame(a: SeededTeam, b: SeededTeam, parityFactor = DEFAULT_PARITY_FACTOR): { winner: SeededTeam; loser: SeededTeam } {
const winProbA = eloWinProbabilityWithParity(a.elo, b.elo, parityFactor);
function playNeutralGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; loser: SeededTeam } {
const winProbA = eloWinProbability(a.elo, b.elo);
if (Math.random() < winProbA) return { winner: a, loser: b };
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
* 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 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)
const wc27 = pg(s(2), s(7));
const wc36 = pg(s(3), s(6));
const wc45 = pg(s(4), s(5));
const wc27 = playGame(s(2), s(7));
const wc36 = playGame(s(3), s(6));
const wc45 = playGame(s(4), s(5));
// Divisional: sort WC winners by original seed (ascending = better seed = home)
// 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
);
const div1 = pg(s(1), wcWinners[wcWinners.length - 1]);
const div2 = pg(wcWinners[0], wcWinners[1]);
const div1 = playGame(s(1), wcWinners[wcWinners.length - 1]);
const div2 = playGame(wcWinners[0], wcWinners[1]);
// Conference Championship (higher remaining seed is home)
const conf = pg(div1.winner, div2.winner);
const conf = playGame(div1.winner, div2.winner);
return {
finalist: conf.winner,
@ -299,11 +292,8 @@ export function simulateConferenceBracket(seeds: SeededTeam[], parityFactor = DE
// ─── Simulator class ──────────────────────────────────────────────────────────
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 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
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)
const winProbMap = new Map<string, number>();
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
@ -406,7 +396,7 @@ export class NFLSimulator implements Simulator {
// ─── Monte Carlo ──────────────────────────────────────────────────────────
for (let sim = 0; sim < numSimulations; sim++) {
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
// Project wins for each team this simulation
const projected = teams.map((t) => ({
participantId: t.participantId,
@ -416,8 +406,7 @@ export class NFLSimulator implements Simulator {
projectedWins: simulateProjectedWins(
t.currentWins,
t.gamesPlayed,
winProbMap.get(t.participantId) as number,
seasonGames
winProbMap.get(t.participantId) as number
),
}));
@ -427,11 +416,11 @@ export class NFLSimulator implements Simulator {
const nfcSeeds = seedConference(nfcTeams);
// Simulate conference brackets
const afcResult = simulateConferenceBracket(afcSeeds, parityFactor);
const nfcResult = simulateConferenceBracket(nfcSeeds, parityFactor);
const afcResult = simulateConferenceBracket(afcSeeds);
const nfcResult = simulateConferenceBracket(nfcSeeds);
// Super Bowl (neutral site)
const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist, parityFactor);
const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist);
// 1st / 2nd
counts[sb.winner.participantId][1] += 1;
@ -456,7 +445,7 @@ export class NFLSimulator implements Simulator {
// Convert counts to probabilities
return teams.map((t) => {
const c = counts[t.participantId];
const N = numSimulations;
const N = NUM_SIMULATIONS;
return {
participantId: t.participantId,
probabilities: {

View file

@ -64,34 +64,38 @@ import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { logger } from "~/lib/logger";
import {
convertAmericanOddsToProbability,
normalizeProbabilities,
} from "~/services/probability-engine";
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. */
const DEFAULT_REGULAR_SEASON_GAMES = 82;
const NHL_REGULAR_SEASON_GAMES = 82;
/**
* Fraction of NHL games that go to overtime / shootout.
* The losing team earns 1 point (instead of 0) in these 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
* reflect the elevated game-to-game variance in hockey. Raising it flattens the
* 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.
* Elo parity factor. NHL uses 1000 (higher than the standard 400) to reflect
* the elevated game-to-game variance in hockey.
*/
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) ───────────────────────────────────────────────
//
@ -175,11 +179,11 @@ export function getTeamData(name: string): NhlTeamData | undefined {
/**
* 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.
*/
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor));
export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
}
// ─── Internal types ───────────────────────────────────────────────────────────
@ -213,10 +217,10 @@ function elo(entry: TeamEntry): number {
* - Regulation loss (0 pts) otherwise
* 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;
const { winProb, remainingGames } = entry;
const otlProb = (1 - winProb) * otRate;
const otlProb = (1 - winProb) * NHL_OT_RATE;
for (let g = 0; g < remainingGames; g++) {
const r = Math.random();
if (r < winProb) {
@ -237,8 +241,8 @@ interface ProjectedTeam {
}
/** Project one team's end-of-season point total with a random tiebreaker. */
function projectTeam(t: TeamEntry, otRate: number): ProjectedTeam {
return { team: t, pts: simulateProjectedPoints(t, otRate), tb: Math.random() };
function projectTeam(t: TeamEntry): ProjectedTeam {
return { team: t, pts: simulateProjectedPoints(t), tb: Math.random() };
}
/** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */
@ -249,7 +253,7 @@ function sortProjected(arr: ProjectedTeam[]): ProjectedTeam[] {
// ─── 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();
// 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);
if (bracketPopulated) {
return this.simulateBracket(sportsSeasonId, allMatches, config);
return this.simulateBracket(sportsSeasonId, allMatches);
}
}
@ -282,6 +286,7 @@ export class NHLSimulator implements Simulator {
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
})
.from(schema.seasonParticipantExpectedValues)
.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);
// 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 otLosses = standing?.otLosses ?? 0;
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;
return {
@ -348,7 +347,7 @@ export class NHLSimulator implements Simulator {
division,
currentPoints,
remainingGames,
winProb: eloWinProbability(resolvedElo, 1500, parityFactor),
winProb: eloWinProbability(resolvedElo, 1500),
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) ─────────────────────────
/**
* Per-game win probability for team A over team B, from resolved Elo.
* Futures odds already influenced `elo()` via the central oddsElo resolver,
* so they are not blended in again here.
* Blended per-game win probability for team A over team B.
* When odds are available: 70% Elo + 30% vig-removed futures head-to-head.
*/
const gameWinProb = (a: TeamEntry, b: TeamEntry): number =>
eloWinProbability(elo(a), elo(b), parityFactor);
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
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. */
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).
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 divBProjected = allProjected.filter((p) => !divASet.has(p.team.id));
@ -463,7 +487,7 @@ export class NHLSimulator implements Simulator {
let effectiveN = 0;
for (let s = 0; s < numSimulations; s++) {
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro);
const westBracket = buildConferenceBracket(westernCentral, westernPacific);
if (!eastBracket || !westBracket) continue;
@ -550,8 +574,7 @@ export class NHLSimulator implements Simulator {
private async simulateBracket(
sportsSeasonId: string,
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
config: Record<string, unknown> = {}
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>
): Promise<SimulationResult[]> {
const db = database();
@ -603,15 +626,13 @@ export class NHLSimulator implements Simulator {
db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.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 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);
}
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.
const r1ByNum = new Map(r1Matches.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 ──────────────────────────────────────────────────────────────────
// Futures odds already shaped each team's resolved Elo via the central
// odds→Elo resolver, so per-game probability is pure Elo (no odds re-blend).
const gameWinProb = (aId: string, bId: string): number =>
eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400, parityFactor);
const gameWinProb = (aId: string, bId: string): number => {
const eloProb = eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400);
if (!hasOdds) return eloProb;
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 winProb = gameWinProb(aId, bId);
@ -678,7 +718,7 @@ export class NHLSimulator implements Simulator {
// ── 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.
const r1Winners: string[] = [];
for (let i = 1; i <= 8; i++) {
@ -718,7 +758,7 @@ export class NHLSimulator implements Simulator {
// ── Convert counts to probability distributions ───────────────────────────────
const N = numSimulations;
const N = NUM_SIMULATIONS;
const results: SimulationResult[] = allParticipantIds.map((participantId) => {
const c = championCounts.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 { getParticipantSimulatorInputs } from "~/models/simulator";
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_PLAYOFF_TEAMS = 8;
const PARITY_FACTOR = 400;
@ -217,8 +216,7 @@ export function simulateNllPlayoffs(
*/
export function resolveQfMatch(
match: MatchSnapshot | undefined,
eloMap: Map<string, number>,
parityFactor = PARITY_FACTOR
eloMap: Map<string, number>
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
@ -233,8 +231,7 @@ export function resolveQfMatch(
}
const result = simulateNllGame(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
parityFactor
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO }
);
return { winner: result.winner.participantId, loser: result.loser.participantId };
}
@ -252,8 +249,7 @@ export function resolveBo3Match(
games: GameSnapshot[],
eloMap: Map<string, number>,
p1Override?: string,
p2Override?: string,
parityFactor = PARITY_FACTOR
p2Override?: string
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
@ -282,7 +278,7 @@ export function resolveBo3Match(
const { winner, loser } = simulateBestOfThree(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
parityFactor,
PARITY_FACTOR,
{ winsA: winsP1, winsB: winsP2 }
);
return { winner: winner.participantId, loser: loser.participantId };
@ -358,10 +354,8 @@ export function simulateRegularSeasonSeeds(
// ─── Simulator class ──────────────────────────────────────────────────────────
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 parityFactor = positiveConfigNumber(config, "parityFactor", PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
// ── Load participants ─────────────────────────────────────────────────────
const participants = await db.query.seasonParticipants.findMany({
@ -427,7 +421,7 @@ export class NLLSimulator implements Simulator {
});
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 &&
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).
@ -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 ─────────────────────────────────────────────────
@ -480,9 +474,7 @@ export class NLLSimulator implements Simulator {
private async simulateBracketAware(
allIds: string[],
eloMap: Map<string, number>,
allMatches: typeof schema.playoffMatches.$inferSelect[],
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
allMatches: typeof schema.playoffMatches.$inferSelect[]
): Promise<SimulationResult[]> {
const db = database();
@ -543,18 +535,18 @@ export class NLLSimulator implements Simulator {
const [sf1, sf2] = sfMatches;
const finalMatch = finalMatches[0];
for (let s = 0; s < numSimulations; s++) {
const r_qf1 = resolveQfMatch(qf1, eloMap, parityFactor);
const r_qf2 = resolveQfMatch(qf2, eloMap, parityFactor);
const r_qf3 = resolveQfMatch(qf3, eloMap, parityFactor);
const r_qf4 = resolveQfMatch(qf4, eloMap, parityFactor);
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const r_qf1 = resolveQfMatch(qf1, eloMap);
const r_qf2 = resolveQfMatch(qf2, eloMap);
const r_qf3 = resolveQfMatch(qf3, eloMap);
const r_qf4 = resolveQfMatch(qf4, eloMap);
// 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)
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);
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);
}
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations);
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
}
// ── Mode 2: Known-Seed ────────────────────────────────────────────────────
private simulateKnownSeeds(
allIds: string[],
seeds: SeedEntry[],
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
seeds: SeedEntry[]
): SimulationResult[] {
const championCounts = 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 qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < numSimulations; s++) {
const result = simulateNllPlayoffs(seeds, parityFactor);
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const result = simulateNllPlayoffs(seeds);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
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 ────────────────────────────────────
private simulateRegularSeason(
allIds: string[],
teams: TeamProjection[],
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
teams: TeamProjection[]
): SimulationResult[] {
const championCounts = 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 qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < numSimulations; s++) {
const seeds = simulateRegularSeasonSeeds(teams, parityFactor);
const result = simulateNllPlayoffs(seeds, parityFactor);
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const seeds = simulateRegularSeasonSeeds(teams);
const result = simulateNllPlayoffs(seeds);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
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 ─────────────────────────────────────────────────
@ -633,10 +621,9 @@ export class NLLSimulator implements Simulator {
championCounts: Map<string, number>,
finalistCounts: Map<string, number>,
sfLoserCounts: Map<string, number>,
qfLoserCounts: Map<string, number>,
numSimulations = DEFAULT_NUM_SIMULATIONS
qfLoserCounts: Map<string, number>
): SimulationResult[] {
const N = numSimulations;
const N = NUM_SIMULATIONS;
return allIds.map((id) => ({
participantId: id,
probabilities: {

View file

@ -105,7 +105,7 @@ export async function runSportsSeasonSimulation(
try {
const simulator = getSimulator(simulatorConfig.simulatorType);
const results = await simulator.simulate(sportsSeasonId, simulatorConfig.config);
const results = await simulator.simulate(sportsSeasonId);
if (results.length === 0) {
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 * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50000;
const NUM_SIMULATIONS = 50000;
/**
* Controls how much Elo gaps affect per-frame win probability.
@ -230,8 +229,7 @@ function shuffle<T>(arr: T[]): T[] {
// ─── Simulator ────────────────────────────────────────────────────────────────
export class SnookerSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 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);
if (bracketPopulated) {
return this.simulateBracket(allMatches, eloMap, numSimulations);
return this.simulateBracket(allMatches, eloMap);
} 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(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, number>,
numSimulations: number
eloMap: Map<string, number>
): Promise<SimulationResult[]> {
// Group matches by round, sorted by match count descending (R32 first).
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 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) ──────────────────────────────────────────────────
const r32Winners: string[] = [];
for (let i = 1; i <= 16; i++) {
@ -436,7 +433,7 @@ export class SnookerSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(participantIds, numSimulations, {
return buildResults(participantIds, NUM_SIMULATIONS, {
championCounts,
finalistCounts,
sfLoserCounts,
@ -453,8 +450,7 @@ export class SnookerSimulator implements Simulator {
private async simulatePreBracket(
sportsSeasonId: string,
eloMap: Map<string, number>,
db: ReturnType<typeof database>,
numSimulations: number
db: ReturnType<typeof database>
): Promise<SimulationResult[]> {
const allParticipants = await db
.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.
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 ──
let drawnQualifiers: string[];
if (!needsQualifying) {
@ -583,7 +579,7 @@ export class SnookerSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(allParticipantIds, numSimulations, {
return buildResults(allParticipantIds, NUM_SIMULATIONS, {
championCounts,
finalistCounts,
sfLoserCounts,

View file

@ -48,11 +48,10 @@ import { getQPConfig, calculateSplitQualifyingPoints } from "~/models/qualifying
import { resolveStructureSource, type IdTranslator } from "./shared-major";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 10_000;
const NUM_SIMULATIONS = 10_000;
/**
* Elo divisor for per-match win probability.
@ -364,8 +363,7 @@ export function drawFromBracket(
// ─── Simulator ────────────────────────────────────────────────────────────────
export class TennisSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 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 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.
const simQP = new Map<string, number>(actualQPMap);
@ -553,14 +551,14 @@ export class TennisSimulator implements Simulator {
return participantIds.map((participantId, i) => ({
participantId,
probabilities: {
probFirst: counts[i][0] / numSimulations,
probSecond: counts[i][1] / numSimulations,
probThird: counts[i][2] / numSimulations,
probFourth: counts[i][3] / numSimulations,
probFifth: counts[i][4] / numSimulations,
probSixth: counts[i][5] / numSimulations,
probSeventh: counts[i][6] / numSimulations,
probEighth: counts[i][7] / numSimulations,
probFirst: counts[i][0] / NUM_SIMULATIONS,
probSecond: counts[i][1] / NUM_SIMULATIONS,
probThird: counts[i][2] / NUM_SIMULATIONS,
probFourth: counts[i][3] / NUM_SIMULATIONS,
probFifth: counts[i][4] / NUM_SIMULATIONS,
probSixth: counts[i][5] / NUM_SIMULATIONS,
probSeventh: counts[i][6] / NUM_SIMULATIONS,
probEighth: counts[i][7] / NUM_SIMULATIONS,
},
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
* its sources raw Elo, projections, futures odds by the input policy and
* 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 {
simulate(sportsSeasonId: string, config?: Record<string, unknown>): Promise<SimulationResult[]>;
simulate(sportsSeasonId: string): Promise<SimulationResult[]>;
}

View file

@ -5,12 +5,12 @@
*
* Algorithm:
* 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
* Elo and futures odds into a single sourceElo before the run)
* 3. Per-match win probability = eloWinProbabilityWithParity(eloA, eloB, parityFactor).
* Odds are NOT blended in per-match here that would double-count the futures
* signal already baked into the Elo.
* 5. Simulate `iterations` tournaments, respecting already-completed matches
* 2. Load futures odds (American format) from participantExpectedValues
* 3. Build two probability signals per team:
* a. Elo derived from futures via convertFuturesToElo() (long-run team strength)
* b. Normalized odds vig-removed implied win probability from the same futures
* 4. Per-match win probability = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
* 5. Simulate 50,000 tournaments, respecting already-completed matches
* 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:
* - probFirst = champion / N
@ -30,23 +30,19 @@
*
* Notes:
* - 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.
* - `parityFactor` (season config) tunes per-match variance; default 400.
* - Falls back to uniform probability (coin flip) when no odds are stored.
* - ELO_WEIGHT and ODDS_WEIGHT can be tuned here; 0.7/0.3 matches the Python calibration.
*/
import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
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 { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ───────────
// ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50000;
/** Elo parity factor. Defaults to 400 (standard formula). */
const DEFAULT_PARITY_FACTOR = 400;
const NUM_SIMULATIONS = 50000;
// ─── Odds helper ──────────────────────────────────────────────────────────────
@ -59,10 +55,8 @@ export function americanToImpliedProb(odds: number): number {
// ─── 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 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.
// 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 elo1 = eloMap.get(p1) ?? 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 } => {
@ -189,7 +183,7 @@ export class UCLSimulator implements Simulator {
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 10. Run Monte Carlo simulations.
for (let s = 0; s < numSimulations; s++) {
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// ── Round of 16 ──────────────────────────────────────────────────────
// R16 losers: no count added (0 points per scoring rules)
const r16Winners: string[] = [];
@ -267,7 +261,7 @@ export class UCLSimulator implements Simulator {
// probFirst/Second → N total (1 per sim)
// probThird/Fourth → sfLoserCounts / (2*N) — 2 SF 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 c = championCounts.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 { normalizeTeamName } from "~/lib/normalize-team-name";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/probability-engine";
import { positiveConfigNumber } from "./config-access";
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
// ─── Simulation parameters (defaults; overridable via season config) ───────────
// ─── Simulation parameters ────────────────────────────────────────────────────
const DEFAULT_NUM_SIMULATIONS = 50_000;
/** Elo parity factor. WNBA defaults to 400 (standard formula). */
const DEFAULT_PARITY_FACTOR = 400;
const NUM_SIMULATIONS = 50_000;
/** 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.
@ -133,10 +129,9 @@ interface TeamEntry {
export function simSeriesN(
a: TeamEntry,
b: TeamEntry,
winTarget: number,
parityFactor = DEFAULT_PARITY_FACTOR
winTarget: number
): { winner: TeamEntry; loser: TeamEntry } {
const winProb = eloWinProbabilityWithParity(a.elo, b.elo, parityFactor);
const winProb = eloWinProbability(a.elo, b.elo);
let winsA = 0;
let winsB = 0;
while (winsA < winTarget && winsB < winTarget) {
@ -158,11 +153,8 @@ function simulateProjectedWins(team: TeamEntry): number {
// ─── 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 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.
const [participantRows, standings, evRows] = await Promise.all([
@ -236,8 +228,8 @@ export class WNBASimulator implements Simulator {
name: r.name,
elo,
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, seasonGames - gamesPlayed),
winProb: eloWinProbabilityWithParity(elo, 1500, parityFactor),
remainingGames: Math.max(0, WNBA_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(elo, 1500),
};
});
@ -263,14 +255,14 @@ export class WNBASimulator implements Simulator {
const r1LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 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();
// Round 1 (best-of-3): 1v8, 4v5, 2v7, 3v6
const r1_a = simSeriesN(s1, s8, 2, parityFactor);
const r1_b = simSeriesN(s4, s5, 2, parityFactor);
const r1_c = simSeriesN(s2, s7, 2, parityFactor);
const r1_d = simSeriesN(s3, s6, 2, parityFactor);
const r1_a = simSeriesN(s1, s8, 2);
const r1_b = simSeriesN(s4, s5, 2);
const r1_c = simSeriesN(s2, s7, 2);
const r1_d = simSeriesN(s3, s6, 2);
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);
@ -278,14 +270,14 @@ export class WNBASimulator implements Simulator {
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)
const sf_a = simSeriesN(r1_a.winner, r1_b.winner, 3, parityFactor);
const sf_b = simSeriesN(r1_c.winner, r1_d.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);
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);
// 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);
finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1);
@ -300,14 +292,14 @@ export class WNBASimulator implements Simulator {
return {
participantId,
probabilities: {
probFirst: c / numSimulations,
probSecond: f / numSimulations,
probThird: sl / (2 * numSimulations),
probFourth: sl / (2 * numSimulations),
probFifth: r1 / (4 * numSimulations),
probSixth: r1 / (4 * numSimulations),
probSeventh: r1 / (4 * numSimulations),
probEighth: r1 / (4 * numSimulations),
probFirst: c / NUM_SIMULATIONS,
probSecond: f / NUM_SIMULATIONS,
probThird: sl / (2 * NUM_SIMULATIONS),
probFourth: sl / (2 * NUM_SIMULATIONS),
probFifth: r1 / (4 * NUM_SIMULATIONS),
probSixth: r1 / (4 * NUM_SIMULATIONS),
probSeventh: r1 / (4 * NUM_SIMULATIONS),
probEighth: r1 / (4 * NUM_SIMULATIONS),
},
source,
};

View file

@ -39,7 +39,6 @@ import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import { eloWinProbability } from "~/services/probability-engine";
import { positiveConfigNumber } from "./config-access";
import { simulateEloSoccerMatch } from "./soccer-helpers";
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { logger } from "~/lib/logger";
@ -441,9 +440,8 @@ export class WorldCupSimulator implements Simulator {
this.numSimulations = numSimulations;
}
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
// 1. Load all participants for this season
const participantRows = await db.query.seasonParticipants.findMany({
@ -671,7 +669,7 @@ export class WorldCupSimulator implements Simulator {
return seeded;
};
for (let sim = 0; sim < numSimulations; sim++) {
for (let sim = 0; sim < this.numSimulations; sim++) {
// ── Knockout rounds ──────────────────────────────────────────
// R32 → R16 → QF → SF → Third Place Game + Final, advancing via the
// 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
const N = numSimulations;
const N = this.numSimulations;
const numQfLosers = 4; // 4 QF losers per sim
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 { OcBlacktopIndyCarStandingsAdapter } from "../f1";
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "../types";
// Pure unit tests for the name-matching logic used by syncStandings.
// The full orchestrator requires DB context; those are covered by integration tests.
@ -48,143 +46,3 @@ describe("findMatchingTeamName (sync name-matching logic)", () => {
expect(findMatchingTeamName(" Boston Celtics ", participants)).toBe("Boston Celtics");
});
});
describe("OcBlacktopIndyCarStandingsAdapter", () => {
afterEach(() => {
vi.restoreAllMocks();
});
// A fallback that records whether it was invoked and returns a sentinel result.
function makeFallback(): StandingsSyncAdapter & { called: boolean } {
const stub = {
called: false,
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
stub.called = true;
return [
{
teamName: "ESPN Fallback Driver",
externalTeamId: "espn-1",
wins: 0,
losses: 0,
winPct: 0,
gamesPlayed: 0,
leagueRank: 1,
currentPoints: 1,
},
];
},
};
return stub;
}
it("maps OC Blacktop driver standings to FetchedStandingsRecord", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify([
{ id: "ocb-palou", position: 1, points: "409.00", firstName: "Alex", lastName: "Palou" },
{ id: "ocb-kirkwood", position: 2, points: "348.00", firstName: "Kyle", lastName: "Kirkwood" },
]),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
const fallback = makeFallback();
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
const records = await adapter.fetchStandings();
expect(fallback.called).toBe(false);
expect(records).toEqual([
expect.objectContaining({
teamName: "Alex Palou",
externalTeamId: "ocb-palou",
leagueRank: 1,
currentPoints: 409,
}),
expect.objectContaining({
teamName: "Kyle Kirkwood",
externalTeamId: "ocb-kirkwood",
leagueRank: 2,
currentPoints: 348,
}),
]);
});
it("falls back to ESPN when the API responds non-OK", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("nope", { status: 500, statusText: "Internal Server Error" })
);
const fallback = makeFallback();
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
const records = await adapter.fetchStandings();
expect(fallback.called).toBe(true);
expect(records[0].teamName).toBe("ESPN Fallback Driver");
});
it("falls back to ESPN when the payload is empty", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } })
);
const fallback = makeFallback();
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
await adapter.fetchStandings();
expect(fallback.called).toBe(true);
});
it("falls back to ESPN when the payload is missing expected fields (schema drift)", async () => {
// Non-empty array, but `points` was renamed upstream — must fail over, not
// silently sync zeros over real standings.
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify([
{ id: "ocb-palou", position: 1, championshipPoints: "409.00", firstName: "Alex", lastName: "Palou" },
]),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
const fallback = makeFallback();
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
const records = await adapter.fetchStandings();
expect(fallback.called).toBe(true);
expect(records[0].teamName).toBe("ESPN Fallback Driver");
});
it("keeps legitimate zero-point drivers (0 points is valid, not a failure)", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify([
{ id: "ocb-1", position: 1, points: "409.00", firstName: "Alex", lastName: "Palou" },
{ id: "ocb-2", position: 33, points: "0.00", firstName: "Rookie", lastName: "Driver" },
]),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
const fallback = makeFallback();
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
const records = await adapter.fetchStandings();
expect(fallback.called).toBe(false);
expect(records[1]).toEqual(
expect.objectContaining({ teamName: "Rookie Driver", leagueRank: 33, currentPoints: 0 })
);
});
it("falls back to ESPN when no API key is configured", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const fallback = makeFallback();
// Empty string is falsy but (unlike undefined) does not trigger the env default.
const adapter = new OcBlacktopIndyCarStandingsAdapter("", fallback);
const records = await adapter.fetchStandings();
expect(fetchSpy).not.toHaveBeenCalled();
expect(fallback.called).toBe(true);
expect(records[0].teamName).toBe("ESPN Fallback Driver");
});
});

View file

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

View file

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

View file

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

View file

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