llws ev #144

Merged
chrisp merged 2 commits from claude/llws-ev-calculation-bug-30xpxg into main 2026-08-27 17:40:25 +00:00
13 changed files with 344 additions and 91 deletions
Showing only changes of commit 7ec17e417d - Show all commits

View file

@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import {
getBracketTemplateIdForSportsSeason,
getBracketTemplateIdsForSportsSeasons,
} from "../bracket-template";
/**
* A sports season can own several scoring events a bracket plus schedule events, or a
* re-created bracket alongside a stale one. Resolving the template from an arbitrary row
* is not harmless: calculateBracketPoints falls back to the flat 5th8th average when the
* template id is null, so losing "llws_20" makes a team locked into 5th6th and one
* locked into 7th8th both score 20.
*/
/**
* Minimal db stub. Applies the same filter and ordering the real query does, so the
* assertions exercise the helper's row-picking rather than re-stating the query.
*/
function makeDb(
rows: Array<{ sportsSeasonId: string; bracketTemplateId: string | null; createdAt: Date }>
) {
const findMany = vi.fn(async () =>
rows
.filter((row) => row.bracketTemplateId !== null)
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
);
return { db: { query: { scoringEvents: { findMany } } } as any, findMany };
}
describe("getBracketTemplateIdForSportsSeason", () => {
it("ignores a non-bracket event and returns the bracket event's template", async () => {
const { db } = makeDb([
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-07-01") },
]);
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
});
it("takes the most recent bracket event when a stale one is still around", async () => {
const { db } = makeDb([
{ sportsSeasonId: "ss1", bracketTemplateId: "simple_16", createdAt: new Date("2026-06-01") },
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
]);
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
});
it("returns null when the season has no bracket event", async () => {
const { db } = makeDb([
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
]);
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBeNull();
});
});
describe("getBracketTemplateIdsForSportsSeasons", () => {
it("resolves each season independently in one query", async () => {
const { db, findMany } = makeDb([
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
{ sportsSeasonId: "ss2", bracketTemplateId: "afl_10", createdAt: new Date("2026-08-02") },
{ sportsSeasonId: "ss3", bracketTemplateId: null, createdAt: new Date("2026-08-03") },
]);
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2", "ss3"], db);
expect(resolved.get("ss1")).toBe("llws_20");
expect(resolved.get("ss2")).toBe("afl_10");
expect(resolved.get("ss3")).toBeNull();
expect(findMany).toHaveBeenCalledTimes(1);
});
it("gives every requested season an entry so callers can cache the miss", async () => {
const { db } = makeDb([]);
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2"], db);
expect([...resolved.entries()]).toEqual([
["ss1", null],
["ss2", null],
]);
});
it("does not query at all for an empty season list", async () => {
const { db, findMany } = makeDb([]);
await expect(getBracketTemplateIdsForSportsSeasons([], db)).resolves.toEqual(new Map());
expect(findMany).not.toHaveBeenCalled();
});
});

View file

@ -80,6 +80,9 @@ function makeDb(
},
scoringEvents: {
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
// getBracketTemplateIdsForSportsSeasons filters to events that carry a
// template, so "no bracket template" is an empty result, not a null row.
findMany: vi.fn().mockResolvedValue([]),
},
seasonParticipantResults: {
findMany: vi.fn().mockResolvedValue(seasonResults),

View file

@ -0,0 +1,66 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { and, desc, inArray, isNotNull } from "drizzle-orm";
/**
* Resolve which bracket template a sports season's placements should be scored against.
*
* A sports season can own several scoring events a bracket plus schedule events, or a
* re-created bracket alongside a stale one and only some of them carry a
* bracketTemplateId. Picking an arbitrary row is not harmless: calculateBracketPoints
* falls back to the standard single 5th8th tier when the template id is null, which
* silently collapses the two-tier templates (llws_20, afl_10) so a team locked into
* 5th6th and one locked into 7th8th both score the flat 58 average. The 3rd/4th
* distinction that llws_20 and fifa_48 have goes the same way.
*
* So: only events that actually carry a template are considered, most recent first
* matching the "a re-created event wins over a stale one" rule the LLWS simulator uses
* when it picks its bracket event.
*
* Every requested season gets an entry, null when it has no bracket event, so callers
* can cache the negative result too.
*/
export async function getBracketTemplateIdsForSportsSeasons(
sportsSeasonIds: string[],
providedDb?: ReturnType<typeof database>
): Promise<Map<string, string | null>> {
const resolved = new Map<string, string | null>(
sportsSeasonIds.map((id) => [id, null])
);
if (sportsSeasonIds.length === 0) return resolved;
const db = providedDb || database();
const events = await db.query.scoringEvents.findMany({
where: and(
inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds),
isNotNull(schema.scoringEvents.bracketTemplateId)
),
columns: { sportsSeasonId: true, bracketTemplateId: true },
// createdAt can tie when a bracket is generated in the same transaction as a
// sibling event, so id breaks the tie and keeps the choice deterministic.
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
});
for (const event of events) {
// Ordered newest-first, so the first row seen for a season is the one to keep.
// The isNotNull filter means bracketTemplateId is set, but a mocked or partial row
// could still carry null — skip those rather than caching a null as a real answer.
if (resolved.get(event.sportsSeasonId) === null && event.bracketTemplateId) {
resolved.set(event.sportsSeasonId, event.bracketTemplateId);
}
}
return resolved;
}
/**
* Single-season form of getBracketTemplateIdsForSportsSeasons.
*/
export async function getBracketTemplateIdForSportsSeason(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<string | null> {
const resolved = await getBracketTemplateIdsForSportsSeasons([sportsSeasonId], providedDb);
return resolved.get(sportsSeasonId) ?? null;
}

View file

@ -7,6 +7,7 @@ import {
calculateBracketPoints,
calculateSharedPlacementPoints,
} from "./scoring-rules";
import { getBracketTemplateIdsForSportsSeasons } from "./bracket-template";
export async function createDraftPick(data: {
seasonId: string;
@ -175,18 +176,10 @@ export async function getDraftedParticipantsWithPoints(
}
// Batch-fetch bracket template IDs (one per sports season)
const bracketTemplateMap = new Map<string, string | null>();
if (bracketSeasonIds.size > 0) {
const events = await db.query.scoringEvents.findMany({
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]),
columns: { sportsSeasonId: true, bracketTemplateId: true },
});
for (const ev of events) {
if (!bracketTemplateMap.has(ev.sportsSeasonId)) {
bracketTemplateMap.set(ev.sportsSeasonId, ev.bracketTemplateId ?? null);
}
}
}
const bracketTemplateMap =
bracketSeasonIds.size > 0
? await getBracketTemplateIdsForSportsSeasons([...bracketSeasonIds], db)
: new Map<string, string | null>();
// Batch-fetch QP totals for qualifying_points participants
const qpMap = new Map<string, number>(); // participantId → totalQP

View file

@ -16,6 +16,7 @@ import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-
import { getUserDisplayName } from "~/models/user";
import { findDiscordIdsByUserIds } from "~/models/account";
import { createDailySnapshot } from "~/models/standings";
import { getBracketTemplateIdForSportsSeason } from "~/models/bracket-template";
import { recordMatchScoreEvents } from "~/models/team-score-events";
import { logger } from "~/lib/logger";
import { getEventResults } from "./event-result";
@ -1465,11 +1466,7 @@ export async function calculateTeamScore(
if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId) ?? null;
}
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId;
}
@ -1578,11 +1575,7 @@ export async function calculateTeamProjectedScore(
if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId) ?? null;
}
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId;
}

View file

@ -5,6 +5,7 @@ import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
import { logger } from "~/lib/logger";
import { getParticipantEV } from "./participant-expected-value";
import { getBracketTemplateIdForSportsSeason } from "./bracket-template";
import { calculateEV } from "~/services/ev-calculator";
// Re-export types from shared types file
@ -163,11 +164,7 @@ export async function getTeamScoreBreakdown(
if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId) ?? null;
}
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId;
}

View file

@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
/**
* The Expected Values admin page renders EV from the stored probability columns.
*
* It used to carry its own hardcoded scoring table (100/70/45/45/20/20/20/20), which
* flattened positions 58 to 20 points each. For a standard single-elimination bracket
* that was invisible all four quarterfinal losers share one tier worth
* avg(25,25,15,15) = 20 anyway but for the templates that split 58 into two tiers
* (llws_20, afl_10) it reported a team locked into 5th6th and a team locked into
* 7th8th as the same 20 points. These pin it to the shared DEFAULT_SCORING_RULES.
*/
vi.mock("../admin.sports-seasons.$id.expected-values.server", () => ({
loader: vi.fn(),
}));
import { evFromProbs } from "../admin.sports-seasons.$id.expected-values";
const ZERO = {
probFirst: "0", probSecond: "0", probThird: "0", probFourth: "0",
probFifth: "0", probSixth: "0", probSeventh: "0", probEighth: "0",
};
describe("evFromProbs", () => {
it("gives a team locked into the 5th6th tier 25 points, not 20", () => {
expect(evFromProbs({ ...ZERO, probFifth: "0.5", probSixth: "0.5" })).toBe(25);
});
it("gives a team locked into the 7th8th tier 15 points, not 20", () => {
expect(evFromProbs({ ...ZERO, probSeventh: "0.5", probEighth: "0.5" })).toBe(15);
});
it("still gives a single 5th8th tier (4 QF losers) 20 points", () => {
const ev = evFromProbs({
...ZERO,
probFifth: "0.25", probSixth: "0.25", probSeventh: "0.25", probEighth: "0.25",
});
expect(ev).toBe(20);
});
it("keeps 3rd and 4th distinct rather than a flat 45 each", () => {
expect(evFromProbs({ ...ZERO, probThird: "1" })).toBe(50);
expect(evFromProbs({ ...ZERO, probFourth: "1" })).toBe(40);
});
it("preserves the 340 total-EV invariant across a full set of unit columns", () => {
const perPosition = [
evFromProbs({ ...ZERO, probFirst: "1" }),
evFromProbs({ ...ZERO, probSecond: "1" }),
evFromProbs({ ...ZERO, probThird: "1" }),
evFromProbs({ ...ZERO, probFourth: "1" }),
evFromProbs({ ...ZERO, probFifth: "1" }),
evFromProbs({ ...ZERO, probSixth: "1" }),
evFromProbs({ ...ZERO, probSeventh: "1" }),
evFromProbs({ ...ZERO, probEighth: "1" }),
];
expect(perPosition.reduce((sum, ev) => sum + ev, 0)).toBe(340);
});
it("returns 0 for a participant with no probability mass", () => {
expect(evFromProbs(ZERO)).toBe(0);
});
});

View file

@ -6,6 +6,7 @@ import {
batchUpsertParticipantEVs,
getAllParticipantEVsForSeason
} from "~/models/participant-expected-value";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id);
@ -27,17 +28,6 @@ export async function loader({ params }: Route.LoaderArgs) {
};
}
const scoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 45,
pointsFor4th: 45,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
@ -58,7 +48,7 @@ export async function action({ request, params }: Route.ActionArgs) {
probSeventh: parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100,
probEighth: parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100,
},
scoringRules,
scoringRules: DEFAULT_SCORING_RULES,
source: "manual" as const,
}));

View file

@ -19,6 +19,8 @@ import {
TableRow,
} from "~/components/ui/table";
import { ArrowLeft, Calculator } from "lucide-react";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
import { calculateEV } from "~/services/ev-calculator";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Expected Values — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
@ -26,9 +28,18 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
export { loader };
// DEFAULT scoring values — must match DEFAULT_SCORING_RULES in the simulate route.
// Scoring: 1st=100, 2nd=70, 3rd/4th (FF losers)=45 each, 5th8th (E8 losers)=20 each.
// Sum = 100+70+45+45+20+20+20+20 = 340.
// EV is shown on the same reference scale the runner persists it with: a sports season
// is shared across leagues with different scoring, so DEFAULT_SCORING_RULES is the
// common scale and each league re-derives its own EV from the stored probabilities
// (see getPersistenceContext in services/simulations/runner.ts).
//
// Scoring: 1st=100, 2nd=70, 3rd=50, 4th=40, 5th/6th=25 each, 7th/8th=15 each.
// Sum = 100+70+50+40+25+25+15+15 = 340.
//
// The 5th8th values must stay distinct rather than collapsing to a flat 20: templates
// that split that zone into two tiers (llws_20, afl_10) put a team locked into 5th6th
// at probFifth=probSixth=0.5 (EV 25) and one locked into 7th8th at
// probSeventh=probEighth=0.5 (EV 15). A flat table reports both as 20.
//
// Total EV invariant: Σ EV across all participants = Σ scoring values = 340,
// because each probability column sums to 1.0 across all participants.
@ -36,20 +47,23 @@ export { loader };
// 1. Stale EV records from a prior simulation run (fix: re-run simulation, which now
// zeros non-bracket participants automatically)
// 2. DB precision truncation (numeric(6,4) = 4dp; max drift ≈ ±1 for 68 teams)
const SCORING = [100, 70, 45, 45, 20, 20, 20, 20] as const;
function evFromProbs(ev: {
export function evFromProbs(ev: {
probFirst: string; probSecond: string; probThird: string; probFourth: string;
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
}): number {
return parseFloat(ev.probFirst) * SCORING[0]
+ parseFloat(ev.probSecond) * SCORING[1]
+ parseFloat(ev.probThird) * SCORING[2]
+ parseFloat(ev.probFourth) * SCORING[3]
+ parseFloat(ev.probFifth) * SCORING[4]
+ parseFloat(ev.probSixth) * SCORING[5]
+ parseFloat(ev.probSeventh) * SCORING[6]
+ parseFloat(ev.probEighth) * SCORING[7];
return calculateEV(
{
probFirst: parseFloat(ev.probFirst),
probSecond: parseFloat(ev.probSecond),
probThird: parseFloat(ev.probThird),
probFourth: parseFloat(ev.probFourth),
probFifth: parseFloat(ev.probFifth),
probSixth: parseFloat(ev.probSixth),
probSeventh: parseFloat(ev.probSeventh),
probEighth: parseFloat(ev.probEighth),
},
DEFAULT_SCORING_RULES
);
}
function fmt(val: string | number) {

View file

@ -8,7 +8,8 @@ import { batchUpsertParticipantEVs } from '~/models/participant-expected-value';
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
import { calculateEV } from '~/services/ev-calculator';
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
import { recalculateStandings } from '~/models/scoring-calculator';
import { database } from '~/database/context';
import * as schema from '~/database/schema';
@ -28,17 +29,6 @@ import { useEffect, useRef, useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
const DEFAULT_SCORING_RULES: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 45,
pointsFor4th: 45,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Golf Skills — ${data?.sportsSeason?.name ?? 'Sports Season'} - Brackt Admin` }];
}

View file

@ -10,7 +10,8 @@ import {
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
import { getSurfaceElosForSeason, batchUpsertSurfaceElos } from '~/models/surface-elo';
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
import { calculateEV } from '~/services/ev-calculator';
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
import { recalculateStandings } from '~/models/scoring-calculator';
import { database } from '~/database/context';
import * as schema from '~/database/schema';
@ -30,17 +31,6 @@ import { useEffect, useRef, useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
const DEFAULT_SCORING_RULES: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 45,
pointsFor4th: 45,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Surface Elo — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}

View file

@ -20,6 +20,7 @@ import type { ProbabilityDistribution } from "./ev-calculator";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
/**
* Result of probability update operation
@ -137,18 +138,9 @@ export async function updateProbabilitiesAfterResult(
.map(r => [r.participantId, r.finalPosition ?? 0])
);
// Update finished participants
// Use default scoring rules (we only care about setting probabilities, not EV for finished)
const defaultScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
};
// 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.
@ -162,7 +154,7 @@ export async function updateProbabilitiesAfterResult(
participantId,
sportsSeasonId,
probabilities,
scoringRules: defaultScoringRules,
scoringRules: DEFAULT_SCORING_RULES,
source: 'manual', // Result is from actual outcome
});
@ -216,7 +208,7 @@ export async function updateProbabilitiesAfterResult(
participantId,
sportsSeasonId,
probabilities,
scoringRules: defaultScoringRules,
scoringRules: DEFAULT_SCORING_RULES,
source: 'futures_odds', // Recalculated from remaining odds
});

View file

@ -767,6 +767,75 @@ describe("LLWSSimulator", () => {
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
});
/**
* Play out the entire U.S. side, so two of its teams are locked into a scoring tier:
* us-7 loses Elimination Round 4 (the 7th-8th tier) and us-9 loses the Elimination
* Final (the 5th-6th tier). Every game feeding those two is recorded, which is what
* makes the results honorable makePlayGame only replays a result when the teams
* the simulation routed into the game are the pair the result was recorded between.
*
* Slot order per side is ids[0..7] into the four Opening Round games and ids[8..9]
* as the byes, so the U.S. draw is us-1 v us-2, us-3 v us-4, us-5 v us-6,
* us-7 v us-8, with us-9 and us-10 entering at Winners Round 2.
*/
function usSidePlayedOut(): PlayoffMatchRow[] {
let matches = seededBracket();
const play = (round: string, matchNumber: number, winnerId: string, loserId: string) => {
matches = completeMatch(matches, round, matchNumber, winnerId, loserId);
};
// Winners bracket
play("Opening Round", 1, "us-1", "us-2");
play("Opening Round", 2, "us-3", "us-4");
play("Opening Round", 3, "us-5", "us-6");
play("Opening Round", 4, "us-7", "us-8");
play("Winners Round 2", 1, "us-9", "us-1"); // bye us-9 v OP1 winner
play("Winners Round 2", 2, "us-10", "us-3"); // bye us-10 v OP2 winner
play("Winners Semifinals", 1, "us-5", "us-9");
play("Winners Semifinals", 2, "us-10", "us-7");
play("Winners Final", 1, "us-5", "us-10");
// Elimination bracket, including the deliberate cross-overs
play("Elimination Round 1", 1, "us-4", "us-6"); // OP2 loser v OP3 loser
play("Elimination Round 1", 2, "us-2", "us-8"); // OP1 loser v OP4 loser
play("Elimination Round 2", 1, "us-1", "us-4");
play("Elimination Round 2", 2, "us-3", "us-2");
play("Elimination Round 3", 1, "us-9", "us-3");
play("Elimination Round 3", 2, "us-7", "us-1");
play("Elimination Round 4", 1, "us-9", "us-7"); // us-7 out: 7th-8th tier
play("Elimination Final", 1, "us-10", "us-9"); // us-9 out: 5th-6th tier
return matches;
}
it("puts a team locked into the 5th-6th tier at exactly 50/50 across those two spots", async () => {
setupMockDb(defaultParticipants(), favouredEvRows, usSidePlayedOut());
const results = await new LLWSSimulator(2_000).simulate("season-1");
const locked = probsFor(results, "us-9");
// The tier is two tied positions, so its probability splits evenly across them.
// Under DEFAULT_SCORING_RULES that is 0.5 x 25 + 0.5 x 25 = 25 points of EV —
// the 5th-6th tier value, not the flat 5th-8th average of 20.
expect(locked.probFifth).toBe(0.5);
expect(locked.probSixth).toBe(0.5);
expect(locked.probSeventh).toBe(0);
expect(locked.probEighth).toBe(0);
expect(locked.probFirst + locked.probSecond + locked.probThird + locked.probFourth).toBe(0);
});
it("puts a team locked into the 7th-8th tier at exactly 50/50 across those two spots", async () => {
setupMockDb(defaultParticipants(), favouredEvRows, usSidePlayedOut());
const results = await new LLWSSimulator(2_000).simulate("season-1");
const locked = probsFor(results, "us-7");
// 0.5 x 15 + 0.5 x 15 = 15 points of EV, again distinct from the flat 20.
expect(locked.probSeventh).toBe(0.5);
expect(locked.probEighth).toBe(0.5);
expect(locked.probFifth).toBe(0);
expect(locked.probSixth).toBe(0);
expect(locked.probFirst + locked.probSecond + locked.probThird + locked.probFourth).toBe(0);
});
});
// ── Result-honoring rules ─────────────────────────────────────────────────