Compare commits
2 commits
main
...
claude/set
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30085ab3e1 | ||
|
|
1ff4082129 |
80 changed files with 1517 additions and 5989 deletions
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=""
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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’s draft-on → 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 9–16 →
|
||||
// (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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} → {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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (0–1)</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 (0–1)</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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 9–16 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,
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 9th–16th 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);
|
||||
|
|
|
|||
|
|
@ -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 9–16 split), so we must NOT round: show whole numbers plainly
|
||||
* and fractional values to at most 2 decimals with trailing zeros trimmed (1.50→1.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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 1–3 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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 1–10.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// probFifth–Eighth → 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;
|
||||
|
|
|
|||
|
|
@ -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 5–12) ───────────────────────────────────────────────
|
||||
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 1–4 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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 odds→Elo 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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// probFifth–Eighth → 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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}". ` +
|
||||
|
|
|
|||
|
|
@ -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 (9th–16th), 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 {
|
||||
|
|
|
|||
|
|
@ -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 9–16: (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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue