claude/afl-ev-calc-issue-8kcmlh #145
5 changed files with 831 additions and 116 deletions
|
|
@ -8,6 +8,8 @@ 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("~/services/simulations/runner");
|
||||||
vi.mock("~/database/context", () => ({
|
vi.mock("~/database/context", () => ({
|
||||||
database: () => ({
|
database: () => ({
|
||||||
query: {
|
query: {
|
||||||
|
|
@ -18,6 +20,9 @@ vi.mock("~/database/context", () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// vi.mock above is hoisted over the imports, so this is already the mocked function.
|
||||||
|
const upsertEV = vi.mocked(participantEVModel.upsertParticipantEV);
|
||||||
|
|
||||||
describe("probability-updater", () => {
|
describe("probability-updater", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -320,3 +325,163 @@ describe("probability-updater", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Bracket-aware simulator seasons ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The ICM branch re-derives a whole distribution from P(1st) alone and knows nothing about
|
||||||
|
// the bracket, 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.
|
||||||
|
// For a season whose EVs came from a simulator that reads the bracket, re-running that
|
||||||
|
// simulator is the correct refresh; for every other season ICM stays exactly as it was.
|
||||||
|
|
||||||
|
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>[];
|
||||||
|
}) {
|
||||||
|
const simulatorModel = await import("~/models/simulator");
|
||||||
|
const runner = await import("~/services/simulations/runner");
|
||||||
|
|
||||||
|
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(icmWrites()).toHaveLength(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("keeps the ICM path for a bracket-blind simulator", async () => {
|
||||||
|
// Re-running one of these would re-draw the field and hand equity back to teams already
|
||||||
|
// knocked out, so nothing changes for them.
|
||||||
|
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "nba_bracket" });
|
||||||
|
|
||||||
|
await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
|
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
||||||
|
expect(icmWrites().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the ICM path when the EVs did not come from the simulator", async () => {
|
||||||
|
const { runner } = await setup({ evSource: "futures_odds", simulatorType: "afl_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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||||
|
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
|
||||||
|
import { getManifestSimulatorProfile } from "~/services/simulations/manifest";
|
||||||
|
import { logger } from "~/lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of probability update operation
|
* Result of probability update operation
|
||||||
|
|
@ -97,6 +100,36 @@ function createFinishedProbabilities(finalPosition: number): number[] {
|
||||||
return probs;
|
return probs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** EV sources written by a simulation run rather than by odds import or manual entry. */
|
||||||
|
const SIMULATOR_EV_SOURCES = new Set(["elo_simulation", "performance_model"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a season's still-alive participants should be refreshed by re-running its
|
||||||
|
* simulator instead of by the ICM recalculation below.
|
||||||
|
*
|
||||||
|
* Two conditions, and both matter:
|
||||||
|
*
|
||||||
|
* - The simulator must be bracket-aware (manifest `bracketAware`). Re-running a
|
||||||
|
* bracket-blind simulator after a result would re-draw the field and hand championship
|
||||||
|
* equity back to teams that have already been knocked out — strictly worse than ICM.
|
||||||
|
* Only AFL and LLWS read the real draw and replay completed matches.
|
||||||
|
* - The EVs must actually have come from that simulator. If an admin entered them by hand
|
||||||
|
* or imported them from futures odds, overwriting them with a simulation is not a refresh.
|
||||||
|
* The finished-participant loop below rewrites rows to `manual`, so only the unfinished
|
||||||
|
* rows — the ones about to be recalculated — are consulted.
|
||||||
|
*/
|
||||||
|
async function shouldRerunSimulator(
|
||||||
|
sportsSeasonId: string,
|
||||||
|
unfinishedEVs: ParticipantEV[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!unfinishedEVs.some((ev) => SIMULATOR_EV_SOURCES.has(ev.source ?? ""))) return false;
|
||||||
|
|
||||||
|
const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||||||
|
if (!simulatorConfig) return false;
|
||||||
|
|
||||||
|
return getManifestSimulatorProfile(simulatorConfig.simulatorType)?.bracketAware === true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update probabilities for a sports season after results come in
|
* Update probabilities for a sports season after results come in
|
||||||
*
|
*
|
||||||
|
|
@ -104,7 +137,8 @@ function createFinishedProbabilities(finalPosition: number): number[] {
|
||||||
* 1. Get all participant results (finished participants)
|
* 1. Get all participant results (finished participants)
|
||||||
* 2. Get all existing participant EVs
|
* 2. Get all existing participant EVs
|
||||||
* 3. For finished participants: set 100% at their placement
|
* 3. For finished participants: set 100% at their placement
|
||||||
* 4. For unfinished participants: recalculate using ICM with remaining participants
|
* 4. For unfinished participants: re-run the season's bracket-aware simulator if it has one,
|
||||||
|
* otherwise recalculate using ICM with remaining participants
|
||||||
*
|
*
|
||||||
* @param sportsSeasonId Sports season to update
|
* @param sportsSeasonId Sports season to update
|
||||||
* @param recalculateUnfinished Whether to recalculate unfinished participants (default true)
|
* @param recalculateUnfinished Whether to recalculate unfinished participants (default true)
|
||||||
|
|
@ -138,39 +172,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) {
|
if (unfinishedEVs.length > 0 && (await shouldRerunSimulator(sportsSeasonId, unfinishedEVs))) {
|
||||||
|
// 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");
|
||||||
|
await runSportsSeasonSimulation(sportsSeasonId);
|
||||||
|
updated += unfinishedEVs.length;
|
||||||
|
} catch (error) {
|
||||||
|
// runSportsSeasonSimulation throws on a completed season, on a run already in
|
||||||
|
// flight, and on failed readiness. Leave the existing probabilities alone rather
|
||||||
|
// than falling back to ICM: for these seasons ICM is the thing being replaced, and
|
||||||
|
// a completed season has nothing unfinished left to recalculate anyway.
|
||||||
|
logger.error(
|
||||||
|
`[ProbabilityUpdater] Failed to re-run simulator for sports season ${sportsSeasonId}; ` +
|
||||||
|
`leaving existing probabilities in place:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
errors.push(`Failed to re-run simulator for sports season ${sportsSeasonId}: ${error}`);
|
||||||
|
}
|
||||||
|
} else if (unfinishedEVs.length > 0) {
|
||||||
// Get their current championship probabilities (use existing P(1st) as proxy)
|
// Get their current championship probabilities (use existing P(1st) as proxy)
|
||||||
const unfinishedOdds = unfinishedEVs.map(ev => {
|
const unfinishedOdds = unfinishedEVs.map(ev => {
|
||||||
const pFirst = parseFloat(ev.probFirst);
|
const pFirst = parseFloat(ev.probFirst);
|
||||||
|
|
@ -220,6 +254,38 @@ export async function updateProbabilitiesAfterResult(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update finished participants. The shared default table is used because we only
|
||||||
|
// care about setting probabilities here, not the EV — each league re-derives its own
|
||||||
|
// EV from the stored probabilities in calculateTeamProjectedScore.
|
||||||
|
//
|
||||||
|
// This runs *after* the recalculation above, not before, because re-running a simulator
|
||||||
|
// rewrites every participant in the season — the finalized ones included. A finalized
|
||||||
|
// placement is a fact, not a projection, so it is written last and wins: if a simulator
|
||||||
|
// ever puts a knocked-out team back in contention (a bracket-aware one whose bracket has
|
||||||
|
// since been cleared and not re-seeded, say), the pin still zeroes them.
|
||||||
|
|
||||||
|
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
|
||||||
|
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
|
||||||
|
// Running these in parallel would race on that shared state.
|
||||||
|
for (const [participantId, finalPosition] of finishedMap.entries()) {
|
||||||
|
try {
|
||||||
|
const probs = createFinishedProbabilities(finalPosition);
|
||||||
|
const probabilities = arrayToProbabilityDistribution(probs);
|
||||||
|
|
||||||
|
await upsertParticipantEV({
|
||||||
|
participantId,
|
||||||
|
sportsSeasonId,
|
||||||
|
probabilities,
|
||||||
|
scoringRules: DEFAULT_SCORING_RULES,
|
||||||
|
source: 'manual', // Result is from actual outcome
|
||||||
|
});
|
||||||
|
|
||||||
|
updated++;
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`Failed to update participant ${participantId}: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
finishedParticipants: finishedMap.size,
|
finishedParticipants: finishedMap.size,
|
||||||
unfishedParticipants: existingEVs.length - finishedMap.size,
|
unfishedParticipants: existingEVs.length - finishedMap.size,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
import { getTeamData, eloWinProbability, AFLSimulator } from "../afl-simulator";
|
import {
|
||||||
|
getTeamData,
|
||||||
|
eloWinProbability,
|
||||||
|
AFLSimulator,
|
||||||
|
readAflBracketSeeds,
|
||||||
|
type BracketMatch,
|
||||||
|
} from "../afl-simulator";
|
||||||
|
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||||
|
import { calculateEV, type ProbabilityDistribution } from "~/services/ev-calculator";
|
||||||
|
|
||||||
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -125,8 +133,82 @@ const PARTICIPANT_ROWS = AFL_TEAMS.map((name, i) => ({
|
||||||
|
|
||||||
const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id);
|
const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the playoff_matches rows generateAFL10Bracket writes, seeded with `seedIds` in
|
||||||
|
* ladder order (index 0 = minor premier). `completed` overrides individual matches with a
|
||||||
|
* recorded result.
|
||||||
|
*/
|
||||||
|
function aflBracketMatches(
|
||||||
|
seedIds: string[],
|
||||||
|
completed: Array<{ round: string; matchNumber: number; winnerId: string; loserId: string }> = []
|
||||||
|
): BracketMatch[] {
|
||||||
|
const seed = (n: number) => seedIds[n - 1] ?? null;
|
||||||
|
const rows: BracketMatch[] = [
|
||||||
|
{ round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
|
||||||
|
{ round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 1, participant1Id: seed(1), participant2Id: seed(4) },
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 2, participant1Id: seed(2), participant2Id: seed(3) },
|
||||||
|
// participant2 is TBD until a Wildcard winner advances into it.
|
||||||
|
{ round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
|
||||||
|
{ round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
|
||||||
|
{ round: "Semi-Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||||||
|
{ round: "Semi-Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
|
||||||
|
{ round: "Preliminary Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||||||
|
{ round: "Preliminary Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
|
||||||
|
{ round: "Grand Final", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||||||
|
].map((m) => ({ ...m, winnerId: null, loserId: null, isComplete: false }));
|
||||||
|
|
||||||
|
for (const done of completed) {
|
||||||
|
const row = rows.find((r) => r.round === done.round && r.matchNumber === done.matchNumber);
|
||||||
|
if (!row) throw new Error(`no such match: ${done.round} #${done.matchNumber}`);
|
||||||
|
row.isComplete = true;
|
||||||
|
row.winnerId = done.winnerId;
|
||||||
|
row.loserId = done.loserId;
|
||||||
|
// A Wildcard winner is advanced into the Elimination Final it feeds.
|
||||||
|
if (done.round === "Wildcard Round") {
|
||||||
|
const ef = rows.find(
|
||||||
|
(r) => r.round === "Elimination Finals" && r.matchNumber === (done.matchNumber === 1 ? 2 : 1)
|
||||||
|
);
|
||||||
|
if (ef) ef.participant2Id = done.winnerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one bracket row for a round/match, failing loudly if the fixture changes shape. */
|
||||||
|
function matchIn(matches: BracketMatch[], round: string, matchNumber: number): BracketMatch {
|
||||||
|
const found = matches.find((m) => m.round === round && m.matchNumber === matchNumber);
|
||||||
|
if (!found) throw new Error(`no such match: ${round} #${matchNumber}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up one participant's result, failing loudly rather than silently passing on undefined. */
|
||||||
|
function resultFor<T extends { participantId: string }>(results: T[], participantId: string): T {
|
||||||
|
const found = results.find((r) => r.participantId === participantId);
|
||||||
|
if (!found) throw new Error(`no simulation result for ${participantId}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EV on the reference scale the runner persists with. */
|
||||||
|
function evOf(result: { probabilities: ProbabilityDistribution }): number {
|
||||||
|
return calculateEV(result.probabilities, DEFAULT_SCORING_RULES);
|
||||||
|
}
|
||||||
|
|
||||||
describe("AFLSimulator.simulate()", () => {
|
describe("AFLSimulator.simulate()", () => {
|
||||||
let mockDb: { select: MockInstance };
|
let mockDb: {
|
||||||
|
select: MockInstance;
|
||||||
|
query: {
|
||||||
|
scoringEvents: { findMany: MockInstance };
|
||||||
|
playoffMatches: { findMany: MockInstance };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Put a seeded afl_10 bracket in front of the simulator. */
|
||||||
|
function seedBracket(matches: BracketMatch[]) {
|
||||||
|
mockDb.query.scoringEvents.findMany.mockResolvedValue([{ id: "event-1" }]);
|
||||||
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { database } = await import("~/database/context");
|
const { database } = await import("~/database/context");
|
||||||
|
|
@ -136,6 +218,11 @@ describe("AFLSimulator.simulate()", () => {
|
||||||
|
|
||||||
let selectCallCount = 0;
|
let selectCallCount = 0;
|
||||||
mockDb = {
|
mockDb = {
|
||||||
|
// Default: no bracket generated yet, so the ladder-projection path runs.
|
||||||
|
query: {
|
||||||
|
scoringEvents: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
},
|
||||||
select: vi.fn().mockImplementation(() => {
|
select: vi.fn().mockImplementation(() => {
|
||||||
selectCallCount++;
|
selectCallCount++;
|
||||||
if (selectCallCount === 1) {
|
if (selectCallCount === 1) {
|
||||||
|
|
@ -355,4 +442,184 @@ describe("AFLSimulator.simulate()", () => {
|
||||||
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
|
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
|
||||||
expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst);
|
expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Bracket-aware mode ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// afl_10 banks points on seeding alone (entryFloor 5 for seeds 1-4, 7 for seeds 5-6) and
|
||||||
|
// on winning a non-scoring round (nonScoringWinnerFloor 7 for the Wildcard Round, 3 for a
|
||||||
|
// Qualifying Final). Those floors are paid out as real fantasy points, so a simulator that
|
||||||
|
// re-draws the ladder every iteration — putting a seeded team back in the Wildcard Round or
|
||||||
|
// out of the finals, where it scores 0 — reports an EV below points already awarded. Each
|
||||||
|
// EV assertion below is that floor.
|
||||||
|
|
||||||
|
describe("bracket-aware mode", () => {
|
||||||
|
/**
|
||||||
|
* Seeds 1-10 in ladder order, drawn from the ten *weakest* clubs by Elo. Seeding the
|
||||||
|
* strongest ten would let the ladder-projection path produce much the same field by
|
||||||
|
* accident, so the floor assertions below would pass even with the bracket ignored.
|
||||||
|
*/
|
||||||
|
const SEEDS = PARTICIPANT_IDS.slice(8);
|
||||||
|
|
||||||
|
it("never values a seed below the entry floor its seeding already banked", async () => {
|
||||||
|
seedBracket(aflBracketMatches(SEEDS));
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
// Seeds 1-4 enter a Qualifying Final: lose it, lose the Semi-Final, still 5th-6th (25).
|
||||||
|
for (const seed of [1, 2, 3, 4]) {
|
||||||
|
expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(25);
|
||||||
|
}
|
||||||
|
// Seeds 5-6 enter an Elimination Final: lose it and they are 7th-8th (15).
|
||||||
|
for (const seed of [5, 6]) {
|
||||||
|
expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(15);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a Qualifying Final entrant out of the 7th-8th tier entirely", async () => {
|
||||||
|
seedBracket(aflBracketMatches(SEEDS));
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
// A seed 1-4 loses the QF into a Semi-Final, so 5th-6th is its worst finish. The
|
||||||
|
// 7th-8th tier is reachable only by losing an Elimination Final.
|
||||||
|
for (const seed of [1, 2, 3, 4]) {
|
||||||
|
expect(resultFor(results, SEEDS[seed - 1]).probabilities.probSeventh, `seed ${seed}`).toBe(0);
|
||||||
|
}
|
||||||
|
// Seeds 5-10 all reach an Elimination Final only by playing one, so they can.
|
||||||
|
expect(resultFor(results, SEEDS[4]).probabilities.probSeventh).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the bracket's draw rather than a re-projected ladder", async () => {
|
||||||
|
// Deliberately inverted: the weakest club is the minor premier and the strongest
|
||||||
|
// scrapes in 10th. On the ladder-projection path Elo decides the seeding, so this only
|
||||||
|
// holds if the bracket's own slots are being read.
|
||||||
|
const inverted = [
|
||||||
|
"team-18", "team-17", "team-16", "team-15", "team-14",
|
||||||
|
"team-13", "team-12", "team-11", "team-10", "team-1",
|
||||||
|
];
|
||||||
|
seedBracket(aflBracketMatches(inverted));
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
// West Coast (weakest Elo) is seeded 1, so it holds the double chance and can never
|
||||||
|
// finish 7th-8th, and its EV clears the seed 1-4 floor.
|
||||||
|
expect(resultFor(results, "team-18").probabilities.probSeventh).toBe(0);
|
||||||
|
expect(evOf(resultFor(results, "team-18"))).toBeGreaterThanOrEqual(25);
|
||||||
|
|
||||||
|
// Western Bulldogs (strongest Elo) is seeded 10, so it starts in the Wildcard Round
|
||||||
|
// with nothing banked and can be knocked out for 0.
|
||||||
|
expect(resultFor(results, "team-1").probabilities.probSeventh).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeroes every participant outside the bracket", async () => {
|
||||||
|
seedBracket(aflBracketMatches(SEEDS));
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
for (const r of results.filter((x) => !SEEDS.includes(x.participantId))) {
|
||||||
|
expect(evOf(r), r.participantId).toBe(0);
|
||||||
|
}
|
||||||
|
expect(results).toHaveLength(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still normalizes every column to 1.0 and the field to 340 total EV", async () => {
|
||||||
|
seedBracket(aflBracketMatches(SEEDS));
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
const keys = [
|
||||||
|
"probFirst", "probSecond", "probThird", "probFourth",
|
||||||
|
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||||
|
] as const;
|
||||||
|
for (const key of keys) {
|
||||||
|
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||||
|
expect(colSum, `${key} column sum`).toBeCloseTo(1.0, 6);
|
||||||
|
}
|
||||||
|
expect(results.reduce((s, r) => s + evOf(r), 0)).toBeCloseTo(340, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replays a completed Wildcard Round instead of re-simulating it", async () => {
|
||||||
|
// Seed 10 beat seed 7, which banks seed 10 a 7th-place floor (15 points).
|
||||||
|
seedBracket(
|
||||||
|
aflBracketMatches(SEEDS, [
|
||||||
|
{ round: "Wildcard Round", matchNumber: 1, winnerId: SEEDS[9], loserId: SEEDS[6] },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
expect(evOf(resultFor(results, SEEDS[9]))).toBeGreaterThanOrEqual(15);
|
||||||
|
// The loser is out with nothing, in every iteration.
|
||||||
|
expect(evOf(resultFor(results, SEEDS[6]))).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replays a completed Qualifying Final, banking the winner's 3rd-4th floor", async () => {
|
||||||
|
// Seed 1 beat seed 4: the winner byes into a Preliminary Final (floor 3rd, 45 points)
|
||||||
|
// and the loser drops into a Semi-Final (floor 5th, 25 points).
|
||||||
|
seedBracket(
|
||||||
|
aflBracketMatches(SEEDS, [
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 1, winnerId: SEEDS[0], loserId: SEEDS[3] },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
const winner = resultFor(results, SEEDS[0]);
|
||||||
|
expect(evOf(winner)).toBeGreaterThanOrEqual(45);
|
||||||
|
// Already through to a Preliminary Final, so the 5th-6th tier is behind it.
|
||||||
|
expect(winner.probabilities.probFifth).toBe(0);
|
||||||
|
|
||||||
|
expect(evOf(resultFor(results, SEEDS[3]))).toBeGreaterThanOrEqual(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the ladder projection when the bracket carries no seeds", async () => {
|
||||||
|
seedBracket(aflBracketMatches([]));
|
||||||
|
const results = await new AFLSimulator().simulate("season-1");
|
||||||
|
|
||||||
|
// Every club is back in contention, so nobody is structurally zeroed.
|
||||||
|
expect(results.filter((r) => evOf(r) > 0).length).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── readAflBracketSeeds ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("readAflBracketSeeds", () => {
|
||||||
|
const teamsById = new Map(
|
||||||
|
PARTICIPANT_IDS.map((id) => [id, { id, name: id, elo: 1500, currentWins: 0, remainingGames: 0, winProb: 0.5 }])
|
||||||
|
);
|
||||||
|
const SEEDS = PARTICIPANT_IDS.slice(0, 10);
|
||||||
|
|
||||||
|
it("returns null when there is no bracket at all", () => {
|
||||||
|
expect(readAflBracketSeeds([], teamsById as never)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a generated but unseeded bracket", () => {
|
||||||
|
expect(readAflBracketSeeds(aflBracketMatches([]), teamsById as never)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the 10 seeds in ladder order", () => {
|
||||||
|
const bracket = readAflBracketSeeds(aflBracketMatches(SEEDS), teamsById as never);
|
||||||
|
expect(bracket?.seeds.map((t) => t.id)).toEqual(SEEDS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat the TBD Elimination Final slots as missing seeds", () => {
|
||||||
|
const matches = aflBracketMatches(SEEDS);
|
||||||
|
for (const m of matches.filter((r) => r.round === "Elimination Finals")) {
|
||||||
|
expect(m.participant2Id).toBeNull();
|
||||||
|
}
|
||||||
|
expect(readAflBracketSeeds(matches, teamsById as never)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a partially seeded bracket rather than discarding the draw", () => {
|
||||||
|
const matches = aflBracketMatches(SEEDS);
|
||||||
|
// ON DELETE SET NULL empties a slot when a participant is removed and re-added.
|
||||||
|
matchIn(matches, "Qualifying Finals", 1).participant2Id = null;
|
||||||
|
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/partially seeded.*seed\(s\) 4/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when one participant holds two slots", () => {
|
||||||
|
const matches = aflBracketMatches(SEEDS);
|
||||||
|
matchIn(matches, "Wildcard Round", 1).participant2Id = SEEDS[0];
|
||||||
|
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/more than one slot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the bracket references a participant outside the season", () => {
|
||||||
|
const matches = aflBracketMatches(SEEDS);
|
||||||
|
matchIn(matches, "Wildcard Round", 1).participant2Id = "ghost";
|
||||||
|
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/not in this sports season/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,36 @@
|
||||||
*
|
*
|
||||||
* Monte Carlo simulation of the AFL regular season and finals for 2026.
|
* Monte Carlo simulation of the AFL regular season and finals for 2026.
|
||||||
*
|
*
|
||||||
|
* Two modes:
|
||||||
|
* 1. Pre-bracket mode: no afl_10 bracket exists yet, or it carries no seeds. The ladder is
|
||||||
|
* re-projected from Elo every iteration and its top 10 are seeded 1-10, so the draw is
|
||||||
|
* modelled as still uncertain.
|
||||||
|
* 2. Bracket-aware mode: a seeded afl_10 bracket exists. Its slots are the seeding, fixed
|
||||||
|
* across every iteration, and games already played are replayed from their recorded
|
||||||
|
* result instead of being re-simulated.
|
||||||
|
*
|
||||||
|
* Bracket-aware mode is what makes a banked floor hold. afl_10 is the only template that
|
||||||
|
* awards points on seeding alone (entryFloor: seeds 1-4 bank 5th, seeds 5-6 bank 7th), and a
|
||||||
|
* simulator that re-draws the ladder every iteration puts those teams back in the Wildcard
|
||||||
|
* Round — or out of the finals entirely — where they score 0, pulling EV below points the
|
||||||
|
* league has already paid out. Reading the real draw removes that by construction: a team
|
||||||
|
* seeded into an Elimination Final is in that game in 100% of iterations, so its worst
|
||||||
|
* outcome is the 7th-8th tier.
|
||||||
|
*
|
||||||
* Algorithm:
|
* Algorithm:
|
||||||
* 1. Load all participants for the sports season from DB
|
* 1. Load all participants for the sports season from DB
|
||||||
* 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
|
* 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
|
||||||
* Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
|
* Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
|
||||||
* 3. Load current regular season standings (wins, gamesPlayed) — if available
|
* 3. Load current regular season standings (wins, gamesPlayed) — if available
|
||||||
* 4. For each simulation:
|
* 4. Load the afl_10 bracket, if one has been generated, for its draw and results so far
|
||||||
* a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed)
|
* 5. For each simulation:
|
||||||
* using Elo win probability vs. an average opponent (Elo 1500)
|
* a. Pre-bracket mode only: for each team, simulate remaining regular season games
|
||||||
* → projectedPoints = currentWins*4 + simulatedRemainingWins*4
|
* (TOTAL_GAMES - gamesPlayed) using Elo win probability vs. an average opponent
|
||||||
* b. Sort all 18 teams by projected points desc + random tiebreaker → final ladder
|
* (Elo 1500) → projectedPoints = currentWins*4 + simulatedRemainingWins*4
|
||||||
* → Top 10 advance to the AFL Finals Series
|
* b. Pre-bracket mode only: sort all 18 teams by projected points desc + random
|
||||||
* c. Simulate AFL Finals Series (AFL_10 bracket):
|
* tiebreaker → final ladder → top 10 advance to the AFL Finals Series.
|
||||||
|
* In bracket-aware mode the bracket's own 10 seeds are used as-is.
|
||||||
|
* c. Simulate the AFL Finals Series (AFL_10 bracket), replaying any completed match:
|
||||||
*
|
*
|
||||||
* Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts)
|
* Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts)
|
||||||
* Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye)
|
* Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye)
|
||||||
|
|
@ -24,8 +42,8 @@
|
||||||
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
||||||
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
||||||
*
|
*
|
||||||
* 5. Track placement counts per scoring tier
|
* 6. Track placement counts per scoring tier
|
||||||
* 6. Convert counts to probability distributions
|
* 7. Convert counts to probability distributions
|
||||||
*
|
*
|
||||||
* Win probability (Elo, PARITY_FACTOR = 450):
|
* Win probability (Elo, PARITY_FACTOR = 450):
|
||||||
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 450))
|
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 450))
|
||||||
|
|
@ -53,7 +71,7 @@
|
||||||
* probFifth/Sixth = Semi-Finals losers (2 per sim — split evenly)
|
* probFifth/Sixth = Semi-Finals losers (2 per sim — split evenly)
|
||||||
* probSeventh/Eighth = Elimination Finals losers (2 per sim — split evenly)
|
* probSeventh/Eighth = Elimination Finals losers (2 per sim — split evenly)
|
||||||
* Wildcard losers → all 0 (score 0 points, same as 9th/10th)
|
* Wildcard losers → all 0 (score 0 points, same as 9th/10th)
|
||||||
* Missed finals → all 0
|
* Missed finals → all 0 (in bracket-aware mode, every team outside the bracket)
|
||||||
*
|
*
|
||||||
* NOTE: AFL uses the AFL_10 bracket template which splits the 5–8 tier into two
|
* NOTE: AFL uses the AFL_10 bracket template which splits the 5–8 tier into two
|
||||||
* separate pairs (5/6 and 7/8). This is already handled by scoring-rules.ts
|
* separate pairs (5/6 and 7/8). This is already handled by scoring-rules.ts
|
||||||
|
|
@ -62,7 +80,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
|
|
@ -75,6 +93,9 @@ import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
||||||
|
|
||||||
|
/** The bracket template the AFL finals are scored against. */
|
||||||
|
const AFL_TEMPLATE_ID = "afl_10";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Elo parity factor for AFL single-game win probability.
|
* Elo parity factor for AFL single-game win probability.
|
||||||
* 450 reflects moderate variance — lower than NHL (1000) to account for
|
* 450 reflects moderate variance — lower than NHL (1000) to account for
|
||||||
|
|
@ -192,6 +213,226 @@ function simulateProjectedWins(entry: TeamEntry): number {
|
||||||
return entry.currentWins + extra;
|
return entry.currentWins + extra;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The playoff_matches columns the simulator actually reads. */
|
||||||
|
export type BracketMatch = Pick<
|
||||||
|
typeof schema.playoffMatches.$inferSelect,
|
||||||
|
"round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete"
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface LoadedBracket {
|
||||||
|
/** The 10 finalists in seed order — index 0 is the minor premier. */
|
||||||
|
seeds: TeamEntry[];
|
||||||
|
/** Every bracket match, keyed by `${round}#${matchNumber}`. */
|
||||||
|
matches: Map<string, BracketMatch>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays one finals game. `round`/`matchNumber` identify it within the bracket so an
|
||||||
|
* already-played result can be looked up; `t1`/`t2` are the teams routed into it.
|
||||||
|
*/
|
||||||
|
type PlayGame = (
|
||||||
|
round: string,
|
||||||
|
matchNumber: number,
|
||||||
|
t1: TeamEntry,
|
||||||
|
t2: TeamEntry
|
||||||
|
) => { winner: TeamEntry; loser: TeamEntry };
|
||||||
|
|
||||||
|
function matchKey(round: string, matchNumber: number): string {
|
||||||
|
return `${round}#${matchNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simGame(t1: TeamEntry, t2: TeamEntry, parityFactor: number): { winner: TeamEntry; loser: TeamEntry } {
|
||||||
|
return Math.random() < eloWinProbability(t1.elo, t2.elo, parityFactor)
|
||||||
|
? { winner: t1, loser: t2 }
|
||||||
|
: { winner: t2, loser: t1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where generateAFL10Bracket (models/playoff-match.ts) writes each seed.
|
||||||
|
*
|
||||||
|
* The two Elimination Final participant2 slots are deliberately absent: they are TBD by
|
||||||
|
* design until a Wildcard winner advances into them, so they are never a missing seed.
|
||||||
|
* That leaves exactly 10 named slots for the 10 finalists.
|
||||||
|
*/
|
||||||
|
const SEED_SLOTS: ReadonlyArray<{ round: string; matchNumber: number; slot: 1 | 2; seed: number }> = [
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 1, slot: 1, seed: 1 },
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 2, slot: 1, seed: 2 },
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 2, slot: 2, seed: 3 },
|
||||||
|
{ round: "Qualifying Finals", matchNumber: 1, slot: 2, seed: 4 },
|
||||||
|
{ round: "Elimination Finals", matchNumber: 1, slot: 1, seed: 5 },
|
||||||
|
{ round: "Elimination Finals", matchNumber: 2, slot: 1, seed: 6 },
|
||||||
|
{ round: "Wildcard Round", matchNumber: 1, slot: 1, seed: 7 },
|
||||||
|
{ round: "Wildcard Round", matchNumber: 2, slot: 1, seed: 8 },
|
||||||
|
{ round: "Wildcard Round", matchNumber: 2, slot: 2, seed: 9 },
|
||||||
|
{ round: "Wildcard Round", matchNumber: 1, slot: 2, seed: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the seeded afl_10 bracket for this season, if there is one.
|
||||||
|
*
|
||||||
|
* Returns null only when the bracket carries no draw at all — no matches, or a freshly
|
||||||
|
* generated bracket with every slot still empty — in which case the caller falls back to
|
||||||
|
* projecting the ladder.
|
||||||
|
*
|
||||||
|
* A *partially* seeded bracket is an error rather than a fallback. Falling back there would
|
||||||
|
* throw away the real draw and every recorded result with it, putting eliminated teams back
|
||||||
|
* in contention; and it is reachable in practice, because playoff_matches.participant1Id /
|
||||||
|
* participant2Id are ON DELETE SET NULL, so removing and re-adding one participant
|
||||||
|
* mid-finals empties a slot. A duplicated or unknown participant fails loudly for the same
|
||||||
|
* reason.
|
||||||
|
*/
|
||||||
|
export function readAflBracketSeeds(
|
||||||
|
matches: BracketMatch[],
|
||||||
|
teamsById: Map<string, TeamEntry>
|
||||||
|
): LoadedBracket | null {
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m]));
|
||||||
|
|
||||||
|
const drawn = SEED_SLOTS.map(({ round, matchNumber, slot }) => {
|
||||||
|
const match = byKey.get(matchKey(round, matchNumber));
|
||||||
|
if (!match) return null;
|
||||||
|
return (slot === 1 ? match.participant1Id : match.participant2Id) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const seededCount = drawn.filter((id) => id !== null).length;
|
||||||
|
|
||||||
|
// Generated but not yet filled in — no draw to honor.
|
||||||
|
if (seededCount === 0) return null;
|
||||||
|
|
||||||
|
if (seededCount < drawn.length) {
|
||||||
|
const missing = SEED_SLOTS.filter((_, i) => drawn[i] === null)
|
||||||
|
.map((s) => s.seed)
|
||||||
|
.toSorted((a, b) => a - b)
|
||||||
|
.join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`AFL bracket is only partially seeded (${seededCount} of ${drawn.length} slots filled; ` +
|
||||||
|
`missing seed(s) ${missing}). Re-seed the bracket in Admin → Bracket before simulating; ` +
|
||||||
|
`simulating around the gap would discard the draw and every recorded result.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filled by seed number below; SEED_SLOTS covers seeds 1-10 exactly once each.
|
||||||
|
const seeds: TeamEntry[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < SEED_SLOTS.length; i++) {
|
||||||
|
const participantId = drawn[i] as string;
|
||||||
|
if (seen.has(participantId)) {
|
||||||
|
throw new Error(`AFL bracket seeds participant ${participantId} into more than one slot.`);
|
||||||
|
}
|
||||||
|
seen.add(participantId);
|
||||||
|
|
||||||
|
const team = teamsById.get(participantId);
|
||||||
|
if (!team) {
|
||||||
|
throw new Error(
|
||||||
|
`AFL bracket references participant ${participantId}, which is not in this sports season.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seeds[SEED_SLOTS[i].seed - 1] = team;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { seeds, matches: byKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recorded loser of a completed match. loserId is written by the scoring flow, but fall
|
||||||
|
* back to "whichever slot isn't the winner" for older rows.
|
||||||
|
*/
|
||||||
|
function completedLoser(match: BracketMatch): string | null {
|
||||||
|
if (match.loserId) return match.loserId;
|
||||||
|
if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id;
|
||||||
|
if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the game-playing function for a bracket.
|
||||||
|
*
|
||||||
|
* When the bracket has a completed result for a game AND that result is between the two teams
|
||||||
|
* the simulation routed into it, the recorded winner is used verbatim — that is what makes an
|
||||||
|
* already-played result stick across all iterations, and what stops a banked floor from being
|
||||||
|
* re-litigated at 50/50. Anything else is simulated. The pair check keeps a corrupt or
|
||||||
|
* out-of-order row from desynchronising the rest of the bracket.
|
||||||
|
*/
|
||||||
|
export function makePlayGame(bracket: LoadedBracket | null, parityFactor: number): PlayGame {
|
||||||
|
if (!bracket) {
|
||||||
|
return (_round, _matchNumber, t1, t2) => simGame(t1, t2, parityFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (round, matchNumber, t1, t2) => {
|
||||||
|
const match = bracket.matches.get(matchKey(round, matchNumber));
|
||||||
|
if (match?.isComplete && match.winnerId) {
|
||||||
|
const loserId = completedLoser(match);
|
||||||
|
const arrived = [t1.id, t2.id];
|
||||||
|
if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) {
|
||||||
|
return match.winnerId === t1.id ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return simGame(t1, t2, parityFactor);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate the AFL Finals Series from a seeded list of 10 teams.
|
||||||
|
*
|
||||||
|
* Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a
|
||||||
|
* recorded result is looked up against the game it was actually played in:
|
||||||
|
* SF1 = QF1 loser v EF2 winner, SF2 = QF2 loser v EF1 winner,
|
||||||
|
* PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner.
|
||||||
|
*
|
||||||
|
* Returns the placement for each team:
|
||||||
|
* "gf_winner" → 1st
|
||||||
|
* "gf_loser" → 2nd
|
||||||
|
* "pf_loser" → 3rd/4th (two teams per sim)
|
||||||
|
* "sf_loser" → 5th/6th (two teams per sim)
|
||||||
|
* "ef_loser" → 7th/8th (two teams per sim)
|
||||||
|
* "wc_loser" → 9th/10th (zero scoring points)
|
||||||
|
*/
|
||||||
|
export function simAFLFinals(
|
||||||
|
finalists: TeamEntry[],
|
||||||
|
play: PlayGame
|
||||||
|
): {
|
||||||
|
gfWinner: TeamEntry;
|
||||||
|
gfLoser: TeamEntry;
|
||||||
|
pfLosers: [TeamEntry, TeamEntry];
|
||||||
|
sfLosers: [TeamEntry, TeamEntry];
|
||||||
|
efLosers: [TeamEntry, TeamEntry];
|
||||||
|
} {
|
||||||
|
const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists;
|
||||||
|
|
||||||
|
// Wildcard Round: #7 vs #10, #8 vs #9
|
||||||
|
const wc1 = play("Wildcard Round", 1, s7, s10);
|
||||||
|
const wc2 = play("Wildcard Round", 2, s8, s9);
|
||||||
|
|
||||||
|
// Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get a bye to a PF)
|
||||||
|
const qf1 = play("Qualifying Finals", 1, s1, s4);
|
||||||
|
const qf2 = play("Qualifying Finals", 2, s2, s3);
|
||||||
|
|
||||||
|
// Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner
|
||||||
|
const ef1 = play("Elimination Finals", 1, s5, wc2.winner);
|
||||||
|
const ef2 = play("Elimination Finals", 2, s6, wc1.winner);
|
||||||
|
|
||||||
|
// Semi-Finals: QF losers (second chance) vs EF winners
|
||||||
|
const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner);
|
||||||
|
const sf2 = play("Semi-Finals", 2, qf2.loser, ef1.winner);
|
||||||
|
|
||||||
|
// Preliminary Finals: QF winners vs SF winners
|
||||||
|
const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);
|
||||||
|
const pf2 = play("Preliminary Finals", 2, qf2.winner, sf1.winner);
|
||||||
|
|
||||||
|
// Grand Final
|
||||||
|
const gf = play("Grand Final", 1, pf1.winner, pf2.winner);
|
||||||
|
|
||||||
|
return {
|
||||||
|
gfWinner: gf.winner,
|
||||||
|
gfLoser: gf.loser,
|
||||||
|
pfLosers: [pf1.loser, pf2.loser],
|
||||||
|
sfLosers: [sf1.loser, sf2.loser],
|
||||||
|
efLosers: [ef1.loser, ef2.loser],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class AFLSimulator implements Simulator {
|
export class AFLSimulator implements Simulator {
|
||||||
|
|
@ -270,11 +511,34 @@ export class AFLSimulator implements Simulator {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
const teamsById = new Map(teams.map((t) => [t.id, t]));
|
||||||
|
|
||||||
/** Simulate a single AFL game. Returns the winner. */
|
// 4. Load the real bracket (draw + results so far), if one has been generated.
|
||||||
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
// Events are filtered on bracketTemplateId rather than eventType and taken most
|
||||||
Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b;
|
// recent first, matching getBracketTemplateIdsForSportsSeasons: a season can own
|
||||||
|
// several events, and landing on a stale or template-less row would silently
|
||||||
|
// discard the real draw and every recorded result. createdAt can tie when a bracket
|
||||||
|
// is generated alongside a sibling event, so id breaks the tie.
|
||||||
|
const playoffEvents = await db.query.scoringEvents.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||||
|
eq(schema.scoringEvents.bracketTemplateId, AFL_TEMPLATE_ID)
|
||||||
|
),
|
||||||
|
columns: { id: true },
|
||||||
|
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
|
||||||
|
});
|
||||||
|
const bracketEvent = playoffEvents[0];
|
||||||
|
|
||||||
|
const bracketMatches = bracketEvent
|
||||||
|
? await db.query.playoffMatches.findMany({
|
||||||
|
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const bracket = readAflBracketSeeds(bracketMatches, teamsById);
|
||||||
|
const play = makePlayGame(bracket, parityFactor);
|
||||||
|
|
||||||
|
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project end-of-season ladder and return the top 10 finalists seeded 1–10.
|
* Project end-of-season ladder and return the top 10 finalists seeded 1–10.
|
||||||
|
|
@ -293,70 +557,7 @@ export class AFLSimulator implements Simulator {
|
||||||
return projected.slice(0, 10).map((x) => x.team);
|
return projected.slice(0, 10).map((x) => x.team);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// 5. Integer placement count maps — initialized to 0 for all participants.
|
||||||
* Simulate the AFL Finals Series from a seeded list of 10 teams.
|
|
||||||
*
|
|
||||||
* Returns the placement for each team:
|
|
||||||
* "gf_winner" → 1st
|
|
||||||
* "gf_loser" → 2nd
|
|
||||||
* "pf_loser" → 3rd/4th (two teams per sim)
|
|
||||||
* "sf_loser" → 5th/6th (two teams per sim)
|
|
||||||
* "ef_loser" → 7th/8th (two teams per sim)
|
|
||||||
* "wc_loser" → 9th/10th (zero scoring points)
|
|
||||||
*/
|
|
||||||
const simAFLFinals = (
|
|
||||||
finalists: TeamEntry[]
|
|
||||||
): {
|
|
||||||
gfWinner: TeamEntry;
|
|
||||||
gfLoser: TeamEntry;
|
|
||||||
pfLosers: [TeamEntry, TeamEntry];
|
|
||||||
sfLosers: [TeamEntry, TeamEntry];
|
|
||||||
efLosers: [TeamEntry, TeamEntry];
|
|
||||||
} => {
|
|
||||||
const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists;
|
|
||||||
|
|
||||||
// Wildcard Round: #7 vs #10, #8 vs #9
|
|
||||||
const wc1Winner = simGame(s7, s10);
|
|
||||||
const wc2Winner = simGame(s8, s9);
|
|
||||||
|
|
||||||
// Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get bye to PF)
|
|
||||||
const qf1Winner = simGame(s1, s4);
|
|
||||||
const qf1Loser = qf1Winner === s1 ? s4 : s1;
|
|
||||||
const qf2Winner = simGame(s2, s3);
|
|
||||||
const qf2Loser = qf2Winner === s2 ? s3 : s2;
|
|
||||||
|
|
||||||
// Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner
|
|
||||||
const ef1Winner = simGame(s5, wc2Winner);
|
|
||||||
const ef1Loser = ef1Winner === s5 ? wc2Winner : s5;
|
|
||||||
const ef2Winner = simGame(s6, wc1Winner);
|
|
||||||
const ef2Loser = ef2Winner === s6 ? wc1Winner : s6;
|
|
||||||
|
|
||||||
// Semi-Finals: QF losers (2nd chance) vs EF winners
|
|
||||||
const sf1Winner = simGame(qf1Loser, ef2Winner);
|
|
||||||
const sf1Loser = sf1Winner === qf1Loser ? ef2Winner : qf1Loser;
|
|
||||||
const sf2Winner = simGame(qf2Loser, ef1Winner);
|
|
||||||
const sf2Loser = sf2Winner === qf2Loser ? ef1Winner : qf2Loser;
|
|
||||||
|
|
||||||
// Preliminary Finals: QF winners vs SF winners
|
|
||||||
const pf1Winner = simGame(qf1Winner, sf2Winner);
|
|
||||||
const pf1Loser = pf1Winner === qf1Winner ? sf2Winner : qf1Winner;
|
|
||||||
const pf2Winner = simGame(qf2Winner, sf1Winner);
|
|
||||||
const pf2Loser = pf2Winner === qf2Winner ? sf1Winner : qf2Winner;
|
|
||||||
|
|
||||||
// Grand Final
|
|
||||||
const gfWinner = simGame(pf1Winner, pf2Winner);
|
|
||||||
const gfLoser = gfWinner === pf1Winner ? pf2Winner : pf1Winner;
|
|
||||||
|
|
||||||
return {
|
|
||||||
gfWinner,
|
|
||||||
gfLoser,
|
|
||||||
pfLosers: [pf1Loser, pf2Loser ],
|
|
||||||
sfLosers: [sf1Loser, sf2Loser ],
|
|
||||||
efLosers: [ef1Loser, ef2Loser ],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. Integer placement count maps — initialized to 0 for all participants.
|
|
||||||
//
|
//
|
||||||
// AFL scoring uses the AFL_10 bracket template which splits 5–8 into two
|
// AFL scoring uses the AFL_10 bracket template which splits 5–8 into two
|
||||||
// separate pairs: Semi-Finals losers share 5th/6th (higher value), and
|
// separate pairs: Semi-Finals losers share 5th/6th (higher value), and
|
||||||
|
|
@ -368,10 +569,12 @@ export class AFLSimulator implements Simulator {
|
||||||
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
|
|
||||||
// 4. Monte Carlo simulation loop.
|
// 6. Monte Carlo simulation loop.
|
||||||
for (let s = 0; s < numSimulations; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
const finalists = buildFinalsList();
|
// With a real bracket the draw is fixed and its played games are replayed from their
|
||||||
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists);
|
// recorded result; without one the ladder is re-projected every iteration.
|
||||||
|
const finalists = bracket ? bracket.seeds : buildFinalsList();
|
||||||
|
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists, play);
|
||||||
|
|
||||||
championCounts.set(gfWinner.id, (championCounts.get(gfWinner.id) ?? 0) + 1);
|
championCounts.set(gfWinner.id, (championCounts.get(gfWinner.id) ?? 0) + 1);
|
||||||
finalistCounts.set(gfLoser.id, (finalistCounts.get(gfLoser.id) ?? 0) + 1);
|
finalistCounts.set(gfLoser.id, (finalistCounts.get(gfLoser.id) ?? 0) + 1);
|
||||||
|
|
@ -388,7 +591,7 @@ export class AFLSimulator implements Simulator {
|
||||||
// Wildcard losers and non-finalists are not counted (0 points per scoring rules).
|
// Wildcard losers and non-finalists are not counted (0 points per scoring rules).
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Convert integer counts to probability distributions.
|
// 7. Convert integer counts to probability distributions.
|
||||||
//
|
//
|
||||||
// Exact denominators guarantee column sums of 1.0 by construction:
|
// Exact denominators guarantee column sums of 1.0 by construction:
|
||||||
// probFirst/Second → / NUM_SIMULATIONS (1 per sim)
|
// probFirst/Second → / NUM_SIMULATIONS (1 per sim)
|
||||||
|
|
@ -421,8 +624,8 @@ export class AFLSimulator implements Simulator {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Per-position normalization — belt-and-suspenders guard against floating-point
|
// 8. Per-position normalization — belt-and-suspenders guard against floating-point
|
||||||
// division residuals. Columns are already near-exactly 1.0 after step 5.
|
// division residuals. Columns are already near-exactly 1.0 after step 7.
|
||||||
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
|
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
|
||||||
"probFirst", "probSecond", "probThird", "probFourth",
|
"probFirst", "probSecond", "probThird", "probFourth",
|
||||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,16 @@ 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 uses the real draw as its seeding
|
||||||
|
* and replays completed matches from their recorded result, instead of re-drawing the field
|
||||||
|
* every iteration.
|
||||||
|
*
|
||||||
|
* Only such a simulator can be safely re-run once results are in — a bracket-blind one puts
|
||||||
|
* knocked-out teams back in contention. updateProbabilitiesAfterResult reads this to decide
|
||||||
|
* whether to re-run the simulator or fall back to the generic ICM recalculation.
|
||||||
|
*/
|
||||||
|
bracketAware?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_CONFIG = {
|
const BASE_CONFIG = {
|
||||||
|
|
@ -112,7 +122,10 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["projectedWins"],
|
optionalInputs: ["projectedWins"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins"] },
|
derivableInputs: { sourceElo: ["projectedWins"] },
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
// The bracket is optional — before one exists the ladder is projected from Elo — but once
|
||||||
|
// it is drawn the simulator seeds from it and honors completed results.
|
||||||
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
epl_standings: {
|
epl_standings: {
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
|
|
@ -189,6 +202,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
// The bracket is optional — without one the draw is randomized — but once it
|
// The bracket is optional — without one the draw is randomized — but once it
|
||||||
// exists the simulator reads the real draw and honors completed results from it.
|
// exists the simulator reads the real draw and honors completed results from it.
|
||||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||||
|
bracketAware: true,
|
||||||
},
|
},
|
||||||
college_hockey_bracket: {
|
college_hockey_bracket: {
|
||||||
// College hockey blends odds into Elo internally (and also uses NPI rank,
|
// College hockey blends odds into Elo internally (and also uses NPI rank,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue