Compare commits
4 commits
1acef25027
...
758166dd46
| Author | SHA1 | Date | |
|---|---|---|---|
| 758166dd46 | |||
|
|
d83f6976bf | ||
|
|
eefe407e7f | ||
|
|
cf817cc9ff |
11 changed files with 1081 additions and 146 deletions
|
|
@ -319,6 +319,22 @@ describe("processMatchResult", () => {
|
||||||
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
|
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips only the probability refresh when asked, still announcing", async () => {
|
||||||
|
// For a caller scoring several matches in a loop: the refresh is season-wide and, for a
|
||||||
|
// bracket-aware sport, a full Monte Carlo run, so it belongs once after the loop rather
|
||||||
|
// than once per match. Standings and the announcement still happen per match.
|
||||||
|
const { db } = makeDb();
|
||||||
|
|
||||||
|
await processMatchResult(
|
||||||
|
{ ...BASE, round: "Quarterfinals", isScoring: true, skipProbabilities: true },
|
||||||
|
db
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updateProbabilitiesAfterResult).not.toHaveBeenCalled();
|
||||||
|
// recalculateAffectedLeagues still ran: it is the only thing that reads seasonSports.
|
||||||
|
expect(db.query.seasonSports.findMany).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not throw even if probability update fails", async () => {
|
it("does not throw even if probability update fails", async () => {
|
||||||
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
new Error("network error")
|
new Error("network error")
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,19 @@ export async function processMatchResult(
|
||||||
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
||||||
matchId?: string;
|
matchId?: string;
|
||||||
skipSideEffects?: boolean;
|
skipSideEffects?: boolean;
|
||||||
|
/**
|
||||||
|
* Skip only the probability refresh, still recalculating standings and announcing.
|
||||||
|
*
|
||||||
|
* For a caller scoring several matches in a loop: the refresh is season-wide and
|
||||||
|
* idempotent, so running it per match repeats the whole thing needlessly — and for a
|
||||||
|
* bracket-aware sport that now means a full Monte Carlo run each time. Set this in the
|
||||||
|
* loop and call updateProbabilitiesAfterResult once when it finishes. Per-match
|
||||||
|
* announcements then project from the previous probabilities until that final call.
|
||||||
|
*
|
||||||
|
* Distinct from skipSideEffects, which also suppresses the standings recalculation and
|
||||||
|
* the announcement.
|
||||||
|
*/
|
||||||
|
skipProbabilities?: boolean;
|
||||||
/**
|
/**
|
||||||
* When true, the loser of this non-scoring round advances to another match
|
* When true, the loser of this non-scoring round advances to another match
|
||||||
* (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be
|
* (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be
|
||||||
|
|
@ -557,7 +570,7 @@ export async function processMatchResult(
|
||||||
providedDb?: ReturnType<typeof database>
|
providedDb?: ReturnType<typeof database>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params;
|
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, skipProbabilities, loserAdvances } = params;
|
||||||
|
|
||||||
if (!isScoring) {
|
if (!isScoring) {
|
||||||
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
||||||
|
|
@ -637,13 +650,15 @@ export async function processMatchResult(
|
||||||
: undefined;
|
: undefined;
|
||||||
// Update probabilities first so the standings recalc reads fresh EVs and
|
// Update probabilities first so the standings recalc reads fresh EVs and
|
||||||
// projected points reflect the new result.
|
// projected points reflect the new result.
|
||||||
try {
|
if (!skipProbabilities) {
|
||||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
try {
|
||||||
} catch (error) {
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||||
logger.error(
|
} catch (error) {
|
||||||
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
logger.error(
|
||||||
error
|
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
||||||
);
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import * as participantEVModel from "~/models/participant-expected-value";
|
||||||
// Mock the dependencies
|
// Mock the dependencies
|
||||||
vi.mock("~/models/participant-result");
|
vi.mock("~/models/participant-result");
|
||||||
vi.mock("~/models/participant-expected-value");
|
vi.mock("~/models/participant-expected-value");
|
||||||
|
vi.mock("~/models/simulator");
|
||||||
|
vi.mock("~/models/sports-season");
|
||||||
|
vi.mock("~/services/simulations/runner");
|
||||||
vi.mock("~/database/context", () => ({
|
vi.mock("~/database/context", () => ({
|
||||||
database: () => ({
|
database: () => ({
|
||||||
query: {
|
query: {
|
||||||
|
|
@ -18,6 +21,9 @@ vi.mock("~/database/context", () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// vi.mock above is hoisted over the imports, so this is already the mocked function.
|
||||||
|
const upsertEV = vi.mocked(participantEVModel.upsertParticipantEV);
|
||||||
|
|
||||||
describe("probability-updater", () => {
|
describe("probability-updater", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -320,3 +326,208 @@ describe("probability-updater", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Bracket-aware simulator seasons ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The ICM branch re-derives a whole distribution from P(1st) alone and knows nothing about
|
||||||
|
// who is playing whom or what has already been decided, so it cannot see the placement floors
|
||||||
|
// an afl_10 seeding or a non-scoring-round win has already banked — it will happily value a
|
||||||
|
// team below points the league has paid out. Whenever the season has a simulator that reads
|
||||||
|
// its bracket, that simulator is the better answer and is re-run instead. Only a season whose
|
||||||
|
// simulator is bracket-blind (or has none) still goes through ICM.
|
||||||
|
|
||||||
|
const evRow = (participantId: string, source: string) => ({
|
||||||
|
id: `ev-${participantId}`,
|
||||||
|
participantId,
|
||||||
|
sportsSeasonId: "season-1",
|
||||||
|
probFirst: "0.1000",
|
||||||
|
probSecond: "0.1000",
|
||||||
|
probThird: "0.1000",
|
||||||
|
probFourth: "0.1000",
|
||||||
|
probFifth: "0.1000",
|
||||||
|
probSixth: "0.1000",
|
||||||
|
probSeventh: "0.1000",
|
||||||
|
probEighth: "0.1000",
|
||||||
|
expectedValue: "34.00",
|
||||||
|
source,
|
||||||
|
sourceOdds: null,
|
||||||
|
calculatedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const finishedResult = (participantId: string, finalPosition: number) => ({
|
||||||
|
id: `result-${participantId}`,
|
||||||
|
participantId,
|
||||||
|
sportsSeasonId: "season-1",
|
||||||
|
finalPosition,
|
||||||
|
isPartialScore: false,
|
||||||
|
qualifyingPoints: null,
|
||||||
|
notes: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
participant: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => {
|
||||||
|
/** Wire up a season: which teams are done, what wrote the EVs, which simulator it has. */
|
||||||
|
async function setup(opts: {
|
||||||
|
evSource: string;
|
||||||
|
simulatorType: string | null;
|
||||||
|
results?: ReturnType<typeof finishedResult>[];
|
||||||
|
seasonStatus?: string;
|
||||||
|
}) {
|
||||||
|
const simulatorModel = await import("~/models/simulator");
|
||||||
|
const sportsSeasonModel = await import("~/models/sports-season");
|
||||||
|
const runner = await import("~/services/simulations/runner");
|
||||||
|
|
||||||
|
vi.mocked(sportsSeasonModel.findSportsSeasonById).mockResolvedValue({
|
||||||
|
id: "season-1",
|
||||||
|
status: opts.seasonStatus ?? "active",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue(
|
||||||
|
opts.results ?? []
|
||||||
|
);
|
||||||
|
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([
|
||||||
|
evRow("alive-1", opts.evSource),
|
||||||
|
evRow("alive-2", opts.evSource),
|
||||||
|
] as never);
|
||||||
|
vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
|
||||||
|
vi.mocked(simulatorModel.getSportsSeasonSimulatorConfig).mockResolvedValue(
|
||||||
|
opts.simulatorType ? ({ simulatorType: opts.simulatorType, config: {} } as never) : null
|
||||||
|
);
|
||||||
|
const runSim = vi.mocked(runner.runSportsSeasonSimulation);
|
||||||
|
runSim.mockResolvedValue({} as never);
|
||||||
|
|
||||||
|
return { runner, runSim };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ICM branch is the only thing that writes unfinished rows with this source. */
|
||||||
|
const icmWrites = () =>
|
||||||
|
upsertEV.mock.calls.filter(([arg]) => arg.source === "futures_odds");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-runs a bracket-aware simulator instead of recalculating ICM", async () => {
|
||||||
|
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
|
||||||
|
|
||||||
|
const result = await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
|
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
|
||||||
|
expect(icmWrites()).toHaveLength(0);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks the run for probabilities only, leaving standings and snapshots to the caller", async () => {
|
||||||
|
// recalculateAffectedLeagues detects change by diffing teamStandings across its own
|
||||||
|
// recalculation, and that diff gates the Discord standings post. A recalculation in here
|
||||||
|
// runs before it takes its "before" snapshot, so the diff comes back empty and the post is
|
||||||
|
// silently dropped — and previousRank gets rolled forward twice, erasing rank movement.
|
||||||
|
const { runSim } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
|
||||||
|
|
||||||
|
await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
|
expect(runSim).toHaveBeenCalledWith("season-1", {
|
||||||
|
skipStandingsRecalc: true,
|
||||||
|
skipSnapshots: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through to ICM on a completed season rather than failing every time", async () => {
|
||||||
|
// finalizeQualifyingPoints marks the season completed immediately before calling here, and
|
||||||
|
// runSportsSeasonSimulation rejects a completed season outright. Treating that as a failure
|
||||||
|
// would strand anyone still unfinished on stale probabilities forever.
|
||||||
|
const { runner } = await setup({
|
||||||
|
evSource: "elo_simulation",
|
||||||
|
simulatorType: "cs2_major_qualifying_points",
|
||||||
|
seasonStatus: "completed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
|
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
||||||
|
expect(icmWrites().length).toBeGreaterThan(0);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still pins finished participants before re-running the simulator", async () => {
|
||||||
|
const { runner } = await setup({
|
||||||
|
evSource: "elo_simulation",
|
||||||
|
simulatorType: "afl_bracket",
|
||||||
|
results: [finishedResult("done-1", 2)],
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
|
const pinned = upsertEV.mock.calls.find(([arg]) => arg.participantId === "done-1");
|
||||||
|
expect(pinned?.[0].probabilities.probSecond).toBe(1.0);
|
||||||
|
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a finalized pin after the re-run, so the pin wins over the simulation", async () => {
|
||||||
|
// runSportsSeasonSimulation rewrites every participant in the season, finalized ones
|
||||||
|
// included. A finalized placement is a fact, not a projection, so it has to land last.
|
||||||
|
const { runSim } = await setup({
|
||||||
|
evSource: "elo_simulation",
|
||||||
|
simulatorType: "afl_bracket",
|
||||||
|
results: [finishedResult("done-1", 0)],
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
|
const pinIndex = upsertEV.mock.calls.findIndex(([arg]) => arg.participantId === "done-1");
|
||||||
|
expect(pinIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(upsertEV.mock.invocationCallOrder[pinIndex]).toBeGreaterThan(
|
||||||
|
runSim.mock.invocationCallOrder[0]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves probabilities alone, and does not fall back to ICM, when the re-run fails", async () => {
|
||||||
|
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
|
||||||
|
vi.mocked(runner.runSportsSeasonSimulation).mockRejectedValue(
|
||||||
|
new Error("A simulation is already running for this sports season.")
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
|
expect(icmWrites()).toHaveLength(0);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0]).toMatch(/Failed to re-run simulator/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-runs the simulator whatever wrote the EVs originally", async () => {
|
||||||
|
// The alternative is not leaving them alone — ICM would overwrite them either way — so
|
||||||
|
// futures-odds EVs are no reason to prefer the bracket-blind overwrite.
|
||||||
|
const { runner } = await setup({ evSource: "futures_odds", simulatorType: "afl_bracket" });
|
||||||
|
|
||||||
|
await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
|
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
|
||||||
|
expect(icmWrites()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the ICM path for a bracket-blind simulator", async () => {
|
||||||
|
// ncaa_football_bracket declares a "bracket" setup section but never reads playoff_matches,
|
||||||
|
// so re-running it would re-draw the field and hand equity back to eliminated teams.
|
||||||
|
const { runner } = await setup({
|
||||||
|
evSource: "elo_simulation",
|
||||||
|
simulatorType: "ncaa_football_bracket",
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
|
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
||||||
|
expect(icmWrites().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the ICM path when the season has no simulator configured", async () => {
|
||||||
|
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: null });
|
||||||
|
|
||||||
|
await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
|
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
||||||
|
expect(icmWrites().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
processQualifyingBracketEvent,
|
processQualifyingBracketEvent,
|
||||||
recalculateAffectedLeagues,
|
recalculateAffectedLeagues,
|
||||||
} from "~/models/scoring-calculator";
|
} from "~/models/scoring-calculator";
|
||||||
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
||||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||||
import {
|
import {
|
||||||
|
|
@ -283,6 +284,11 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventName: event.name ?? undefined,
|
eventName: event.name ?? undefined,
|
||||||
matchId: playoffMatch.id,
|
matchId: playoffMatch.id,
|
||||||
|
// The probability refresh is season-wide and idempotent, and for a bracket-aware
|
||||||
|
// sport it is a full Monte Carlo run — doing it per match would repeat that for
|
||||||
|
// every match in the sync. It runs once after the loop instead. Standings and the
|
||||||
|
// per-match Discord post still happen here as before.
|
||||||
|
skipProbabilities: true,
|
||||||
loserAdvances: event.bracketTemplateId
|
loserAdvances: event.bracketTemplateId
|
||||||
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
|
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
|
||||||
: false,
|
: false,
|
||||||
|
|
@ -300,6 +306,15 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The refresh skipped inside the loop, run once for the whole sync.
|
||||||
|
if (playoffUpdated > 0) {
|
||||||
|
try {
|
||||||
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[match-sync] Error updating probabilities after bracket sync:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
|
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||||
|
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
|
||||||
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||||||
|
import { getManifestSimulatorProfile } from "~/services/simulations/manifest";
|
||||||
|
import { logger } from "~/lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of probability update operation
|
* Result of probability update operation
|
||||||
|
|
@ -97,6 +101,40 @@ function createFinishedProbabilities(finalPosition: number): number[] {
|
||||||
return probs;
|
return probs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this season's still-alive participants should be refreshed by re-running its
|
||||||
|
* simulator instead of by the ICM recalculation below.
|
||||||
|
*
|
||||||
|
* If the season has a simulator that reads its bracket, that simulator is simply a better
|
||||||
|
* answer than ICM to "what happens from here": it seeds from the real draw and replays every
|
||||||
|
* completed match, where ICM re-derives a whole distribution from P(1st) alone and knows
|
||||||
|
* nothing about who is playing whom or what has already been decided. That blindness is what
|
||||||
|
* makes ICM report a placement floor the league has already paid out as worth less than its
|
||||||
|
* awarded points.
|
||||||
|
*
|
||||||
|
* Where the EVs originally came from is not consulted, because the alternative here is not
|
||||||
|
* leaving them alone — the ICM branch overwrites them either way. Given the choice between
|
||||||
|
* two overwrites, the bracket-aware one wins.
|
||||||
|
*
|
||||||
|
* The gate is `bracketAware`, not merely "has a simulator": re-running a bracket-blind
|
||||||
|
* simulator would re-draw the field and hand equity back to teams already knocked out.
|
||||||
|
*/
|
||||||
|
async function shouldRerunSimulator(sportsSeasonId: string): Promise<boolean> {
|
||||||
|
// A completed season cannot be simulated — runSportsSeasonSimulation rejects it outright —
|
||||||
|
// and finalizeQualifyingPoints marks the season completed immediately before calling here,
|
||||||
|
// so taking this branch there would fail every single time and leave anyone still in the
|
||||||
|
// unfinished set on permanently stale probabilities. It is not a failure, it is not this
|
||||||
|
// branch's case: the season is over, every placement is final, and the floor this branch
|
||||||
|
// exists to protect can no longer be contradicted. Fall through to ICM as before.
|
||||||
|
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||||
|
if (sportsSeason?.status === "completed") return false;
|
||||||
|
|
||||||
|
const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||||||
|
if (!simulatorConfig) return false;
|
||||||
|
|
||||||
|
return getManifestSimulatorProfile(simulatorConfig.simulatorType)?.bracketAware === true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update probabilities for a sports season after results come in
|
* Update probabilities for a sports season after results come in
|
||||||
*
|
*
|
||||||
|
|
@ -104,7 +142,8 @@ function createFinishedProbabilities(finalPosition: number): number[] {
|
||||||
* 1. Get all participant results (finished participants)
|
* 1. Get all participant results (finished participants)
|
||||||
* 2. Get all existing participant EVs
|
* 2. Get all existing participant EVs
|
||||||
* 3. For finished participants: set 100% at their placement
|
* 3. For finished participants: set 100% at their placement
|
||||||
* 4. For unfinished participants: recalculate using ICM with remaining participants
|
* 4. For unfinished participants: re-run the season's bracket-aware simulator if it has one,
|
||||||
|
* otherwise recalculate using ICM with remaining participants
|
||||||
*
|
*
|
||||||
* @param sportsSeasonId Sports season to update
|
* @param sportsSeasonId Sports season to update
|
||||||
* @param recalculateUnfinished Whether to recalculate unfinished participants (default true)
|
* @param recalculateUnfinished Whether to recalculate unfinished participants (default true)
|
||||||
|
|
@ -138,39 +177,49 @@ export async function updateProbabilitiesAfterResult(
|
||||||
.map(r => [r.participantId, r.finalPosition ?? 0])
|
.map(r => [r.participantId, r.finalPosition ?? 0])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update finished participants. The shared default table is used because we only
|
|
||||||
// care about setting probabilities here, not the EV — each league re-derives its own
|
|
||||||
// EV from the stored probabilities in calculateTeamProjectedScore.
|
|
||||||
|
|
||||||
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
|
|
||||||
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
|
|
||||||
// Running these in parallel would race on that shared state.
|
|
||||||
for (const [participantId, finalPosition] of finishedMap.entries()) {
|
|
||||||
try {
|
|
||||||
const probs = createFinishedProbabilities(finalPosition);
|
|
||||||
const probabilities = arrayToProbabilityDistribution(probs);
|
|
||||||
|
|
||||||
await upsertParticipantEV({
|
|
||||||
participantId,
|
|
||||||
sportsSeasonId,
|
|
||||||
probabilities,
|
|
||||||
scoringRules: DEFAULT_SCORING_RULES,
|
|
||||||
source: 'manual', // Result is from actual outcome
|
|
||||||
});
|
|
||||||
|
|
||||||
updated++;
|
|
||||||
} catch (error) {
|
|
||||||
errors.push(`Failed to update participant ${participantId}: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recalculate unfinished participants if requested
|
// Recalculate unfinished participants if requested
|
||||||
if (recalculateUnfinished) {
|
if (recalculateUnfinished) {
|
||||||
const unfinishedEVs = existingEVs.filter(
|
const unfinishedEVs = existingEVs.filter(
|
||||||
ev => !finishedMap.has(ev.participantId)
|
ev => !finishedMap.has(ev.participantId)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (unfinishedEVs.length > 0) {
|
if (unfinishedEVs.length > 0 && (await shouldRerunSimulator(sportsSeasonId))) {
|
||||||
|
// The simulator reads the bracket, so it already knows this result: it seeds from the
|
||||||
|
// real draw and replays every completed match. Re-running it keeps each participant's
|
||||||
|
// distribution consistent with the games actually played — including the placement
|
||||||
|
// floors a bracket entry or a non-scoring-round win has already banked, which the ICM
|
||||||
|
// branch below cannot see and would value below points the league has paid out.
|
||||||
|
//
|
||||||
|
// Imported lazily: probability-updater → runner → scoring-calculator →
|
||||||
|
// probability-updater is a module cycle, and a static import leaves the binding
|
||||||
|
// undefined at module-init time.
|
||||||
|
try {
|
||||||
|
const { runSportsSeasonSimulation } = await import("~/services/simulations/runner");
|
||||||
|
// Probabilities only. Our callers recalculate standings themselves right after this,
|
||||||
|
// and recalculateAffectedLeagues detects change by diffing teamStandings across its
|
||||||
|
// own recalculation — a recalculation slipped in here empties that diff and silently
|
||||||
|
// suppresses the Discord standings post, and rolls previousRank forward a second time
|
||||||
|
// so rank movement disappears. The daily EV snapshot is not ours to write either: it
|
||||||
|
// is keyed by date, so writing it per result overwrites the day with intra-day values.
|
||||||
|
await runSportsSeasonSimulation(sportsSeasonId, {
|
||||||
|
skipStandingsRecalc: true,
|
||||||
|
skipSnapshots: true,
|
||||||
|
});
|
||||||
|
updated += unfinishedEVs.length;
|
||||||
|
} catch (error) {
|
||||||
|
// A run already in flight, failed readiness, or a bracket the simulator refuses to
|
||||||
|
// read (afl_10 seeded into only some of its slots). Leave the existing probabilities
|
||||||
|
// alone rather than falling back to ICM — for these seasons ICM is precisely the
|
||||||
|
// thing being replaced, and reintroducing it here would reintroduce sub-floor EVs.
|
||||||
|
// Completed seasons never reach this: shouldRerunSimulator excludes them.
|
||||||
|
logger.error(
|
||||||
|
`[ProbabilityUpdater] Failed to re-run simulator for sports season ${sportsSeasonId}; ` +
|
||||||
|
`leaving existing probabilities in place:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
errors.push(`Failed to re-run simulator for sports season ${sportsSeasonId}: ${error}`);
|
||||||
|
}
|
||||||
|
} else if (unfinishedEVs.length > 0) {
|
||||||
// Get their current championship probabilities (use existing P(1st) as proxy)
|
// Get their current championship probabilities (use existing P(1st) as proxy)
|
||||||
const unfinishedOdds = unfinishedEVs.map(ev => {
|
const unfinishedOdds = unfinishedEVs.map(ev => {
|
||||||
const pFirst = parseFloat(ev.probFirst);
|
const pFirst = parseFloat(ev.probFirst);
|
||||||
|
|
@ -220,6 +269,38 @@ export async function updateProbabilitiesAfterResult(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update finished participants. The shared default table is used because we only
|
||||||
|
// care about setting probabilities here, not the EV — each league re-derives its own
|
||||||
|
// EV from the stored probabilities in calculateTeamProjectedScore.
|
||||||
|
//
|
||||||
|
// This runs *after* the recalculation above, not before, because re-running a simulator
|
||||||
|
// rewrites every participant in the season — the finalized ones included. A finalized
|
||||||
|
// placement is a fact, not a projection, so it is written last and wins: if a simulator
|
||||||
|
// ever puts a knocked-out team back in contention (a bracket-aware one whose bracket has
|
||||||
|
// since been cleared and not re-seeded, say), the pin still zeroes them.
|
||||||
|
|
||||||
|
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
|
||||||
|
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
|
||||||
|
// Running these in parallel would race on that shared state.
|
||||||
|
for (const [participantId, finalPosition] of finishedMap.entries()) {
|
||||||
|
try {
|
||||||
|
const probs = createFinishedProbabilities(finalPosition);
|
||||||
|
const probabilities = arrayToProbabilityDistribution(probs);
|
||||||
|
|
||||||
|
await upsertParticipantEV({
|
||||||
|
participantId,
|
||||||
|
sportsSeasonId,
|
||||||
|
probabilities,
|
||||||
|
scoringRules: DEFAULT_SCORING_RULES,
|
||||||
|
source: 'manual', // Result is from actual outcome
|
||||||
|
});
|
||||||
|
|
||||||
|
updated++;
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`Failed to update participant ${participantId}: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
finishedParticipants: finishedMap.size,
|
finishedParticipants: finishedMap.size,
|
||||||
unfishedParticipants: existingEVs.length - finishedMap.size,
|
unfishedParticipants: existingEVs.length - finishedMap.size,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
import { getTeamData, eloWinProbability, AFLSimulator } from "../afl-simulator";
|
import {
|
||||||
|
getTeamData,
|
||||||
|
eloWinProbability,
|
||||||
|
AFLSimulator,
|
||||||
|
readAflBracketSeeds,
|
||||||
|
type BracketMatch,
|
||||||
|
} from "../afl-simulator";
|
||||||
|
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||||
|
import { calculateEV, type ProbabilityDistribution } from "~/services/ev-calculator";
|
||||||
|
|
||||||
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -125,8 +133,82 @@ const PARTICIPANT_ROWS = AFL_TEAMS.map((name, i) => ({
|
||||||
|
|
||||||
const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id);
|
const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the playoff_matches rows generateAFL10Bracket writes, seeded with `seedIds` in
|
||||||
|
* ladder order (index 0 = minor premier). `completed` overrides individual matches with a
|
||||||
|
* recorded result.
|
||||||
|
*/
|
||||||
|
function aflBracketMatches(
|
||||||
|
seedIds: string[],
|
||||||
|
completed: Array<{ round: string; matchNumber: number; winnerId: string; loserId: string }> = []
|
||||||
|
): BracketMatch[] {
|
||||||
|
const seed = (n: number) => seedIds[n - 1] ?? null;
|
||||||
|
const rows: BracketMatch[] = [
|
||||||
|
{ round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
|
||||||
|
{ round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 1, participant1Id: seed(1), participant2Id: seed(4) },
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 2, participant1Id: seed(2), participant2Id: seed(3) },
|
||||||
|
// participant2 is TBD until a Wildcard winner advances into it.
|
||||||
|
{ round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
|
||||||
|
{ round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
|
||||||
|
{ round: "Semi-Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||||||
|
{ round: "Semi-Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
|
||||||
|
{ round: "Preliminary Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||||||
|
{ round: "Preliminary Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
|
||||||
|
{ round: "Grand Final", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||||||
|
].map((m) => ({ ...m, winnerId: null, loserId: null, isComplete: false }));
|
||||||
|
|
||||||
|
for (const done of completed) {
|
||||||
|
const row = rows.find((r) => r.round === done.round && r.matchNumber === done.matchNumber);
|
||||||
|
if (!row) throw new Error(`no such match: ${done.round} #${done.matchNumber}`);
|
||||||
|
row.isComplete = true;
|
||||||
|
row.winnerId = done.winnerId;
|
||||||
|
row.loserId = done.loserId;
|
||||||
|
// A Wildcard winner is advanced into the Elimination Final it feeds.
|
||||||
|
if (done.round === "Wildcard Round") {
|
||||||
|
const ef = rows.find(
|
||||||
|
(r) => r.round === "Elimination Finals" && r.matchNumber === (done.matchNumber === 1 ? 2 : 1)
|
||||||
|
);
|
||||||
|
if (ef) ef.participant2Id = done.winnerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one bracket row for a round/match, failing loudly if the fixture changes shape. */
|
||||||
|
function matchIn(matches: BracketMatch[], round: string, matchNumber: number): BracketMatch {
|
||||||
|
const found = matches.find((m) => m.round === round && m.matchNumber === matchNumber);
|
||||||
|
if (!found) throw new Error(`no such match: ${round} #${matchNumber}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up one participant's result, failing loudly rather than silently passing on undefined. */
|
||||||
|
function resultFor<T extends { participantId: string }>(results: T[], participantId: string): T {
|
||||||
|
const found = results.find((r) => r.participantId === participantId);
|
||||||
|
if (!found) throw new Error(`no simulation result for ${participantId}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EV on the reference scale the runner persists with. */
|
||||||
|
function evOf(result: { probabilities: ProbabilityDistribution }): number {
|
||||||
|
return calculateEV(result.probabilities, DEFAULT_SCORING_RULES);
|
||||||
|
}
|
||||||
|
|
||||||
describe("AFLSimulator.simulate()", () => {
|
describe("AFLSimulator.simulate()", () => {
|
||||||
let mockDb: { select: MockInstance };
|
let mockDb: {
|
||||||
|
select: MockInstance;
|
||||||
|
query: {
|
||||||
|
scoringEvents: { findMany: MockInstance };
|
||||||
|
playoffMatches: { findMany: MockInstance };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Put a seeded afl_10 bracket in front of the simulator. */
|
||||||
|
function seedBracket(matches: BracketMatch[]) {
|
||||||
|
mockDb.query.scoringEvents.findMany.mockResolvedValue([{ id: "event-1" }]);
|
||||||
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { database } = await import("~/database/context");
|
const { database } = await import("~/database/context");
|
||||||
|
|
@ -136,6 +218,11 @@ describe("AFLSimulator.simulate()", () => {
|
||||||
|
|
||||||
let selectCallCount = 0;
|
let selectCallCount = 0;
|
||||||
mockDb = {
|
mockDb = {
|
||||||
|
// Default: no bracket generated yet, so the ladder-projection path runs.
|
||||||
|
query: {
|
||||||
|
scoringEvents: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
},
|
||||||
select: vi.fn().mockImplementation(() => {
|
select: vi.fn().mockImplementation(() => {
|
||||||
selectCallCount++;
|
selectCallCount++;
|
||||||
if (selectCallCount === 1) {
|
if (selectCallCount === 1) {
|
||||||
|
|
@ -355,4 +442,184 @@ describe("AFLSimulator.simulate()", () => {
|
||||||
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
|
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
|
||||||
expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst);
|
expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Bracket-aware mode ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// afl_10 banks points on seeding alone (entryFloor 5 for seeds 1-4, 7 for seeds 5-6) and
|
||||||
|
// on winning a non-scoring round (nonScoringWinnerFloor 7 for the Wildcard Round, 3 for a
|
||||||
|
// Qualifying Final). Those floors are paid out as real fantasy points, so a simulator that
|
||||||
|
// re-draws the ladder every iteration — putting a seeded team back in the Wildcard Round or
|
||||||
|
// out of the finals, where it scores 0 — reports an EV below points already awarded. Each
|
||||||
|
// EV assertion below is that floor.
|
||||||
|
|
||||||
|
describe("bracket-aware mode", () => {
|
||||||
|
/**
|
||||||
|
* Seeds 1-10 in ladder order, drawn from the ten *weakest* clubs by Elo. Seeding the
|
||||||
|
* strongest ten would let the ladder-projection path produce much the same field by
|
||||||
|
* accident, so the floor assertions below would pass even with the bracket ignored.
|
||||||
|
*/
|
||||||
|
const SEEDS = PARTICIPANT_IDS.slice(8);
|
||||||
|
|
||||||
|
it("never values a seed below the entry floor its seeding already banked", async () => {
|
||||||
|
seedBracket(aflBracketMatches(SEEDS));
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
// Seeds 1-4 enter a Qualifying Final: lose it, lose the Semi-Final, still 5th-6th (25).
|
||||||
|
for (const seed of [1, 2, 3, 4]) {
|
||||||
|
expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(25);
|
||||||
|
}
|
||||||
|
// Seeds 5-6 enter an Elimination Final: lose it and they are 7th-8th (15).
|
||||||
|
for (const seed of [5, 6]) {
|
||||||
|
expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(15);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a Qualifying Final entrant out of the 7th-8th tier entirely", async () => {
|
||||||
|
seedBracket(aflBracketMatches(SEEDS));
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
// A seed 1-4 loses the QF into a Semi-Final, so 5th-6th is its worst finish. The
|
||||||
|
// 7th-8th tier is reachable only by losing an Elimination Final.
|
||||||
|
for (const seed of [1, 2, 3, 4]) {
|
||||||
|
expect(resultFor(results, SEEDS[seed - 1]).probabilities.probSeventh, `seed ${seed}`).toBe(0);
|
||||||
|
}
|
||||||
|
// Seeds 5-10 all reach an Elimination Final only by playing one, so they can.
|
||||||
|
expect(resultFor(results, SEEDS[4]).probabilities.probSeventh).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the bracket's draw rather than a re-projected ladder", async () => {
|
||||||
|
// Deliberately inverted: the weakest club is the minor premier and the strongest
|
||||||
|
// scrapes in 10th. On the ladder-projection path Elo decides the seeding, so this only
|
||||||
|
// holds if the bracket's own slots are being read.
|
||||||
|
const inverted = [
|
||||||
|
"team-18", "team-17", "team-16", "team-15", "team-14",
|
||||||
|
"team-13", "team-12", "team-11", "team-10", "team-1",
|
||||||
|
];
|
||||||
|
seedBracket(aflBracketMatches(inverted));
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
// West Coast (weakest Elo) is seeded 1, so it holds the double chance and can never
|
||||||
|
// finish 7th-8th, and its EV clears the seed 1-4 floor.
|
||||||
|
expect(resultFor(results, "team-18").probabilities.probSeventh).toBe(0);
|
||||||
|
expect(evOf(resultFor(results, "team-18"))).toBeGreaterThanOrEqual(25);
|
||||||
|
|
||||||
|
// Western Bulldogs (strongest Elo) is seeded 10, so it starts in the Wildcard Round
|
||||||
|
// with nothing banked and can be knocked out for 0.
|
||||||
|
expect(resultFor(results, "team-1").probabilities.probSeventh).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeroes every participant outside the bracket", async () => {
|
||||||
|
seedBracket(aflBracketMatches(SEEDS));
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
for (const r of results.filter((x) => !SEEDS.includes(x.participantId))) {
|
||||||
|
expect(evOf(r), r.participantId).toBe(0);
|
||||||
|
}
|
||||||
|
expect(results).toHaveLength(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still normalizes every column to 1.0 and the field to 340 total EV", async () => {
|
||||||
|
seedBracket(aflBracketMatches(SEEDS));
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
const keys = [
|
||||||
|
"probFirst", "probSecond", "probThird", "probFourth",
|
||||||
|
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||||
|
] as const;
|
||||||
|
for (const key of keys) {
|
||||||
|
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||||
|
expect(colSum, `${key} column sum`).toBeCloseTo(1.0, 6);
|
||||||
|
}
|
||||||
|
expect(results.reduce((s, r) => s + evOf(r), 0)).toBeCloseTo(340, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replays a completed Wildcard Round instead of re-simulating it", async () => {
|
||||||
|
// Seed 10 beat seed 7, which banks seed 10 a 7th-place floor (15 points).
|
||||||
|
seedBracket(
|
||||||
|
aflBracketMatches(SEEDS, [
|
||||||
|
{ round: "Wildcard Round", matchNumber: 1, winnerId: SEEDS[9], loserId: SEEDS[6] },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
expect(evOf(resultFor(results, SEEDS[9]))).toBeGreaterThanOrEqual(15);
|
||||||
|
// The loser is out with nothing, in every iteration.
|
||||||
|
expect(evOf(resultFor(results, SEEDS[6]))).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replays a completed Qualifying Final, banking the winner's 3rd-4th floor", async () => {
|
||||||
|
// Seed 1 beat seed 4: the winner byes into a Preliminary Final (floor 3rd, 45 points)
|
||||||
|
// and the loser drops into a Semi-Final (floor 5th, 25 points).
|
||||||
|
seedBracket(
|
||||||
|
aflBracketMatches(SEEDS, [
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 1, winnerId: SEEDS[0], loserId: SEEDS[3] },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
const winner = resultFor(results, SEEDS[0]);
|
||||||
|
expect(evOf(winner)).toBeGreaterThanOrEqual(45);
|
||||||
|
// Already through to a Preliminary Final, so the 5th-6th tier is behind it.
|
||||||
|
expect(winner.probabilities.probFifth).toBe(0);
|
||||||
|
|
||||||
|
expect(evOf(resultFor(results, SEEDS[3]))).toBeGreaterThanOrEqual(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the ladder projection when the bracket carries no seeds", async () => {
|
||||||
|
seedBracket(aflBracketMatches([]));
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
// Every club is back in contention, so nobody is structurally zeroed.
|
||||||
|
expect(results.filter((r) => evOf(r) > 0).length).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── readAflBracketSeeds ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("readAflBracketSeeds", () => {
|
||||||
|
const teamsById = new Map(
|
||||||
|
PARTICIPANT_IDS.map((id) => [id, { id, name: id, elo: 1500, currentWins: 0, remainingGames: 0, winProb: 0.5 }])
|
||||||
|
);
|
||||||
|
const SEEDS = PARTICIPANT_IDS.slice(0, 10);
|
||||||
|
|
||||||
|
it("returns null when there is no bracket at all", () => {
|
||||||
|
expect(readAflBracketSeeds([], teamsById as never)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a generated but unseeded bracket", () => {
|
||||||
|
expect(readAflBracketSeeds(aflBracketMatches([]), teamsById as never)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the 10 seeds in ladder order", () => {
|
||||||
|
const bracket = readAflBracketSeeds(aflBracketMatches(SEEDS), teamsById as never);
|
||||||
|
expect(bracket?.seeds.map((t) => t.id)).toEqual(SEEDS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat the TBD Elimination Final slots as missing seeds", () => {
|
||||||
|
const matches = aflBracketMatches(SEEDS);
|
||||||
|
for (const m of matches.filter((r) => r.round === "Elimination Finals")) {
|
||||||
|
expect(m.participant2Id).toBeNull();
|
||||||
|
}
|
||||||
|
expect(readAflBracketSeeds(matches, teamsById as never)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a partially seeded bracket rather than discarding the draw", () => {
|
||||||
|
const matches = aflBracketMatches(SEEDS);
|
||||||
|
// ON DELETE SET NULL empties a slot when a participant is removed and re-added.
|
||||||
|
matchIn(matches, "Qualifying Finals", 1).participant2Id = null;
|
||||||
|
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/partially seeded.*seed\(s\) 4/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when one participant holds two slots", () => {
|
||||||
|
const matches = aflBracketMatches(SEEDS);
|
||||||
|
matchIn(matches, "Wildcard Round", 1).participant2Id = SEEDS[0];
|
||||||
|
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/more than one slot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the bracket references a participant outside the season", () => {
|
||||||
|
const matches = aflBracketMatches(SEEDS);
|
||||||
|
matchIn(matches, "Wildcard Round", 1).participant2Id = "ghost";
|
||||||
|
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/not in this sports season/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,33 @@ describe("simulator manifest", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// updateProbabilitiesAfterResult sends a season down the re-run path or the ICM path purely
|
||||||
|
// on this flag, and getting it wrong is silent in both directions: set it on a simulator
|
||||||
|
// that re-plays decided games and eliminated teams come back to life; leave it off a
|
||||||
|
// bracket-aware one and ICM keeps reporting placement floors as worth less than the points
|
||||||
|
// already awarded. Pinning the set makes a new simulator an explicit decision rather than a
|
||||||
|
// default. To add one, confirm it reads playoff_matches AND honors isComplete/winnerId.
|
||||||
|
it("pins which simulators are bracket-aware", () => {
|
||||||
|
const bracketAware = SIMULATOR_TYPES.filter((t) => SIMULATOR_MANIFEST[t].bracketAware);
|
||||||
|
expect(bracketAware.toSorted()).toEqual(
|
||||||
|
[
|
||||||
|
"afl_bracket",
|
||||||
|
"college_hockey_bracket",
|
||||||
|
"cs2_major_qualifying_points",
|
||||||
|
"darts_bracket",
|
||||||
|
"llws_bracket",
|
||||||
|
"nba_bracket",
|
||||||
|
"ncaam_bracket",
|
||||||
|
"ncaaw_bracket",
|
||||||
|
"nhl_bracket",
|
||||||
|
"nll_bracket",
|
||||||
|
"snooker_bracket",
|
||||||
|
"ucl_bracket",
|
||||||
|
"world_cup",
|
||||||
|
].toSorted()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("only derives inputs from declared optional inputs", () => {
|
it("only derives inputs from declared optional inputs", () => {
|
||||||
for (const simulatorType of SIMULATOR_TYPES) {
|
for (const simulatorType of SIMULATOR_TYPES) {
|
||||||
const profile = SIMULATOR_MANIFEST[simulatorType];
|
const profile = SIMULATOR_MANIFEST[simulatorType];
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ import {
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||||
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
||||||
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
||||||
|
import { recalculateStandings } from "~/models/scoring-calculator";
|
||||||
|
import { database } from "~/database/context";
|
||||||
import { getSimulator } from "~/services/simulations/registry";
|
import { getSimulator } from "~/services/simulations/registry";
|
||||||
import { normalizeSimulationResultColumns } from "~/services/simulations/simulation-probabilities";
|
import { normalizeSimulationResultColumns } from "~/services/simulations/simulation-probabilities";
|
||||||
|
|
||||||
|
|
@ -126,6 +128,42 @@ describe("runSportsSeasonSimulation", () => {
|
||||||
expect(vi.mocked(updateSportsSeason).mock.calls[1]).toEqual(["season-1", { simulationStatus: "idle" }]);
|
expect(vi.mocked(updateSportsSeason).mock.calls[1]).toEqual(["season-1", { simulationStatus: "idle" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The default mock has no linked leagues, so nothing to recalculate. Give it one. */
|
||||||
|
function withLinkedLeague() {
|
||||||
|
vi.mocked(database).mockReturnValue({
|
||||||
|
query: {
|
||||||
|
seasonSports: { findMany: vi.fn().mockResolvedValue([{ seasonId: "fantasy-1" }]) },
|
||||||
|
seasons: { findFirst: vi.fn() },
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("recalculates standings and writes the daily snapshot by default", async () => {
|
||||||
|
withLinkedLeague();
|
||||||
|
|
||||||
|
await runSportsSeasonSimulation("season-1");
|
||||||
|
|
||||||
|
expect(recalculateStandings).toHaveBeenCalledWith("fantasy-1");
|
||||||
|
expect(batchUpsertParticipantEvSnapshots).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips standings and snapshots when the caller owns them", async () => {
|
||||||
|
withLinkedLeague();
|
||||||
|
|
||||||
|
// updateProbabilitiesAfterResult runs inside the result path, where the caller
|
||||||
|
// recalculates standings straight afterwards. A recalculation here lands before
|
||||||
|
// recalculateAffectedLeagues takes its "before" snapshot, emptying the diff that gates the
|
||||||
|
// Discord standings post and rolling previousRank forward twice. EVs are still written.
|
||||||
|
await runSportsSeasonSimulation("season-1", {
|
||||||
|
skipStandingsRecalc: true,
|
||||||
|
skipSnapshots: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recalculateStandings).not.toHaveBeenCalled();
|
||||||
|
expect(batchUpsertParticipantEvSnapshots).not.toHaveBeenCalled();
|
||||||
|
expect(batchUpsertParticipantEVs).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("throws when the sports season is not found", async () => {
|
it("throws when the sports season is not found", async () => {
|
||||||
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);
|
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,36 @@
|
||||||
*
|
*
|
||||||
* Monte Carlo simulation of the AFL regular season and finals for 2026.
|
* Monte Carlo simulation of the AFL regular season and finals for 2026.
|
||||||
*
|
*
|
||||||
|
* Two modes:
|
||||||
|
* 1. Pre-bracket mode: no afl_10 bracket exists yet, or it carries no seeds. The ladder is
|
||||||
|
* re-projected from Elo every iteration and its top 10 are seeded 1-10, so the draw is
|
||||||
|
* modelled as still uncertain.
|
||||||
|
* 2. Bracket-aware mode: a seeded afl_10 bracket exists. Its slots are the seeding, fixed
|
||||||
|
* across every iteration, and games already played are replayed from their recorded
|
||||||
|
* result instead of being re-simulated.
|
||||||
|
*
|
||||||
|
* Bracket-aware mode is what makes a banked floor hold. afl_10 is the only template that
|
||||||
|
* awards points on seeding alone (entryFloor: seeds 1-4 bank 5th, seeds 5-6 bank 7th), and a
|
||||||
|
* simulator that re-draws the ladder every iteration puts those teams back in the Wildcard
|
||||||
|
* Round — or out of the finals entirely — where they score 0, pulling EV below points the
|
||||||
|
* league has already paid out. Reading the real draw removes that by construction: a team
|
||||||
|
* seeded into an Elimination Final is in that game in 100% of iterations, so its worst
|
||||||
|
* outcome is the 7th-8th tier.
|
||||||
|
*
|
||||||
* Algorithm:
|
* Algorithm:
|
||||||
* 1. Load all participants for the sports season from DB
|
* 1. Load all participants for the sports season from DB
|
||||||
* 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
|
* 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
|
||||||
* Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
|
* Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
|
||||||
* 3. Load current regular season standings (wins, gamesPlayed) — if available
|
* 3. Load current regular season standings (wins, gamesPlayed) — if available
|
||||||
* 4. For each simulation:
|
* 4. Load the afl_10 bracket, if one has been generated, for its draw and results so far
|
||||||
* a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed)
|
* 5. For each simulation:
|
||||||
* using Elo win probability vs. an average opponent (Elo 1500)
|
* a. Pre-bracket mode only: for each team, simulate remaining regular season games
|
||||||
* → projectedPoints = currentWins*4 + simulatedRemainingWins*4
|
* (TOTAL_GAMES - gamesPlayed) using Elo win probability vs. an average opponent
|
||||||
* b. Sort all 18 teams by projected points desc + random tiebreaker → final ladder
|
* (Elo 1500) → projectedPoints = currentWins*4 + simulatedRemainingWins*4
|
||||||
* → Top 10 advance to the AFL Finals Series
|
* b. Pre-bracket mode only: sort all 18 teams by projected points desc + random
|
||||||
* c. Simulate AFL Finals Series (AFL_10 bracket):
|
* tiebreaker → final ladder → top 10 advance to the AFL Finals Series.
|
||||||
|
* In bracket-aware mode the bracket's own 10 seeds are used as-is.
|
||||||
|
* c. Simulate the AFL Finals Series (AFL_10 bracket), replaying any completed match:
|
||||||
*
|
*
|
||||||
* Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts)
|
* Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts)
|
||||||
* Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye)
|
* Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye)
|
||||||
|
|
@ -24,8 +42,8 @@
|
||||||
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
||||||
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
||||||
*
|
*
|
||||||
* 5. Track placement counts per scoring tier
|
* 6. Track placement counts per scoring tier
|
||||||
* 6. Convert counts to probability distributions
|
* 7. Convert counts to probability distributions
|
||||||
*
|
*
|
||||||
* Win probability (Elo, PARITY_FACTOR = 450):
|
* Win probability (Elo, PARITY_FACTOR = 450):
|
||||||
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 450))
|
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 450))
|
||||||
|
|
@ -53,7 +71,7 @@
|
||||||
* probFifth/Sixth = Semi-Finals losers (2 per sim — split evenly)
|
* probFifth/Sixth = Semi-Finals losers (2 per sim — split evenly)
|
||||||
* probSeventh/Eighth = Elimination Finals losers (2 per sim — split evenly)
|
* probSeventh/Eighth = Elimination Finals losers (2 per sim — split evenly)
|
||||||
* Wildcard losers → all 0 (score 0 points, same as 9th/10th)
|
* Wildcard losers → all 0 (score 0 points, same as 9th/10th)
|
||||||
* Missed finals → all 0
|
* Missed finals → all 0 (in bracket-aware mode, every team outside the bracket)
|
||||||
*
|
*
|
||||||
* NOTE: AFL uses the AFL_10 bracket template which splits the 5–8 tier into two
|
* NOTE: AFL uses the AFL_10 bracket template which splits the 5–8 tier into two
|
||||||
* separate pairs (5/6 and 7/8). This is already handled by scoring-rules.ts
|
* separate pairs (5/6 and 7/8). This is already handled by scoring-rules.ts
|
||||||
|
|
@ -62,7 +80,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
|
|
@ -75,6 +93,9 @@ import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
||||||
|
|
||||||
|
/** The bracket template the AFL finals are scored against. */
|
||||||
|
const AFL_TEMPLATE_ID = "afl_10";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Elo parity factor for AFL single-game win probability.
|
* Elo parity factor for AFL single-game win probability.
|
||||||
* 450 reflects moderate variance — lower than NHL (1000) to account for
|
* 450 reflects moderate variance — lower than NHL (1000) to account for
|
||||||
|
|
@ -192,6 +213,226 @@ function simulateProjectedWins(entry: TeamEntry): number {
|
||||||
return entry.currentWins + extra;
|
return entry.currentWins + extra;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The playoff_matches columns the simulator actually reads. */
|
||||||
|
export type BracketMatch = Pick<
|
||||||
|
typeof schema.playoffMatches.$inferSelect,
|
||||||
|
"round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete"
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface LoadedBracket {
|
||||||
|
/** The 10 finalists in seed order — index 0 is the minor premier. */
|
||||||
|
seeds: TeamEntry[];
|
||||||
|
/** Every bracket match, keyed by `${round}#${matchNumber}`. */
|
||||||
|
matches: Map<string, BracketMatch>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays one finals game. `round`/`matchNumber` identify it within the bracket so an
|
||||||
|
* already-played result can be looked up; `t1`/`t2` are the teams routed into it.
|
||||||
|
*/
|
||||||
|
type PlayGame = (
|
||||||
|
round: string,
|
||||||
|
matchNumber: number,
|
||||||
|
t1: TeamEntry,
|
||||||
|
t2: TeamEntry
|
||||||
|
) => { winner: TeamEntry; loser: TeamEntry };
|
||||||
|
|
||||||
|
function matchKey(round: string, matchNumber: number): string {
|
||||||
|
return `${round}#${matchNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simGame(t1: TeamEntry, t2: TeamEntry, parityFactor: number): { winner: TeamEntry; loser: TeamEntry } {
|
||||||
|
return Math.random() < eloWinProbability(t1.elo, t2.elo, parityFactor)
|
||||||
|
? { winner: t1, loser: t2 }
|
||||||
|
: { winner: t2, loser: t1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where generateAFL10Bracket (models/playoff-match.ts) writes each seed.
|
||||||
|
*
|
||||||
|
* The two Elimination Final participant2 slots are deliberately absent: they are TBD by
|
||||||
|
* design until a Wildcard winner advances into them, so they are never a missing seed.
|
||||||
|
* That leaves exactly 10 named slots for the 10 finalists.
|
||||||
|
*/
|
||||||
|
const SEED_SLOTS: ReadonlyArray<{ round: string; matchNumber: number; slot: 1 | 2; seed: number }> = [
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 1, slot: 1, seed: 1 },
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 2, slot: 1, seed: 2 },
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 2, slot: 2, seed: 3 },
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 1, slot: 2, seed: 4 },
|
||||||
|
{ round: "Elimination Finals", matchNumber: 1, slot: 1, seed: 5 },
|
||||||
|
{ round: "Elimination Finals", matchNumber: 2, slot: 1, seed: 6 },
|
||||||
|
{ round: "Wildcard Round", matchNumber: 1, slot: 1, seed: 7 },
|
||||||
|
{ round: "Wildcard Round", matchNumber: 2, slot: 1, seed: 8 },
|
||||||
|
{ round: "Wildcard Round", matchNumber: 2, slot: 2, seed: 9 },
|
||||||
|
{ round: "Wildcard Round", matchNumber: 1, slot: 2, seed: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the seeded afl_10 bracket for this season, if there is one.
|
||||||
|
*
|
||||||
|
* Returns null only when the bracket carries no draw at all — no matches, or a freshly
|
||||||
|
* generated bracket with every slot still empty — in which case the caller falls back to
|
||||||
|
* projecting the ladder.
|
||||||
|
*
|
||||||
|
* A *partially* seeded bracket is an error rather than a fallback. Falling back there would
|
||||||
|
* throw away the real draw and every recorded result with it, putting eliminated teams back
|
||||||
|
* in contention; and it is reachable in practice, because playoff_matches.participant1Id /
|
||||||
|
* participant2Id are ON DELETE SET NULL, so removing and re-adding one participant
|
||||||
|
* mid-finals empties a slot. A duplicated or unknown participant fails loudly for the same
|
||||||
|
* reason.
|
||||||
|
*/
|
||||||
|
export function readAflBracketSeeds(
|
||||||
|
matches: BracketMatch[],
|
||||||
|
teamsById: Map<string, TeamEntry>
|
||||||
|
): LoadedBracket | null {
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m]));
|
||||||
|
|
||||||
|
const drawn = SEED_SLOTS.map(({ round, matchNumber, slot }) => {
|
||||||
|
const match = byKey.get(matchKey(round, matchNumber));
|
||||||
|
if (!match) return null;
|
||||||
|
return (slot === 1 ? match.participant1Id : match.participant2Id) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const seededCount = drawn.filter((id) => id !== null).length;
|
||||||
|
|
||||||
|
// Generated but not yet filled in — no draw to honor.
|
||||||
|
if (seededCount === 0) return null;
|
||||||
|
|
||||||
|
if (seededCount < drawn.length) {
|
||||||
|
const missing = SEED_SLOTS.filter((_, i) => drawn[i] === null)
|
||||||
|
.map((s) => s.seed)
|
||||||
|
.toSorted((a, b) => a - b)
|
||||||
|
.join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`AFL bracket is only partially seeded (${seededCount} of ${drawn.length} slots filled; ` +
|
||||||
|
`missing seed(s) ${missing}). Re-seed the bracket in Admin → Bracket before simulating; ` +
|
||||||
|
`simulating around the gap would discard the draw and every recorded result.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filled by seed number below; SEED_SLOTS covers seeds 1-10 exactly once each.
|
||||||
|
const seeds: TeamEntry[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < SEED_SLOTS.length; i++) {
|
||||||
|
const participantId = drawn[i] as string;
|
||||||
|
if (seen.has(participantId)) {
|
||||||
|
throw new Error(`AFL bracket seeds participant ${participantId} into more than one slot.`);
|
||||||
|
}
|
||||||
|
seen.add(participantId);
|
||||||
|
|
||||||
|
const team = teamsById.get(participantId);
|
||||||
|
if (!team) {
|
||||||
|
throw new Error(
|
||||||
|
`AFL bracket references participant ${participantId}, which is not in this sports season.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seeds[SEED_SLOTS[i].seed - 1] = team;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { seeds, matches: byKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recorded loser of a completed match. loserId is written by the scoring flow, but fall
|
||||||
|
* back to "whichever slot isn't the winner" for older rows.
|
||||||
|
*/
|
||||||
|
function completedLoser(match: BracketMatch): string | null {
|
||||||
|
if (match.loserId) return match.loserId;
|
||||||
|
if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id;
|
||||||
|
if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the game-playing function for a bracket.
|
||||||
|
*
|
||||||
|
* When the bracket has a completed result for a game AND that result is between the two teams
|
||||||
|
* the simulation routed into it, the recorded winner is used verbatim — that is what makes an
|
||||||
|
* already-played result stick across all iterations, and what stops a banked floor from being
|
||||||
|
* re-litigated at 50/50. Anything else is simulated. The pair check keeps a corrupt or
|
||||||
|
* out-of-order row from desynchronising the rest of the bracket.
|
||||||
|
*/
|
||||||
|
export function makePlayGame(bracket: LoadedBracket | null, parityFactor: number): PlayGame {
|
||||||
|
if (!bracket) {
|
||||||
|
return (_round, _matchNumber, t1, t2) => simGame(t1, t2, parityFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (round, matchNumber, t1, t2) => {
|
||||||
|
const match = bracket.matches.get(matchKey(round, matchNumber));
|
||||||
|
if (match?.isComplete && match.winnerId) {
|
||||||
|
const loserId = completedLoser(match);
|
||||||
|
const arrived = [t1.id, t2.id];
|
||||||
|
if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) {
|
||||||
|
return match.winnerId === t1.id ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return simGame(t1, t2, parityFactor);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate the AFL Finals Series from a seeded list of 10 teams.
|
||||||
|
*
|
||||||
|
* Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a
|
||||||
|
* recorded result is looked up against the game it was actually played in:
|
||||||
|
* SF1 = QF1 loser v EF2 winner, SF2 = QF2 loser v EF1 winner,
|
||||||
|
* PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner.
|
||||||
|
*
|
||||||
|
* Returns the placement for each team:
|
||||||
|
* "gf_winner" → 1st
|
||||||
|
* "gf_loser" → 2nd
|
||||||
|
* "pf_loser" → 3rd/4th (two teams per sim)
|
||||||
|
* "sf_loser" → 5th/6th (two teams per sim)
|
||||||
|
* "ef_loser" → 7th/8th (two teams per sim)
|
||||||
|
* "wc_loser" → 9th/10th (zero scoring points)
|
||||||
|
*/
|
||||||
|
export function simAFLFinals(
|
||||||
|
finalists: TeamEntry[],
|
||||||
|
play: PlayGame
|
||||||
|
): {
|
||||||
|
gfWinner: TeamEntry;
|
||||||
|
gfLoser: TeamEntry;
|
||||||
|
pfLosers: [TeamEntry, TeamEntry];
|
||||||
|
sfLosers: [TeamEntry, TeamEntry];
|
||||||
|
efLosers: [TeamEntry, TeamEntry];
|
||||||
|
} {
|
||||||
|
const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists;
|
||||||
|
|
||||||
|
// Wildcard Round: #7 vs #10, #8 vs #9
|
||||||
|
const wc1 = play("Wildcard Round", 1, s7, s10);
|
||||||
|
const wc2 = play("Wildcard Round", 2, s8, s9);
|
||||||
|
|
||||||
|
// Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get a bye to a PF)
|
||||||
|
const qf1 = play("Qualifying Finals", 1, s1, s4);
|
||||||
|
const qf2 = play("Qualifying Finals", 2, s2, s3);
|
||||||
|
|
||||||
|
// Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner
|
||||||
|
const ef1 = play("Elimination Finals", 1, s5, wc2.winner);
|
||||||
|
const ef2 = play("Elimination Finals", 2, s6, wc1.winner);
|
||||||
|
|
||||||
|
// Semi-Finals: QF losers (second chance) vs EF winners
|
||||||
|
const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner);
|
||||||
|
const sf2 = play("Semi-Finals", 2, qf2.loser, ef1.winner);
|
||||||
|
|
||||||
|
// Preliminary Finals: QF winners vs SF winners
|
||||||
|
const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);
|
||||||
|
const pf2 = play("Preliminary Finals", 2, qf2.winner, sf1.winner);
|
||||||
|
|
||||||
|
// Grand Final
|
||||||
|
const gf = play("Grand Final", 1, pf1.winner, pf2.winner);
|
||||||
|
|
||||||
|
return {
|
||||||
|
gfWinner: gf.winner,
|
||||||
|
gfLoser: gf.loser,
|
||||||
|
pfLosers: [pf1.loser, pf2.loser],
|
||||||
|
sfLosers: [sf1.loser, sf2.loser],
|
||||||
|
efLosers: [ef1.loser, ef2.loser],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class AFLSimulator implements Simulator {
|
export class AFLSimulator implements Simulator {
|
||||||
|
|
@ -270,11 +511,34 @@ export class AFLSimulator implements Simulator {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
const teamsById = new Map(teams.map((t) => [t.id, t]));
|
||||||
|
|
||||||
/** Simulate a single AFL game. Returns the winner. */
|
// 4. Load the real bracket (draw + results so far), if one has been generated.
|
||||||
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
// Events are filtered on bracketTemplateId rather than eventType and taken most
|
||||||
Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b;
|
// recent first, matching getBracketTemplateIdsForSportsSeasons: a season can own
|
||||||
|
// several events, and landing on a stale or template-less row would silently
|
||||||
|
// discard the real draw and every recorded result. createdAt can tie when a bracket
|
||||||
|
// is generated alongside a sibling event, so id breaks the tie.
|
||||||
|
const playoffEvents = await db.query.scoringEvents.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||||
|
eq(schema.scoringEvents.bracketTemplateId, AFL_TEMPLATE_ID)
|
||||||
|
),
|
||||||
|
columns: { id: true },
|
||||||
|
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
|
||||||
|
});
|
||||||
|
const bracketEvent = playoffEvents[0];
|
||||||
|
|
||||||
|
const bracketMatches = bracketEvent
|
||||||
|
? await db.query.playoffMatches.findMany({
|
||||||
|
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const bracket = readAflBracketSeeds(bracketMatches, teamsById);
|
||||||
|
const play = makePlayGame(bracket, parityFactor);
|
||||||
|
|
||||||
|
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project end-of-season ladder and return the top 10 finalists seeded 1–10.
|
* Project end-of-season ladder and return the top 10 finalists seeded 1–10.
|
||||||
|
|
@ -293,70 +557,7 @@ export class AFLSimulator implements Simulator {
|
||||||
return projected.slice(0, 10).map((x) => x.team);
|
return projected.slice(0, 10).map((x) => x.team);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// 5. Integer placement count maps — initialized to 0 for all participants.
|
||||||
* Simulate the AFL Finals Series from a seeded list of 10 teams.
|
|
||||||
*
|
|
||||||
* Returns the placement for each team:
|
|
||||||
* "gf_winner" → 1st
|
|
||||||
* "gf_loser" → 2nd
|
|
||||||
* "pf_loser" → 3rd/4th (two teams per sim)
|
|
||||||
* "sf_loser" → 5th/6th (two teams per sim)
|
|
||||||
* "ef_loser" → 7th/8th (two teams per sim)
|
|
||||||
* "wc_loser" → 9th/10th (zero scoring points)
|
|
||||||
*/
|
|
||||||
const simAFLFinals = (
|
|
||||||
finalists: TeamEntry[]
|
|
||||||
): {
|
|
||||||
gfWinner: TeamEntry;
|
|
||||||
gfLoser: TeamEntry;
|
|
||||||
pfLosers: [TeamEntry, TeamEntry];
|
|
||||||
sfLosers: [TeamEntry, TeamEntry];
|
|
||||||
efLosers: [TeamEntry, TeamEntry];
|
|
||||||
} => {
|
|
||||||
const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists;
|
|
||||||
|
|
||||||
// Wildcard Round: #7 vs #10, #8 vs #9
|
|
||||||
const wc1Winner = simGame(s7, s10);
|
|
||||||
const wc2Winner = simGame(s8, s9);
|
|
||||||
|
|
||||||
// Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get bye to PF)
|
|
||||||
const qf1Winner = simGame(s1, s4);
|
|
||||||
const qf1Loser = qf1Winner === s1 ? s4 : s1;
|
|
||||||
const qf2Winner = simGame(s2, s3);
|
|
||||||
const qf2Loser = qf2Winner === s2 ? s3 : s2;
|
|
||||||
|
|
||||||
// Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner
|
|
||||||
const ef1Winner = simGame(s5, wc2Winner);
|
|
||||||
const ef1Loser = ef1Winner === s5 ? wc2Winner : s5;
|
|
||||||
const ef2Winner = simGame(s6, wc1Winner);
|
|
||||||
const ef2Loser = ef2Winner === s6 ? wc1Winner : s6;
|
|
||||||
|
|
||||||
// Semi-Finals: QF losers (2nd chance) vs EF winners
|
|
||||||
const sf1Winner = simGame(qf1Loser, ef2Winner);
|
|
||||||
const sf1Loser = sf1Winner === qf1Loser ? ef2Winner : qf1Loser;
|
|
||||||
const sf2Winner = simGame(qf2Loser, ef1Winner);
|
|
||||||
const sf2Loser = sf2Winner === qf2Loser ? ef1Winner : qf2Loser;
|
|
||||||
|
|
||||||
// Preliminary Finals: QF winners vs SF winners
|
|
||||||
const pf1Winner = simGame(qf1Winner, sf2Winner);
|
|
||||||
const pf1Loser = pf1Winner === qf1Winner ? sf2Winner : qf1Winner;
|
|
||||||
const pf2Winner = simGame(qf2Winner, sf1Winner);
|
|
||||||
const pf2Loser = pf2Winner === qf2Winner ? sf1Winner : qf2Winner;
|
|
||||||
|
|
||||||
// Grand Final
|
|
||||||
const gfWinner = simGame(pf1Winner, pf2Winner);
|
|
||||||
const gfLoser = gfWinner === pf1Winner ? pf2Winner : pf1Winner;
|
|
||||||
|
|
||||||
return {
|
|
||||||
gfWinner,
|
|
||||||
gfLoser,
|
|
||||||
pfLosers: [pf1Loser, pf2Loser ],
|
|
||||||
sfLosers: [sf1Loser, sf2Loser ],
|
|
||||||
efLosers: [ef1Loser, ef2Loser ],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. Integer placement count maps — initialized to 0 for all participants.
|
|
||||||
//
|
//
|
||||||
// AFL scoring uses the AFL_10 bracket template which splits 5–8 into two
|
// AFL scoring uses the AFL_10 bracket template which splits 5–8 into two
|
||||||
// separate pairs: Semi-Finals losers share 5th/6th (higher value), and
|
// separate pairs: Semi-Finals losers share 5th/6th (higher value), and
|
||||||
|
|
@ -368,10 +569,12 @@ export class AFLSimulator implements Simulator {
|
||||||
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
|
|
||||||
// 4. Monte Carlo simulation loop.
|
// 6. Monte Carlo simulation loop.
|
||||||
for (let s = 0; s < numSimulations; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
const finalists = buildFinalsList();
|
// With a real bracket the draw is fixed and its played games are replayed from their
|
||||||
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists);
|
// recorded result; without one the ladder is re-projected every iteration.
|
||||||
|
const finalists = bracket ? bracket.seeds : buildFinalsList();
|
||||||
|
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists, play);
|
||||||
|
|
||||||
championCounts.set(gfWinner.id, (championCounts.get(gfWinner.id) ?? 0) + 1);
|
championCounts.set(gfWinner.id, (championCounts.get(gfWinner.id) ?? 0) + 1);
|
||||||
finalistCounts.set(gfLoser.id, (finalistCounts.get(gfLoser.id) ?? 0) + 1);
|
finalistCounts.set(gfLoser.id, (finalistCounts.get(gfLoser.id) ?? 0) + 1);
|
||||||
|
|
@ -388,7 +591,7 @@ export class AFLSimulator implements Simulator {
|
||||||
// Wildcard losers and non-finalists are not counted (0 points per scoring rules).
|
// Wildcard losers and non-finalists are not counted (0 points per scoring rules).
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Convert integer counts to probability distributions.
|
// 7. Convert integer counts to probability distributions.
|
||||||
//
|
//
|
||||||
// Exact denominators guarantee column sums of 1.0 by construction:
|
// Exact denominators guarantee column sums of 1.0 by construction:
|
||||||
// probFirst/Second → / NUM_SIMULATIONS (1 per sim)
|
// probFirst/Second → / NUM_SIMULATIONS (1 per sim)
|
||||||
|
|
@ -421,8 +624,8 @@ export class AFLSimulator implements Simulator {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Per-position normalization — belt-and-suspenders guard against floating-point
|
// 8. Per-position normalization — belt-and-suspenders guard against floating-point
|
||||||
// division residuals. Columns are already near-exactly 1.0 after step 5.
|
// division residuals. Columns are already near-exactly 1.0 after step 7.
|
||||||
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
|
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
|
||||||
"probFirst", "probSecond", "probThird", "probFourth",
|
"probFirst", "probSecond", "probThird", "probFourth",
|
||||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,25 @@ export interface SimulatorManifestProfile {
|
||||||
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
|
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
|
||||||
setupSections: SimulatorSetupSection[];
|
setupSections: SimulatorSetupSection[];
|
||||||
minParticipantInputs?: number;
|
minParticipantInputs?: number;
|
||||||
|
/**
|
||||||
|
* The simulator reads the season's generated bracket: it seeds from the real draw and
|
||||||
|
* replays completed matches from their recorded result, rather than re-drawing the field
|
||||||
|
* and re-playing decided games every iteration.
|
||||||
|
*
|
||||||
|
* updateProbabilitiesAfterResult reads this to decide whether a result should be absorbed
|
||||||
|
* by re-running the simulator or by the generic ICM recalculation. Re-running is both more
|
||||||
|
* accurate and the only option that respects a banked placement floor, but it is only safe
|
||||||
|
* here: re-running a bracket-blind simulator would re-draw the field and hand equity back
|
||||||
|
* to teams already knocked out.
|
||||||
|
*
|
||||||
|
* Both halves are required. A simulator that reads the draw but re-simulates games already
|
||||||
|
* played is NOT bracket-aware for this purpose — it resurrects eliminated teams just the
|
||||||
|
* same. Check for an `isComplete`/`winnerId` replay before setting this on a new simulator.
|
||||||
|
*
|
||||||
|
* This is deliberately separate from `setupSections: ["bracket"]`, which only drives admin
|
||||||
|
* links and a readiness warning and does not track this accurately in either direction.
|
||||||
|
*/
|
||||||
|
bracketAware?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_CONFIG = {
|
const BASE_CONFIG = {
|
||||||
|
|
@ -71,6 +90,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["sourceOdds"],
|
optionalInputs: ["sourceOdds"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
ncaam_bracket: {
|
ncaam_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
|
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
|
||||||
|
|
@ -78,6 +98,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
||||||
derivableInputs: { rating: ["sourceOdds"] },
|
derivableInputs: { rating: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
ncaaw_bracket: {
|
ncaaw_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
|
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
|
||||||
|
|
@ -85,6 +106,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["sourceOdds", "seed", "region"],
|
optionalInputs: ["sourceOdds", "seed", "region"],
|
||||||
derivableInputs: { rating: ["sourceOdds"] },
|
derivableInputs: { rating: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
nba_bracket: {
|
nba_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
|
||||||
|
|
@ -92,6 +114,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
nhl_bracket: {
|
nhl_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
|
||||||
|
|
@ -99,6 +122,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
nfl_bracket: {
|
nfl_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
|
||||||
|
|
@ -112,7 +136,10 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["projectedWins"],
|
optionalInputs: ["projectedWins"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins"] },
|
derivableInputs: { sourceElo: ["projectedWins"] },
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
// The bracket is optional — before one exists the ladder is projected from Elo — but once
|
||||||
|
// it is drawn the simulator seeds from it and honors completed results.
|
||||||
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
epl_standings: {
|
epl_standings: {
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
|
|
@ -135,6 +162,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["worldRanking", "seed"],
|
optionalInputs: ["worldRanking", "seed"],
|
||||||
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
|
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
tennis_qualifying_points: {
|
tennis_qualifying_points: {
|
||||||
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
|
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
|
||||||
|
|
@ -162,18 +190,21 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
|
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
darts_bracket: {
|
darts_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
|
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
|
||||||
requiredInputs: ["sourceElo", "worldRanking"],
|
requiredInputs: ["sourceElo", "worldRanking"],
|
||||||
optionalInputs: ["seed"],
|
optionalInputs: ["seed"],
|
||||||
setupSections: ["participants", "eloRatings", "rankings"],
|
setupSections: ["participants", "eloRatings", "rankings"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
cs2_major_qualifying_points: {
|
cs2_major_qualifying_points: {
|
||||||
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
|
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["worldRanking", "metadata"],
|
optionalInputs: ["worldRanking", "metadata"],
|
||||||
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
ncaa_football_bracket: {
|
ncaa_football_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
|
||||||
|
|
@ -189,6 +220,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
// The bracket is optional — without one the draw is randomized — but once it
|
// The bracket is optional — without one the draw is randomized — but once it
|
||||||
// exists the simulator reads the real draw and honors completed results from it.
|
// exists the simulator reads the real draw and honors completed results from it.
|
||||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
college_hockey_bracket: {
|
college_hockey_bracket: {
|
||||||
// College hockey blends odds into Elo internally (and also uses NPI rank,
|
// College hockey blends odds into Elo internally (and also uses NPI rank,
|
||||||
|
|
@ -201,6 +233,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
|
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
brackt: {
|
brackt: {
|
||||||
defaultConfig: { iterations: 20_000 },
|
defaultConfig: { iterations: 20_000 },
|
||||||
|
|
@ -224,6 +257,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
|
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
mls_bracket: {
|
mls_bracket: {
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,29 @@ async function getPersistenceContext(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side effects a caller can opt out of.
|
||||||
|
*
|
||||||
|
* A simulation run does three jobs — recompute probabilities, recalculate standings, and record
|
||||||
|
* the day's EV snapshot. `updateProbabilitiesAfterResult` wants only the first: it runs inside
|
||||||
|
* the result path, where the caller recalculates standings itself immediately afterwards.
|
||||||
|
*
|
||||||
|
* Letting the run recalculate there is not merely redundant, it is wrong.
|
||||||
|
* recalculateAffectedLeagues detects change by snapshotting teamStandings, recalculating, then
|
||||||
|
* diffing, and that diff gates the Discord standings post; a recalculation slipped in
|
||||||
|
* beforehand makes the diff empty and silently suppresses the notification. recalculateStandings
|
||||||
|
* also rolls previousRank forward on every call, so an extra one erases rank movement.
|
||||||
|
*/
|
||||||
|
export interface RunSportsSeasonSimulationOptions {
|
||||||
|
/** Leave standings to the caller. */
|
||||||
|
skipStandingsRecalc?: boolean;
|
||||||
|
/**
|
||||||
|
* Skip the daily EV snapshot. The snapshot is a per-day series keyed by snapshotDate, so
|
||||||
|
* writing it on every match result just overwrites the day's row with intra-day values.
|
||||||
|
*/
|
||||||
|
skipSnapshots?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RunSportsSeasonSimulationResult {
|
export interface RunSportsSeasonSimulationResult {
|
||||||
sportsSeasonId: string;
|
sportsSeasonId: string;
|
||||||
simulatorType: SimulatorType;
|
simulatorType: SimulatorType;
|
||||||
|
|
@ -71,7 +94,8 @@ export interface RunSportsSeasonSimulationResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runSportsSeasonSimulation(
|
export async function runSportsSeasonSimulation(
|
||||||
sportsSeasonId: string
|
sportsSeasonId: string,
|
||||||
|
options: RunSportsSeasonSimulationOptions = {}
|
||||||
): Promise<RunSportsSeasonSimulationResult> {
|
): Promise<RunSportsSeasonSimulationResult> {
|
||||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||||
if (!sportsSeason) {
|
if (!sportsSeason) {
|
||||||
|
|
@ -135,29 +159,33 @@ export async function runSportsSeasonSimulation(
|
||||||
})),
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const seasonSports = await database().query.seasonSports.findMany({
|
if (!options.skipStandingsRecalc) {
|
||||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
const seasonSports = await database().query.seasonSports.findMany({
|
||||||
});
|
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
||||||
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
});
|
||||||
|
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
||||||
|
}
|
||||||
|
|
||||||
const snapshotDate = new Date().toISOString().slice(0, 10);
|
const snapshotDate = new Date().toISOString().slice(0, 10);
|
||||||
await batchUpsertParticipantEvSnapshots(
|
if (!options.skipSnapshots) {
|
||||||
results.map((r) => ({
|
await batchUpsertParticipantEvSnapshots(
|
||||||
participantId: r.participantId,
|
results.map((r) => ({
|
||||||
sportsSeasonId,
|
participantId: r.participantId,
|
||||||
snapshotDate,
|
sportsSeasonId,
|
||||||
probFirst: r.probabilities.probFirst,
|
snapshotDate,
|
||||||
probSecond: r.probabilities.probSecond,
|
probFirst: r.probabilities.probFirst,
|
||||||
probThird: r.probabilities.probThird,
|
probSecond: r.probabilities.probSecond,
|
||||||
probFourth: r.probabilities.probFourth,
|
probThird: r.probabilities.probThird,
|
||||||
probFifth: r.probabilities.probFifth,
|
probFourth: r.probabilities.probFourth,
|
||||||
probSixth: r.probabilities.probSixth,
|
probFifth: r.probabilities.probFifth,
|
||||||
probSeventh: r.probabilities.probSeventh,
|
probSixth: r.probabilities.probSixth,
|
||||||
probEighth: r.probabilities.probEighth,
|
probSeventh: r.probabilities.probSeventh,
|
||||||
calculatedEV: calculateEV(r.probabilities, persistence.scoringRules),
|
probEighth: r.probabilities.probEighth,
|
||||||
source: r.source,
|
calculatedEV: calculateEV(r.probabilities, persistence.scoringRules),
|
||||||
}))
|
source: r.source,
|
||||||
);
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue