Compare commits
No commits in common. "758166dd4650189c63778326e36a6c3ea999da29" and "1acef25027208f862c871e0e60f835b94122b7a5" have entirely different histories.
758166dd46
...
1acef25027
11 changed files with 146 additions and 1081 deletions
|
|
@ -319,22 +319,6 @@ 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,19 +547,6 @@ 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
|
||||||
|
|
@ -570,7 +557,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, skipProbabilities, loserAdvances } = params;
|
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, 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),
|
||||||
|
|
@ -650,7 +637,6 @@ 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.
|
||||||
if (!skipProbabilities) {
|
|
||||||
try {
|
try {
|
||||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -659,7 +645,6 @@ export async function processMatchResult(
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,6 @@ 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: {
|
||||||
|
|
@ -21,9 +18,6 @@ vi.mock("~/database/context", () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// vi.mock above is hoisted over the imports, so this is already the mocked function.
|
|
||||||
const upsertEV = vi.mocked(participantEVModel.upsertParticipantEV);
|
|
||||||
|
|
||||||
describe("probability-updater", () => {
|
describe("probability-updater", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -326,208 +320,3 @@ 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,7 +22,6 @@ 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 {
|
||||||
|
|
@ -284,11 +283,6 @@ 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,
|
||||||
|
|
@ -306,15 +300,6 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The refresh skipped inside the loop, run once for the whole sync.
|
|
||||||
if (playoffUpdated > 0) {
|
|
||||||
try {
|
|
||||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`[match-sync] Error updating probabilities after bracket sync:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
|
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,6 @@ 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
|
||||||
|
|
@ -101,40 +97,6 @@ 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
|
||||||
*
|
*
|
||||||
|
|
@ -142,8 +104,7 @@ async function shouldRerunSimulator(sportsSeasonId: string): Promise<boolean> {
|
||||||
* 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: re-run the season's bracket-aware simulator if it has one,
|
* 4. For unfinished participants: recalculate using ICM with remaining participants
|
||||||
* 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)
|
||||||
|
|
@ -177,49 +138,39 @@ 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 && (await shouldRerunSimulator(sportsSeasonId))) {
|
if (unfinishedEVs.length > 0) {
|
||||||
// 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);
|
||||||
|
|
@ -269,38 +220,6 @@ export async function updateProbabilitiesAfterResult(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update finished participants. The shared default table is used because we only
|
|
||||||
// care about setting probabilities here, not the EV — each league re-derives its own
|
|
||||||
// EV from the stored probabilities in calculateTeamProjectedScore.
|
|
||||||
//
|
|
||||||
// This runs *after* the recalculation above, not before, because re-running a simulator
|
|
||||||
// rewrites every participant in the season — the finalized ones included. A finalized
|
|
||||||
// placement is a fact, not a projection, so it is written last and wins: if a simulator
|
|
||||||
// ever puts a knocked-out team back in contention (a bracket-aware one whose bracket has
|
|
||||||
// since been cleared and not re-seeded, say), the pin still zeroes them.
|
|
||||||
|
|
||||||
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
|
|
||||||
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
|
|
||||||
// Running these in parallel would race on that shared state.
|
|
||||||
for (const [participantId, finalPosition] of finishedMap.entries()) {
|
|
||||||
try {
|
|
||||||
const probs = createFinishedProbabilities(finalPosition);
|
|
||||||
const probabilities = arrayToProbabilityDistribution(probs);
|
|
||||||
|
|
||||||
await upsertParticipantEV({
|
|
||||||
participantId,
|
|
||||||
sportsSeasonId,
|
|
||||||
probabilities,
|
|
||||||
scoringRules: DEFAULT_SCORING_RULES,
|
|
||||||
source: 'manual', // Result is from actual outcome
|
|
||||||
});
|
|
||||||
|
|
||||||
updated++;
|
|
||||||
} catch (error) {
|
|
||||||
errors.push(`Failed to update participant ${participantId}: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
finishedParticipants: finishedMap.size,
|
finishedParticipants: finishedMap.size,
|
||||||
unfishedParticipants: existingEVs.length - finishedMap.size,
|
unfishedParticipants: existingEVs.length - finishedMap.size,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
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 {
|
import { getTeamData, eloWinProbability, AFLSimulator } from "../afl-simulator";
|
||||||
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 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -133,82 +125,8 @@ 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: {
|
let mockDb: { select: MockInstance };
|
||||||
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");
|
||||||
|
|
@ -218,11 +136,6 @@ 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) {
|
||||||
|
|
@ -442,184 +355,4 @@ 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,33 +30,6 @@ 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,8 +50,6 @@ 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";
|
||||||
|
|
||||||
|
|
@ -128,42 +126,6 @@ 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,36 +3,18 @@
|
||||||
*
|
*
|
||||||
* 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. Load the afl_10 bracket, if one has been generated, for its draw and results so far
|
* 4. For each simulation:
|
||||||
* 5. For each simulation:
|
* a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed)
|
||||||
* a. Pre-bracket mode only: for each team, simulate remaining regular season games
|
* using Elo win probability vs. an average opponent (Elo 1500)
|
||||||
* (TOTAL_GAMES - gamesPlayed) using Elo win probability vs. an average opponent
|
* → projectedPoints = currentWins*4 + simulatedRemainingWins*4
|
||||||
* (Elo 1500) → projectedPoints = currentWins*4 + simulatedRemainingWins*4
|
* b. Sort all 18 teams by projected points desc + random tiebreaker → final ladder
|
||||||
* b. Pre-bracket mode only: sort all 18 teams by projected points desc + random
|
* → Top 10 advance to the AFL Finals Series
|
||||||
* tiebreaker → final ladder → top 10 advance to the AFL Finals Series.
|
* c. Simulate AFL Finals Series (AFL_10 bracket):
|
||||||
* 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)
|
||||||
|
|
@ -42,8 +24,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
|
||||||
*
|
*
|
||||||
* 6. Track placement counts per scoring tier
|
* 5. Track placement counts per scoring tier
|
||||||
* 7. Convert counts to probability distributions
|
* 6. 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))
|
||||||
|
|
@ -71,7 +53,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 (in bracket-aware mode, every team outside the bracket)
|
* Missed finals → all 0
|
||||||
*
|
*
|
||||||
* 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
|
||||||
|
|
@ -80,7 +62,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
|
|
@ -93,9 +75,6 @@ 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
|
||||||
|
|
@ -213,226 +192,6 @@ 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 {
|
||||||
|
|
@ -511,35 +270,12 @@ export class AFLSimulator implements Simulator {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const teamsById = new Map(teams.map((t) => [t.id, t]));
|
|
||||||
|
|
||||||
// 4. Load the real bracket (draw + results so far), if one has been generated.
|
|
||||||
// Events are filtered on bracketTemplateId rather than eventType and taken most
|
|
||||||
// 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) ─────────────────────────
|
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
||||||
|
|
||||||
|
/** Simulate a single AFL game. Returns the winner. */
|
||||||
|
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
||||||
|
Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
*
|
*
|
||||||
|
|
@ -557,7 +293,70 @@ 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
|
||||||
|
|
@ -569,12 +368,10 @@ 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]));
|
||||||
|
|
||||||
// 6. Monte Carlo simulation loop.
|
// 4. Monte Carlo simulation loop.
|
||||||
for (let s = 0; s < numSimulations; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
// With a real bracket the draw is fixed and its played games are replayed from their
|
const finalists = buildFinalsList();
|
||||||
// recorded result; without one the ladder is re-projected every iteration.
|
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists);
|
||||||
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);
|
||||||
|
|
@ -591,7 +388,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).
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Convert integer counts to probability distributions.
|
// 5. 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)
|
||||||
|
|
@ -624,8 +421,8 @@ export class AFLSimulator implements Simulator {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 8. Per-position normalization — belt-and-suspenders guard against floating-point
|
// 6. Per-position normalization — belt-and-suspenders guard against floating-point
|
||||||
// division residuals. Columns are already near-exactly 1.0 after step 7.
|
// division residuals. Columns are already near-exactly 1.0 after step 5.
|
||||||
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,25 +34,6 @@ 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 = {
|
||||||
|
|
@ -90,7 +71,6 @@ 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 } },
|
||||||
|
|
@ -98,7 +78,6 @@ 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 } },
|
||||||
|
|
@ -106,7 +85,6 @@ 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 },
|
||||||
|
|
@ -114,7 +92,6 @@ 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 },
|
||||||
|
|
@ -122,7 +99,6 @@ 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 },
|
||||||
|
|
@ -136,10 +112,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["projectedWins"],
|
optionalInputs: ["projectedWins"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins"] },
|
derivableInputs: { sourceElo: ["projectedWins"] },
|
||||||
// The bracket is optional — before one exists the ladder is projected from Elo — but once
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||||
// 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: {
|
||||||
|
|
@ -162,7 +135,6 @@ 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 },
|
||||||
|
|
@ -190,21 +162,18 @@ 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 } },
|
||||||
|
|
@ -220,7 +189,6 @@ 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,
|
||||||
|
|
@ -233,7 +201,6 @@ 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 },
|
||||||
|
|
@ -257,7 +224,6 @@ 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,29 +62,6 @@ 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;
|
||||||
|
|
@ -94,8 +71,7 @@ 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) {
|
||||||
|
|
@ -159,15 +135,12 @@ export async function runSportsSeasonSimulation(
|
||||||
})),
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!options.skipStandingsRecalc) {
|
|
||||||
const seasonSports = await database().query.seasonSports.findMany({
|
const seasonSports = await database().query.seasonSports.findMany({
|
||||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
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);
|
||||||
if (!options.skipSnapshots) {
|
|
||||||
await batchUpsertParticipantEvSnapshots(
|
await batchUpsertParticipantEvSnapshots(
|
||||||
results.map((r) => ({
|
results.map((r) => ({
|
||||||
participantId: r.participantId,
|
participantId: r.participantId,
|
||||||
|
|
@ -185,7 +158,6 @@ export async function runSportsSeasonSimulation(
|
||||||
source: r.source,
|
source: r.source,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue