Compare commits

..

No commits in common. "main" and "claude/llws-ev-calculation-bug-30xpxg" have entirely different histories.

37 changed files with 264 additions and 3931 deletions

View file

@ -1,26 +1,18 @@
import * as Sentry from "@sentry/react-router";
import { PassThrough } from "node:stream";
import { logger } from "~/lib/logger";
import { shouldReportServerError } from "~/lib/error-reporting";
import type { AppLoadContext, EntryContext, HandleErrorFunction } from "react-router";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
const sentryHandleError = Sentry.createSentryHandleError({
export const handleError = Sentry.createSentryHandleError({
logErrors: true,
});
export const handleError: HandleErrorFunction = (error, args) => {
// Unrecognised URLs and methods are bot scans, not bugs. Skipping early also
// keeps them out of the `logErrors` console output; morgan still logs the request.
if (!shouldReportServerError(error, args.request)) return;
return sentryHandleError(error, args);
};
export const streamTimeout = 5_000;
async function handleRequest(

View file

@ -1,117 +0,0 @@
/**
* The AFL Wildcard winners are re-seeded into the Elimination Finals by ladder position
* (5th draws the lower-ranked winner, 6th the higher-ranked one) rather than crossing
* over from a fixed Wildcard match. These tests pin that mapping for every combination
* of results, and for either order of entry.
*/
import { describe, it, expect } from "vitest";
import {
resolveAflWildcardPlacements,
AFL_WILDCARD_DRAW,
AFL_ELIMINATION_HOSTS,
type AflWildcardResult,
} from "../afl-wildcard-reseed";
/** Both Wildcard games decided, addressed by the seed that won each. */
function bothDecided(match1Winner: 7 | 10, match2Winner: 8 | 9): AflWildcardResult[] {
return [
{ matchNumber: 1, winnerSlot: match1Winner === 7 ? 1 : 2 },
{ matchNumber: 2, winnerSlot: match2Winner === 8 ? 1 : 2 },
];
}
/** Elimination Finals match number each winning seed was sent to. */
function slotsBySeed(results: AflWildcardResult[]): Record<number, number> {
return Object.fromEntries(
resolveAflWildcardPlacements(results).map((p) => [p.seed, p.eliminationMatchNumber])
);
}
describe("AFL Wildcard draw constants", () => {
it("draws 7v10 and 8v9", () => {
expect(AFL_WILDCARD_DRAW[1]).toEqual([7, 10]);
expect(AFL_WILDCARD_DRAW[2]).toEqual([8, 9]);
});
it("hosts the Elimination Finals with seeds 5 and 6", () => {
expect(AFL_ELIMINATION_HOSTS[1]).toBe(5);
expect(AFL_ELIMINATION_HOSTS[2]).toBe(6);
});
});
describe("resolveAflWildcardPlacements", () => {
it("sends the higher-ranked winner to 6th and the lower to 5th (7 and 8 win)", () => {
expect(slotsBySeed(bothDecided(7, 8))).toEqual({ 7: 2, 8: 1 });
});
it("re-seeds when the lower seed wins the 7v10 game (10 and 8 win)", () => {
// The bug this replaces sent the 7v10 winner to 6th regardless, pairing 5th with
// 8th and handing 6th the weakest survivor.
expect(slotsBySeed(bothDecided(10, 8))).toEqual({ 8: 2, 10: 1 });
});
it("re-seeds when the lower seed wins the 8v9 game (7 and 9 win)", () => {
expect(slotsBySeed(bothDecided(7, 9))).toEqual({ 7: 2, 9: 1 });
});
it("re-seeds when both lower seeds win (10 and 9 win)", () => {
expect(slotsBySeed(bothDecided(10, 9))).toEqual({ 9: 2, 10: 1 });
});
it("places the 7v10 winner alone, since its rank is settled either way", () => {
// 7th outranks both possible 8v9 winners; 10th is outranked by both.
expect(slotsBySeed([
{ matchNumber: 1, winnerSlot: 1 },
{ matchNumber: 2, winnerSlot: null },
])).toEqual({ 7: 2 });
expect(slotsBySeed([
{ matchNumber: 1, winnerSlot: 2 },
{ matchNumber: 2, winnerSlot: null },
])).toEqual({ 10: 1 });
});
it("holds an 8v9 winner back until the 7v10 game is decided", () => {
// 8th and 9th both sit between 7th and 10th, so either slot is still possible.
expect(slotsBySeed([
{ matchNumber: 1, winnerSlot: null },
{ matchNumber: 2, winnerSlot: 1 },
])).toEqual({});
expect(slotsBySeed([
{ matchNumber: 1, winnerSlot: null },
{ matchNumber: 2, winnerSlot: 2 },
])).toEqual({});
});
it("places nothing while both games are undecided", () => {
expect(resolveAflWildcardPlacements([
{ matchNumber: 1, winnerSlot: null },
{ matchNumber: 2, winnerSlot: null },
])).toEqual([]);
});
it("gives the same answer whichever result is entered first", () => {
for (const m1 of [7, 10] as const) {
for (const m2 of [8, 9] as const) {
const final = slotsBySeed(bothDecided(m1, m2));
// Whatever a single result places must survive the second result unchanged.
const m1First = slotsBySeed([
{ matchNumber: 1, winnerSlot: m1 === 7 ? 1 : 2 },
{ matchNumber: 2, winnerSlot: null },
]);
for (const [seed, slot] of Object.entries(m1First)) {
expect(final[Number(seed)]).toBe(slot);
}
}
}
});
it("rejects a match number outside the Wildcard draw", () => {
expect(() => resolveAflWildcardPlacements([{ matchNumber: 3, winnerSlot: 1 }])).toThrow(
/Unknown AFL Wildcard Round match number 3/
);
});
});

View file

@ -1,230 +0,0 @@
import { describe, it, expect } from "vitest";
import { createStaticHandler } from "react-router";
import { shouldReportServerError } from "../error-reporting";
const ORIGIN = "https://brackt.com";
/** Shaped like the ErrorResponse React Router hands to `handleError`. */
function routeError(
status: number,
internal: boolean,
statusText = "Not Found",
) {
return {
status,
statusText,
internal,
data: `Error: No route matches URL "/blog/wp/v2/posts/999999"`,
};
}
function request(path: string, referer?: string, method = "GET") {
return new Request(`${ORIGIN}${path}`, {
method,
headers: referer ? { referer } : {},
});
}
describe("shouldReportServerError", () => {
it("drops a router 404 for a scanner hitting a URL cold", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/blog/wp/v2/posts/999999"),
),
).toBe(false);
});
it("drops a router 404 linked from another site", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/blog/", "https://evil.example/"),
),
).toBe(false);
});
it("reports a router 404 linked from one of our own pages", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/leagues/gone", `${ORIGIN}/leagues`),
),
).toBe(true);
});
it("drops the 405 from a POST to a route with no action", () => {
expect(
shouldReportServerError(
routeError(405, true, "Method Not Allowed"),
request("/", undefined, "POST"),
),
).toBe(false);
});
it("reports a 404 the app threw deliberately", () => {
expect(
shouldReportServerError(
routeError(404, false),
request("/leagues/missing"),
),
).toBe(true);
});
it("reports a 403 the app threw from an ownership check", () => {
expect(
shouldReportServerError(
routeError(403, false, "Forbidden"),
request("/admin/sports"),
),
).toBe(true);
});
it("reports a router-internal 500", () => {
expect(
shouldReportServerError(
routeError(500, true, "Internal Server Error"),
request("/leagues"),
),
).toBe(true);
});
it("reports a plain exception", () => {
expect(
shouldReportServerError(new Error("boom"), request("/leagues")),
).toBe(true);
});
it("reports anything that is not a route error response", () => {
expect(shouldReportServerError("just a string", request("/leagues"))).toBe(
true,
);
expect(shouldReportServerError(null, request("/leagues"))).toBe(true);
});
it("drops a router 404 whose referer header is not a URL", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/blog/", "not a url"),
),
).toBe(false);
});
});
/**
* The unit tests above use hand-written error objects. These drive real requests
* through React Router so the suite fails if the shape it throws ever changes.
*/
describe("shouldReportServerError against real React Router errors", () => {
const handler = createStaticHandler([
{
id: "root",
path: "/",
children: [{ id: "home", index: true, loader: () => null }],
},
]);
async function errorFor(req: Request) {
const ctx = await handler.query(req);
if (ctx instanceof Response) return null;
return Object.values(ctx.errors ?? {})[0] ?? null;
}
it('drops the 404 for an unmatched URL (No route matches URL "...")', async () => {
const req = request("/blog/wp/v2/posts/999999");
const error = await errorFor(req);
expect(error).toMatchObject({ status: 404, internal: true });
expect(shouldReportServerError(error, req)).toBe(false);
});
it("reports the same 404 when it came from a link on our own site", async () => {
const req = request("/nope", `${ORIGIN}/leagues`);
expect(shouldReportServerError(await errorFor(req), req)).toBe(true);
});
it("drops the 405 from a POST to a route with no action", async () => {
const req = request("/", undefined, "POST");
const error = await errorFor(req);
expect(error).toMatchObject({ status: 405, internal: true });
expect(shouldReportServerError(error, req)).toBe(false);
});
});
describe("static asset 404s", () => {
it("drops a stale hashed bundle even with a same-host referer", () => {
// Every deploy leaves clients requesting the previous build's assets.
const req = request("/assets/index-OLDHASH.js", `${ORIGIN}/leagues`);
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
});
it.each([
"/assets/app-x1.css",
"/fonts/inter.woff2",
"/images/logo.png",
"/favicon.ico",
])("drops a 404 for %s", (path) => {
expect(
shouldReportServerError(
routeError(404, true),
request(path, `${ORIGIN}/`),
),
).toBe(false);
});
it("still follows the referer rule for a non-asset path containing a dot", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/leagues/v1.2", `${ORIGIN}/leagues`),
),
).toBe(true);
expect(
shouldReportServerError(routeError(404, true), request("/leagues/v1.2")),
).toBe(false);
});
});
describe("React Router internal statuses that are not 404/405", () => {
it("reports an internal 400 (route is missing a loader)", () => {
expect(
shouldReportServerError(
routeError(400, true, "Bad Request"),
request("/leagues"),
),
).toBe(true);
});
it("reports an internal 403 (route does not match URL)", () => {
expect(
shouldReportServerError(
routeError(403, true, "Forbidden"),
request("/leagues"),
),
).toBe(true);
});
});
describe("production shape: TLS terminated upstream", () => {
it("reports a 404 linked from our own site when the proxy strips https", () => {
// Express builds request.url from req.protocol, which is `http` inside the
// container. Real browsers send an https referer. Comparing full origins
// would never match, silencing every broken internal link.
const req = new Request("http://brackt.com/leagues/gone", {
headers: { referer: "https://brackt.com/leagues" },
});
expect(shouldReportServerError(routeError(404, true), req)).toBe(true);
});
it("still drops a cold scanner hit under that same shape", () => {
const req = new Request("http://brackt.com/blog/wp/v2/posts/999999");
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
});
it("still drops a 404 linked from another site under that same shape", () => {
const req = new Request("http://brackt.com/nope", {
headers: { referer: "https://evil.example/" },
});
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
});
});

View file

@ -1,99 +0,0 @@
/**
* AFL Wildcard Round Elimination Finals re-seeding.
*
* The Wildcard Round is drawn 7 v 10 and 8 v 9, and its two winners fill the open slots
* in the Elimination Finals opposite the 5th and 6th seeds. Those slots are NOT a fixed
* crossover: the winners are re-seeded by ladder position, exactly as the classic final
* eight pairs 5 v 8 and 6 v 7 the higher seed of the two hosts meets the lower-ranked
* winner. So 5th plays whichever winner finished further down the ladder and 6th plays
* the other, whichever Wildcard game each came out of.
*
* Worked example: 10th beats 7th and 9th beats 8th. A fixed crossover would send the
* 7v10 winner (10th) to 6th and the 8v9 winner (9th) to 5th handing the higher host
* the better opponent. Re-seeded, 5th plays 10th and 6th plays 9th.
*/
/** Seeds drawn into each Wildcard Round match, in [participant1, participant2] order. */
export const AFL_WILDCARD_DRAW: Readonly<Record<number, readonly [number, number]>> = {
1: [7, 10],
2: [8, 9],
};
/** Seed hosting each Elimination Finals match (its participant1 slot). */
export const AFL_ELIMINATION_HOSTS: Readonly<Record<number, number>> = {
1: 5,
2: 6,
};
export interface AflWildcardResult {
matchNumber: number;
/** Slot the winner occupied, or null while the match is still to be played. */
winnerSlot: 1 | 2 | null;
}
export interface AflWildcardPlacement {
wildcardMatchNumber: number;
/** Seed of the Wildcard winner being placed. */
seed: number;
eliminationMatchNumber: number;
}
/**
* Decide which Elimination Final each decided Wildcard winner belongs in.
*
* A winner is only placed once its destination is settled whichever way the other
* Wildcard game falls, so results can be entered in either order:
* - 7th winning match 1 outranks both possible match 2 winners always meets 6th.
* - 10th winning match 1 is outranked by both always meets 5th.
* - A match 2 winner (8th or 9th) sits between them, so it is held back until match 1
* is decided rather than being placed and then moved.
*
* Undecided winners are simply omitted; the caller fills the slots it is handed and
* leaves the rest TBD.
*/
export function resolveAflWildcardPlacements(
results: readonly AflWildcardResult[]
): AflWildcardPlacement[] {
const entries = results.map((result) => {
const draw = AFL_WILDCARD_DRAW[result.matchNumber];
if (!draw) {
throw new Error(`Unknown AFL Wildcard Round match number ${result.matchNumber}`);
}
return {
matchNumber: result.matchNumber,
seed: result.winnerSlot === null ? null : draw[result.winnerSlot - 1],
// Every seed the match could still send through — one entry once it is decided.
possibleSeeds: result.winnerSlot === null ? [...draw] : [draw[result.winnerSlot - 1]],
};
});
// Best-ranked winner takes the weakest host, so order the hosts worst seed first.
const hostsWorstFirst = Object.keys(AFL_ELIMINATION_HOSTS)
.map(Number)
.toSorted((a, b) => AFL_ELIMINATION_HOSTS[b] - AFL_ELIMINATION_HOSTS[a]);
const placements: AflWildcardPlacement[] = [];
for (const entry of entries) {
const seed = entry.seed;
if (seed === null) continue;
const others = entries.filter((other) => other !== entry);
const outranks = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s < seed);
const outrankedBy = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s > seed);
// This winner's rank is only knowable while every other one sits wholly above or
// wholly below it — an undecided game straddling this seed leaves it unplaceable.
if (!others.every((other) => outranks(other) || outrankedBy(other))) continue;
const rank = others.filter(outranks).length;
const eliminationMatchNumber = hostsWorstFirst[rank];
if (eliminationMatchNumber === undefined) {
throw new Error(`No Elimination Finals slot for AFL Wildcard winner ranked ${rank + 1}`);
}
placements.push({ wildcardMatchNumber: entry.matchNumber, seed, eliminationMatchNumber });
}
return placements;
}

View file

@ -703,8 +703,7 @@ export const NFL_14: BracketTemplate = {
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
* - Week 1 Finals:
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
* - Elimination Finals: the two Wildcard winners are re-seeded by ladder position, so
* 5th hosts the lower-ranked winner and 6th the higher-ranked one (losers share 7th-8th)
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th)
* - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th)
* - Week 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
* - Week 4: Grand Final (1st vs 2nd)

View file

@ -1,87 +0,0 @@
/**
* Decides which server-side errors are worth sending to Sentry.
*
* Automated scanners probe for CMS paths that have never existed here
* (`/blog/wp/v2/posts/999999`, `/wp-login.php`, a bare `POST /`). React Router
* throws for each one a 404 when no route matches, a 405 when a route has no
* `action` and every throw reaches `handleError` in `app/entry.server.tsx`.
* Reporting those burns the Sentry quota without ever describing a real bug.
*/
import { isRouteErrorResponse } from "react-router";
/**
* Statuses React Router uses to say "nothing here matched this request":
* 404 when no route matches the URL, 405 when the route has no `action` or the
* method is invalid. Its other internal statuses (400 "did not provide a
* `loader`", 403 "Route does not match URL") describe a misconfigured route
* rather than an unrecognised request, so those keep reporting.
*/
const UNMATCHED_REQUEST_STATUSES = new Set([404, 405]);
/**
* Static assets 404 in bulk for reasons that are never actionable: scanners
* guessing filenames, and clients running stale HTML that still references the
* previous deploy's hashed bundles.
*/
const ASSET_EXT_RE =
/\.(css|js|mjs|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot)$/i;
/** React Router stamps `internal: true` on the errors it generates itself. */
function isInternalRouterError(error: unknown): boolean {
return (error as { internal?: unknown }).internal === true;
}
/**
* True when the request was linked from a page on this same site.
*
* Compares host rather than origin on purpose. Production terminates TLS
* upstream and serves plain HTTP in the container, so `request.url` which
* `@react-router/express` builds from `req.protocol` says `http` while the
* browser sends an `https` referer. Comparing full origins would therefore
* never match in production. (`app/routes/leagues/$leagueId.server.ts` works
* around the same mismatch for invite URLs.) Protocol tells us nothing about
* whether the link was ours; host does.
*/
function hasSameHostReferer(request: Request): boolean {
const referer = request.headers.get("referer");
if (!referer) return false;
try {
return new URL(referer).host === new URL(request.url).host;
} catch {
// Scanners send garbage in this header; a referer we can't parse isn't ours.
return false;
}
}
/**
* Whether `error` should be reported to Sentry.
*
* Drops the 404s and 405s React Router generated for a request that matched
* nothing. Everything else is reported: real exceptions, 5xx, React Router's
* other internal statuses, and responses the app threw deliberately
* (`internal: false`), so a 403 from an ownership check still shows up.
*
* The exception is a request carrying a same-host `Referer`: a 404 reached from
* one of our own pages is a broken internal link, not a scanner, and stays
* visible in Sentry. Asset paths are excluded from that exception a stale
* client requesting last deploy's bundle sends a same-host referer too, and
* would otherwise spike Sentry on every release.
*/
export function shouldReportServerError(
error: unknown,
request: Request,
): boolean {
if (!isRouteErrorResponse(error)) return true;
if (!isInternalRouterError(error)) return true;
if (!UNMATCHED_REQUEST_STATUSES.has(error.status)) return true;
let pathname: string;
try {
pathname = new URL(request.url).pathname;
} catch {
pathname = "";
}
if (ASSET_EXT_RE.test(pathname)) return false;
return hasSameHostReferer(request);
}

View file

@ -1,273 +0,0 @@
/**
* Advancing an AFL Elimination Finals winner into the Semi-Finals.
*
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
* n. The crossover comes a round later, at Semi-Finals Preliminary Finals, so that a
* Qualifying Final loser cannot meet the side that just beat it.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AFL_10 } from "~/lib/bracket-templates";
interface MatchRow {
id: string;
scoringEventId: string;
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
isComplete: boolean;
winnerId: string | null;
loserId: string | null;
}
let rows: MatchRow[] = [];
/**
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
* mock needs to tell one lookup from another there is no query engine behind it.
*/
function whereValues(node: unknown, depth = 0): string[] {
if (!node || depth > 10) return [];
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
if (typeof node !== "object") return [];
const obj = node as Record<string, unknown>;
const own = typeof obj.value === "string" ? [obj.value] : [];
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
}
const db = {
query: {
playoffMatches: {
findFirst: vi.fn(({ where }: { where: unknown }) => {
const values = whereValues(where);
return Promise.resolve(rows.find((r) => values.includes(r.id)));
}),
findMany: vi.fn(({ where }: { where: unknown }) => {
const values = whereValues(where);
return Promise.resolve(
rows
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
.toSorted((a, b) => a.matchNumber - b.matchNumber)
);
}),
},
},
update: vi.fn(() => ({
set: (data: Partial<MatchRow>) => {
const applyTo = (where: unknown) => {
const values = whereValues(where);
const target = rows.find((r) => values.includes(r.id));
if (target) Object.assign(target, data);
return target;
};
// Advancement writes through the query builder with and without .returning().
return {
where: (where: unknown) => {
const applied = Promise.resolve([applyTo(where)]);
return Object.assign(applied, { returning: () => applied });
},
};
},
})),
// No rollback: the tests assert the writes that were attempted, in order.
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
};
vi.mock("~/database/context", () => ({ database: () => db }));
const { advanceWinnerTemplate, reseedAflSemiFinals } = await import("../playoff-match");
const EVENT = "event-1";
/**
* The real 2026 finals, which is what surfaced the crossover bug. Ladder: 1 Fremantle,
* 2 Sydney, 3 Brisbane, 4 Hawthorn, 5 Geelong, 6 Adelaide, 7 Melbourne, 8 Bulldogs,
* 9 Collingwood, 10 Carlton. Carlton (10th) and the Bulldogs (8th) came through the
* Wildcard Round, so 5th hosts Carlton and 6th hosts the Bulldogs.
*/
const FREO = "fremantle";
const SYDNEY = "sydney";
const BRISBANE = "brisbane";
const HAWTHORN = "hawthorn";
const GEELONG = "geelong";
const ADELAIDE = "adelaide";
const BULLDOGS = "bulldogs";
const CARLTON = "carlton";
/** An afl_10 bracket with week one played: Freo and Brisbane lost their Qualifying Finals. */
function bracket(): MatchRow[] {
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
return [
{ ...base, id: "qf1", round: "Qualifying Finals", matchNumber: 1, participant1Id: FREO, participant2Id: HAWTHORN, isComplete: true, winnerId: HAWTHORN, loserId: FREO },
{ ...base, id: "qf2", round: "Qualifying Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: BRISBANE, isComplete: true, winnerId: SYDNEY, loserId: BRISBANE },
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: GEELONG, participant2Id: CARLTON },
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: ADELAIDE, participant2Id: BULLDOGS },
// Filled by the Qualifying Final losers, as advancement already does.
{ ...base, id: "sf1", round: "Semi-Finals", matchNumber: 1, participant1Id: FREO, participant2Id: null },
{ ...base, id: "sf2", round: "Semi-Finals", matchNumber: 2, participant1Id: BRISBANE, participant2Id: null },
{ ...base, id: "pf1", round: "Preliminary Finals", matchNumber: 1, participant1Id: HAWTHORN, participant2Id: null },
{ ...base, id: "pf2", round: "Preliminary Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: null },
];
}
function row(id: string): MatchRow {
const found = rows.find((r) => r.id === id);
if (!found) throw new Error(`No such match ${id}`);
return found;
}
/** Record a result the way setMatchWinner does, then advance it. */
async function win(id: string, winnerId: string) {
const match = row(id);
match.winnerId = winnerId;
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
match.isComplete = true;
await advanceWinnerTemplate(id, winnerId, AFL_10);
}
const pairing = () => ({
sf1: [row("sf1").participant1Id, row("sf1").participant2Id],
sf2: [row("sf2").participant1Id, row("sf2").participant2Id],
});
beforeEach(() => {
rows = bracket();
vi.clearAllMocks();
});
describe("Elimination Finals → Semi-Finals advancement", () => {
it("feeds Elimination Final 1 into Semi-Final 1", async () => {
await win("ef1", GEELONG);
expect(row("sf1").participant2Id).toBe(GEELONG);
expect(row("sf2").participant2Id).toBeNull();
});
it("feeds Elimination Final 2 into Semi-Final 2", async () => {
await win("ef2", ADELAIDE);
expect(row("sf2").participant2Id).toBe(ADELAIDE);
expect(row("sf1").participant2Id).toBeNull();
});
it("draws the real 2026 Semi-Finals: Freo v Geelong and Brisbane v Adelaide", async () => {
await win("ef1", GEELONG);
await win("ef2", ADELAIDE);
expect(pairing()).toEqual({
sf1: [FREO, GEELONG],
sf2: [BRISBANE, ADELAIDE],
});
});
it("draws the same Semi-Finals whichever order the results are entered", async () => {
await win("ef2", ADELAIDE);
await win("ef1", GEELONG);
expect(pairing()).toEqual({
sf1: [FREO, GEELONG],
sf2: [BRISBANE, ADELAIDE],
});
});
it("keeps the Preliminary Finals crossover so a QF loser dodges the side that beat it", async () => {
await win("ef1", GEELONG);
await win("ef2", ADELAIDE);
// Freo (lost QF1 to Hawthorn) wins its semi, so it must land in Sydney's Prelim.
await win("sf1", FREO);
expect(row("pf2").participant2Id).toBe(FREO);
expect(row("pf1").participant2Id).toBeNull();
});
it("pulls the beaten team back out when an Elimination Final result is corrected", async () => {
await win("ef1", GEELONG);
expect(row("sf1").participant2Id).toBe(GEELONG);
await win("ef1", CARLTON);
expect(row("sf1").participant2Id).toBe(CARLTON);
expect(row("sf2").participant2Id).toBeNull();
});
});
describe("reseedAflSemiFinals", () => {
it("repairs a bracket left crossed by the old fixed crossover", async () => {
// What advancement wrote before the fix: EF1 winner into SF2, EF2 winner into SF1.
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
row("sf1").participant2Id = ADELAIDE;
row("sf2").participant2Id = GEELONG;
const reseed = await reseedAflSemiFinals(EVENT);
expect(pairing()).toEqual({
sf1: [FREO, GEELONG],
sf2: [BRISBANE, ADELAIDE],
});
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
{ matchNumber: 1, participantId: GEELONG },
{ matchNumber: 2, participantId: ADELAIDE },
]);
});
it("writes nothing when the pairings are already right", async () => {
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
row("sf1").participant2Id = GEELONG;
row("sf2").participant2Id = ADELAIDE;
const reseed = await reseedAflSemiFinals(EVENT);
expect(reseed).toEqual({ vacated: [], filled: [] });
expect(db.update).not.toHaveBeenCalled();
});
it("leaves an undecided Elimination Final's slot TBD", async () => {
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
await reseedAflSemiFinals(EVENT);
expect(row("sf1").participant2Id).toBe(GEELONG);
expect(row("sf2").participant2Id).toBeNull();
});
it("refuses a slot held by someone who never played an Elimination Final", async () => {
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
row("sf1").participant2Id = SYDNEY;
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow("SF 1 participant2 already filled");
});
it("refuses to move a qualifier out of a Semi-Final that has been played", async () => {
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
Object.assign(row("sf1"), {
participant2Id: ADELAIDE,
isComplete: true,
winnerId: FREO,
loserId: ADELAIDE,
});
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
"Semi-Finals match 1 already has a recorded result"
);
});
it("rejects an Elimination Final winner who is not one of its participants", async () => {
Object.assign(row("ef1"), { isComplete: true, winnerId: SYDNEY, loserId: CARLTON });
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
"Elimination Finals match 1 winner is not one of its participants"
);
});
it("throws on an event with no Semi-Finals to re-seed", async () => {
rows = rows.filter((r) => r.round !== "Semi-Finals");
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
"no AFL Elimination Finals / Semi-Finals matches to re-seed"
);
});
});

View file

@ -1,271 +0,0 @@
/**
* Advancing an AFL Wildcard Round winner into the Elimination Finals.
*
* The two winners are re-seeded by ladder position 5th hosts the lower-ranked winner,
* 6th the higher-ranked one so the destination is not a fixed crossover from a given
* Wildcard match, and results can be recorded in either order.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AFL_10 } from "~/lib/bracket-templates";
interface MatchRow {
id: string;
scoringEventId: string;
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
isComplete: boolean;
winnerId: string | null;
loserId: string | null;
}
let rows: MatchRow[] = [];
/**
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
* mock needs to tell one lookup from another there is no query engine behind it.
*/
function whereValues(node: unknown, depth = 0): string[] {
if (!node || depth > 10) return [];
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
if (typeof node !== "object") return [];
const obj = node as Record<string, unknown>;
const own = typeof obj.value === "string" ? [obj.value] : [];
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
}
const db = {
query: {
playoffMatches: {
findFirst: vi.fn(({ where }: { where: unknown }) => {
const values = whereValues(where);
return Promise.resolve(rows.find((r) => values.includes(r.id)));
}),
findMany: vi.fn(({ where }: { where: unknown }) => {
const values = whereValues(where);
return Promise.resolve(
rows
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
.toSorted((a, b) => a.matchNumber - b.matchNumber)
);
}),
},
},
update: vi.fn(() => ({
set: (data: Partial<MatchRow>) => {
const applyTo = (where: unknown) => {
const values = whereValues(where);
const target = rows.find((r) => values.includes(r.id));
if (target) Object.assign(target, data);
return target;
};
// Advancement writes through the query builder with and without .returning().
return {
where: (where: unknown) => {
const applied = Promise.resolve([applyTo(where)]);
return Object.assign(applied, { returning: () => applied });
},
};
},
})),
// No rollback: the tests assert the writes that were attempted, in order.
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
};
vi.mock("~/database/context", () => ({ database: () => db }));
const { advanceWinnerTemplate, reseedAflEliminationFinals } = await import("../playoff-match");
const EVENT = "event-1";
/** Ladder seed n → participant id. */
const seed = (n: number) => `seed-${n}`;
/** A freshly generated afl_10 Wildcard Round (7v10, 8v9) and Elimination Finals (5, 6). */
function bracket(): MatchRow[] {
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
return [
{ ...base, id: "wc1", round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
{ ...base, id: "wc2", round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
];
}
function row(id: string): MatchRow {
const found = rows.find((r) => r.id === id);
if (!found) throw new Error(`No such match ${id}`);
return found;
}
/** Record a Wildcard result the way setMatchWinner does, then advance it. */
async function winWildcard(id: string, winnerId: string) {
const match = row(id);
match.winnerId = winnerId;
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
match.isComplete = true;
await advanceWinnerTemplate(id, winnerId, AFL_10);
}
describe("AFL Wildcard Round advancement", () => {
beforeEach(() => {
rows = bracket();
});
it("sends 5th the lower-ranked winner and 6th the higher-ranked one", async () => {
await winWildcard("wc1", seed(7));
await winWildcard("wc2", seed(8));
expect(row("ef1").participant2Id).toBe(seed(8));
expect(row("ef2").participant2Id).toBe(seed(7));
});
it("re-seeds when the lower seed wins through", async () => {
// The reported bug: 10th beating 7th used to be crossed straight to 6th, leaving
// 5th with the better survivor.
await winWildcard("wc1", seed(10));
await winWildcard("wc2", seed(8));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
});
it("re-seeds a 9th-placed winner above a 10th-placed one", async () => {
await winWildcard("wc1", seed(10));
await winWildcard("wc2", seed(9));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(9));
});
it("places the same pairings whichever result is entered first", async () => {
await winWildcard("wc2", seed(8));
await winWildcard("wc1", seed(10));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
});
it("places the 7v10 winner immediately, since its slot is settled either way", async () => {
await winWildcard("wc1", seed(7));
expect(row("ef2").participant2Id).toBe(seed(7));
expect(row("ef1").participant2Id).toBeNull();
});
it("holds an 8v9 winner back until the 7v10 game is decided", async () => {
// 8th and 9th sit between 7th and 10th, so placing one now could need undoing.
await winWildcard("wc2", seed(8));
expect(row("ef1").participant2Id).toBeNull();
expect(row("ef2").participant2Id).toBeNull();
});
it("does not disturb a winner it already placed", async () => {
await winWildcard("wc1", seed(7));
await winWildcard("wc2", seed(9));
expect(row("ef2").participant2Id).toBe(seed(7));
expect(row("ef1").participant2Id).toBe(seed(9));
});
it("refuses to overwrite a slot already holding someone else", async () => {
row("ef1").participant2Id = "stranger";
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already filled/);
expect(row("ef1").participant2Id).toBe("stranger");
});
it("moves the winner when a recorded Wildcard result is corrected", async () => {
await winWildcard("wc1", seed(7));
expect(row("ef2").participant2Id).toBe(seed(7));
// The result was wrong: 10th won. 7th must not be left alive in the other slot.
await winWildcard("wc1", seed(10));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBeNull();
});
it("re-seeds a pairing left behind by the old fixed crossover", async () => {
// Pre-fix state: the 7v10 winner was crossed to 6th whatever its ladder position.
row("wc1").winnerId = seed(10);
row("wc1").loserId = seed(7);
row("wc1").isComplete = true;
row("ef2").participant2Id = seed(10);
await winWildcard("wc2", seed(8));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
});
it("swaps both winners when re-resolving an already-placed pair", async () => {
row("wc1").winnerId = seed(10);
row("wc1").loserId = seed(7);
row("wc1").isComplete = true;
row("ef2").participant2Id = seed(10);
row("ef1").participant2Id = seed(8);
await winWildcard("wc2", seed(8));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
});
it("refuses to re-seed an Elimination Final that has already been played", async () => {
await winWildcard("wc1", seed(7));
Object.assign(row("ef2"), { isComplete: true, winnerId: seed(6), loserId: seed(7) });
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already has a recorded result/);
expect(row("ef2").participant2Id).toBe(seed(7));
});
it("repairs an already-advanced bracket from the recorded results alone", async () => {
// What scripts/fix-afl-wildcard-reseed.ts does: no new result, just the rows a
// bracket advanced under the old fixed crossover left behind.
Object.assign(row("wc1"), { isComplete: true, winnerId: seed(10), loserId: seed(7) });
Object.assign(row("wc2"), { isComplete: true, winnerId: seed(8), loserId: seed(9) });
row("ef2").participant2Id = seed(10);
row("ef1").participant2Id = seed(8);
const reseed = await reseedAflEliminationFinals(EVENT);
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
{ matchNumber: 1, participantId: seed(10) },
{ matchNumber: 2, participantId: seed(8) },
]);
});
it("reports no change when a repair run finds the pairings correct", async () => {
await winWildcard("wc1", seed(7));
await winWildcard("wc2", seed(8));
db.transaction.mockClear();
const reseed = await reseedAflEliminationFinals(EVENT);
expect(reseed).toEqual({ vacated: [], filled: [] });
expect(db.transaction).not.toHaveBeenCalled();
});
it("rejects an event with no AFL bracket rather than reporting nothing to do", async () => {
await expect(reseedAflEliminationFinals("no-such-event")).rejects.toThrow(/no AFL Wildcard/);
});
it("leaves the bracket alone when the pairings are already right", async () => {
await winWildcard("wc1", seed(7));
await winWildcard("wc2", seed(8));
db.transaction.mockClear();
await winWildcard("wc2", seed(8));
expect(db.transaction).not.toHaveBeenCalled();
expect(row("ef1").participant2Id).toBe(seed(8));
expect(row("ef2").participant2Id).toBe(seed(7));
});
});

View file

@ -319,22 +319,6 @@ describe("processMatchResult", () => {
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 () => {
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("network error")

View file

@ -111,50 +111,4 @@ describe("simulator input model", () => {
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull();
});
it("hides an Elo flagged as projection-derived so the projection is re-derived", async () => {
// This is what stops a stale Elo from winning the baseEloPriority race. A row
// carrying projectedWins and a projectedWins method flag must surface with a
// null sourceElo, so resolveSourceElos falls through to the projection rather
// than reusing an Elo that was itself derived from an older projection.
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
{ id: "projected" },
{ id: "hand-entered" },
]);
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
{
participantId: "projected",
sourceOdds: null,
sourceElo: 1561,
worldRanking: null,
rating: null,
projectedWins: "95.00",
projectedTablePoints: null,
seed: null,
region: null,
metadata: { sourceEloMethod: "projectedWins" },
},
{
participantId: "hand-entered",
sourceOdds: null,
sourceElo: 1561,
worldRanking: null,
rating: null,
projectedWins: "95.00",
projectedTablePoints: null,
seed: null,
region: null,
metadata: {},
},
]);
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
const inputs = await getParticipantSimulatorInputs("season-1");
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
expect(byParticipant.get("projected")?.sourceElo).toBeNull();
expect(byParticipant.get("projected")?.projectedWins).toBe(95);
// No flag means the admin entered that Elo themselves — it is trusted as direct.
expect(byParticipant.get("hand-entered")?.sourceElo).toBe(1561);
});
});

View file

@ -15,10 +15,6 @@ import {
resolveLLWSAdvancement,
type LLWSResolvedDestination,
} from "~/lib/llws-bracket";
import {
resolveAflWildcardPlacements,
type AflWildcardResult,
} from "~/lib/afl-wildcard-reseed";
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
@ -746,11 +742,9 @@ async function generateNFL14Bracket(
* Structure:
* - Wildcard Round: 7v10, 8v9
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
* - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder
* position 5th draws the lower-ranked winner, 6th the higher-ranked one
* - Semi-Finals: SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner
* - Preliminary Finals: PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner
* (the crossover keeps a QF loser away from the side that just beat it)
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
* - Semi-Finals: QF losers vs EF winners
* - Preliminary Finals: QF winners vs SF winners
* - Grand Final: PF winners
*/
async function generateAFL10Bracket(
@ -802,16 +796,14 @@ async function generateAFL10Bracket(
});
}
// Elimination Finals: 5th and 6th host the two Wildcard winners. Which winner lands
// where is decided by ladder position once both games are played (see
// resolveAflWildcardPlacements), not by a fixed crossover from a Wildcard match.
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
const eliminationSeeding = [
{ higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
{ higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
];
for (let i = 0; i < eliminationSeeding.length; i++) {
const { higher, opponent } = eliminationSeeding[i];
const { higher, wildcard } = eliminationSeeding[i];
matches.push({
scoringEventId: eventId,
round: "Elimination Finals",
@ -821,11 +813,11 @@ async function generateAFL10Bracket(
isComplete: false,
isScoring: true, // Losers share 7th-8th
templateRound: "Elimination Finals",
seedInfo: participantIds ? `${higher + 1} vs ${opponent}` : null,
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null,
});
}
// Semi-Finals: SF n = QF n loser vs EF n winner (TBD vs TBD)
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
for (let i = 0; i < 2; i++) {
matches.push({
scoringEventId: eventId,
@ -871,257 +863,15 @@ async function generateAFL10Bracket(
return await createManyPlayoffMatches(matches);
}
/** What a re-seed changed, by Elimination Finals match number. */
export interface AflEliminationReseed {
vacated: number[];
filled: Array<{ matchNumber: number; participantId: string }>;
}
/**
* Put the decided Wildcard winners in the Elimination Finals they belong in.
*
* The two winners are re-seeded by ladder position 5th meets the lower-ranked one and
* 6th the higher-ranked one rather than crossing over from a fixed Wildcard match. That
* destination depends on both games, so this reconciles both slots against the results
* recorded so far every time it runs: it places a winner whose slot only became certain
* once the other game was decided, and moves one that an earlier (or corrected) result,
* or a bracket advanced before this rule existed, put in the other slot.
*
* `pending` supplies a result that may not be in the database yet the row read back
* while advancing a match can predate the winner being written to it.
*
* Idempotent: pairings that are already right do no writes.
*/
export async function reseedAflEliminationFinals(
eventId: string,
pending?: { matchId: string; winnerId: string }
): Promise<AflEliminationReseed> {
const [wcMatches, efMatches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"),
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
]);
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
if (wcMatches.length === 0 || efMatches.length === 0) {
throw new Error(
`Event ${eventId} has no AFL Wildcard Round / Elimination Finals matches to re-seed`
);
}
const winnerByMatchNumber = new Map<number, string>();
for (const wc of wcMatches) {
const decidedWinner =
pending && wc.id === pending.matchId ? pending.winnerId : wc.isComplete ? wc.winnerId : null;
if (decidedWinner) winnerByMatchNumber.set(wc.matchNumber, decidedWinner);
}
const results: AflWildcardResult[] = wcMatches.map((wc) => {
const decidedWinner = winnerByMatchNumber.get(wc.matchNumber) ?? null;
if (decidedWinner === null) return { matchNumber: wc.matchNumber, winnerSlot: null };
if (decidedWinner === wc.participant1Id) return { matchNumber: wc.matchNumber, winnerSlot: 1 };
if (decidedWinner === wc.participant2Id) return { matchNumber: wc.matchNumber, winnerSlot: 2 };
throw new Error(
`Wildcard Round match ${wc.matchNumber} winner is not one of its participants`
);
});
const wanted = new Map<number, string>();
for (const placement of resolveAflWildcardPlacements(results)) {
const placedWinner = winnerByMatchNumber.get(placement.wildcardMatchNumber);
if (placedWinner) wanted.set(placement.eliminationMatchNumber, placedWinner);
}
// Only these teams can legitimately be moved between the two Elimination Finals;
// anyone else in a slot came from somewhere this function knows nothing about.
const wildcardParticipants = new Set<string>();
for (const wc of wcMatches) {
if (wc.participant1Id) wildcardParticipants.add(wc.participant1Id);
if (wc.participant2Id) wildcardParticipants.add(wc.participant2Id);
}
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
for (const efMatch of efMatches) {
const occupant = efMatch.participant2Id;
const belongsHere = wanted.get(efMatch.matchNumber) ?? null;
if (occupant === belongsHere) continue;
if (occupant !== null && !wildcardParticipants.has(occupant)) {
throw new Error(`EF ${efMatch.matchNumber} participant2 already filled`);
}
// Re-seeding a game that has already been played would rewrite who contested a
// recorded result. Surface that (this message is not one callers swallow) rather
// than quietly corrupting the bracket.
if (occupant !== null && (efMatch.isComplete || efMatch.winnerId)) {
throw new Error(
`Elimination Finals match ${efMatch.matchNumber} already has a recorded result, ` +
`so its Wildcard qualifier cannot be re-seeded — clear and regenerate the bracket`
);
}
if (occupant !== null) slotsToClear.push({ id: efMatch.id, matchNumber: efMatch.matchNumber });
if (belongsHere !== null) {
slotsToFill.push({ id: efMatch.id, matchNumber: efMatch.matchNumber, participantId: belongsHere });
}
}
const reseed: AflEliminationReseed = {
vacated: slotsToClear.map((slot) => slot.matchNumber),
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
};
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
// One transaction, vacating before filling: a half-applied re-seed would leave the
// same team in both Elimination Finals.
const db = database();
await db.transaction(async (tx) => {
const now = new Date();
for (const slot of slotsToClear) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: null, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
for (const slot of slotsToFill) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: slot.participantId, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
});
return reseed;
}
/** What a Semi-Finals re-seed changed, by Semi-Finals match number. */
export interface AflSemiFinalReseed {
vacated: number[];
filled: Array<{ matchNumber: number; participantId: string }>;
}
/**
* Put the decided Elimination Final winners in the Semi-Finals they belong in.
*
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
* n, so SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the EF2
* winner. The crossover in this system comes a round later, at Semi-Final Preliminary
* Final, so that a Qualifying Final loser cannot meet the side that just beat it.
*
* Brackets advanced before this was fixed crossed the two winners the EF1 winner went
* to SF2 and the EF2 winner to SF1 which is why this reconciles both slots against the
* results recorded so far rather than writing the one it was called for: a winner sitting
* in the wrong Semi-Final is vacated, and a corrected Elimination Final result pulls the
* beaten team back out instead of leaving it alive.
*
* `pending` supplies a result that may not be in the database yet the row read back
* while advancing a match can predate the winner being written to it.
*
* Idempotent: pairings that are already right do no writes.
*/
export async function reseedAflSemiFinals(
eventId: string,
pending?: { matchId: string; winnerId: string }
): Promise<AflSemiFinalReseed> {
const [efMatches, sfMatches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals"),
]);
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
if (efMatches.length === 0 || sfMatches.length === 0) {
throw new Error(
`Event ${eventId} has no AFL Elimination Finals / Semi-Finals matches to re-seed`
);
}
// Elimination Final n feeds Semi-Final n, so a decided winner's destination never
// depends on the other game.
const wanted = new Map<number, string>();
for (const ef of efMatches) {
const decidedWinner =
pending && ef.id === pending.matchId ? pending.winnerId : ef.isComplete ? ef.winnerId : null;
if (!decidedWinner) continue;
if (decidedWinner !== ef.participant1Id && decidedWinner !== ef.participant2Id) {
throw new Error(
`Elimination Finals match ${ef.matchNumber} winner is not one of its participants`
);
}
wanted.set(ef.matchNumber, decidedWinner);
}
// Only these teams can legitimately be moved between the two Semi-Finals; anyone else
// in a slot came from somewhere this function knows nothing about.
const eliminationParticipants = new Set<string>();
for (const ef of efMatches) {
if (ef.participant1Id) eliminationParticipants.add(ef.participant1Id);
if (ef.participant2Id) eliminationParticipants.add(ef.participant2Id);
}
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
for (const sfMatch of sfMatches) {
const occupant = sfMatch.participant2Id;
const belongsHere = wanted.get(sfMatch.matchNumber) ?? null;
if (occupant === belongsHere) continue;
if (occupant !== null && !eliminationParticipants.has(occupant)) {
throw new Error(`SF ${sfMatch.matchNumber} participant2 already filled`);
}
// Re-seeding a game that has already been played would rewrite who contested a
// recorded result. Surface that (this message is not one callers swallow) rather
// than quietly corrupting the bracket.
if (occupant !== null && (sfMatch.isComplete || sfMatch.winnerId)) {
throw new Error(
`Semi-Finals match ${sfMatch.matchNumber} already has a recorded result, ` +
`so its Elimination Finals qualifier cannot be re-seeded — clear and regenerate the bracket`
);
}
if (occupant !== null) slotsToClear.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber });
if (belongsHere !== null) {
slotsToFill.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber, participantId: belongsHere });
}
}
const reseed: AflSemiFinalReseed = {
vacated: slotsToClear.map((slot) => slot.matchNumber),
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
};
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
// One transaction, vacating before filling: a half-applied re-seed would leave the
// same team in both Semi-Finals.
const db = database();
await db.transaction(async (tx) => {
const now = new Date();
for (const slot of slotsToClear) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: null, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
for (const slot of slotsToFill) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: slot.participantId, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
});
return reseed;
}
/**
* AFL-specific advancement logic for the complex double-chance system
* Phase 3.3: Handles both winners and losers advancing to different rounds
*
* Advancement rules:
* - Wildcard Round: Winner Elimination Finals (re-seeded by ladder position)
* - Qualifying Finals: Winner Preliminary Finals, Loser Semi-Finals (QF n PF n, SF n)
* - Elimination Finals: Winner Semi-Finals (EF n SF n, a fixed pathway)
* - Semi-Finals: Winner Preliminary Finals (SF n crosses over: SF1 PF2, SF2 PF1)
* - Wildcard Round: Winner Elimination Finals
* - Qualifying Finals: Winner Preliminary Finals, Loser Semi-Finals
* - Elimination Finals: Winner Semi-Finals
* - Semi-Finals: Winner Preliminary Finals
* - Preliminary Finals: Winner Grand Final
*/
async function advanceAFLWinner(
@ -1131,10 +881,18 @@ async function advanceAFLWinner(
): Promise<void> {
const eventId = match.scoringEventId;
// Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
// position, so every result re-resolves both slots.
// Wildcard Round: Winner advances to Elimination Finals
if (match.round === "Wildcard Round") {
await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId });
// Wildcard Match 1 winner → EF Match 2, participant2Id
// Wildcard Match 2 winner → EF Match 1, participant2Id
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
return;
}
@ -1162,11 +920,18 @@ async function advanceAFLWinner(
return;
}
// Elimination Finals: Winner → Semi-Finals. EF n feeds SF n — the crossover in this
// system is a round later, at Semi-Finals → Preliminary Finals. Reconcile both slots so
// a corrected result moves the qualifier instead of leaving the beaten team alive.
// Elimination Finals: Winner → Semi-Finals
if (match.round === "Elimination Finals") {
await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
// EF Match 1 winner → SF2 participant2
// EF Match 2 winner → SF1 participant2
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
return;
}

View file

@ -547,19 +547,6 @@ export async function processMatchResult(
/** When set, Discord notification only shows this match (not all completed matches for the event). */
matchId?: string;
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
* (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>
): Promise<void> {
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) {
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
@ -650,7 +637,6 @@ export async function processMatchResult(
: undefined;
// Update probabilities first so the standings recalc reads fresh EVs and
// projected points reflect the new result.
if (!skipProbabilities) {
try {
await updateProbabilitiesAfterResult(sportsSeasonId, true);
} catch (error) {
@ -659,7 +645,6 @@ export async function processMatchResult(
error
);
}
}
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
}
}

View file

@ -359,28 +359,18 @@ export async function batchUpsertParticipantSimulatorInputs(
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
// tell readers whether the stored Elo/rating is generated vs. a trusted
// direct value. Two rules apply, and both always apply — they are not
// alternatives:
//
// 1. Drop the method flag for any column receiving a fresh direct
// value, otherwise a stale "generated" flag would cause that
// newly-entered Elo/rating to be filtered out as derived (see
// getParticipantSimulatorInputs).
// 2. Merge any metadata the caller supplied over the result
// (prepareSimulatorInputsForRun and the projection importers set the
// correct flags) — a merge rather than a replace so a caller that
// only needs to stamp one method flag does not wipe unrelated keys.
//
// Ordering matters: strip first, then merge, so a caller stamping one flag
// still gets the other column's stale flag cleared. Running these as
// exclusive CASE branches instead would mean a bulk row carrying both a
// direct `rating` and a `projectedWins` (which stamps sourceEloMethod)
// silently kept a stale ratingMethod, hiding the rating it just set.
metadata: sql`(
COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
// direct value. When a caller supplies explicit metadata, use it as-is
// (prepareSimulatorInputsForRun and the projection importer set the
// correct flags). Otherwise preserve existing metadata, but drop the
// method flag for any column receiving a fresh direct value — otherwise a
// stale "generated" flag would cause that newly-entered Elo/rating to be
// filtered out as derived (see getParticipantSimulatorInputs).
metadata: sql`CASE
WHEN excluded.metadata IS NOT NULL THEN excluded.metadata
ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
) || COALESCE(excluded.metadata, '{}'::jsonb)`,
END`,
updatedAt: sql`excluded.updated_at`,
},
});

View file

@ -1,99 +0,0 @@
import { describe, expect, it } from "vitest";
import {
parseBaseEloPriorityChoice,
projectionMethodMetadata,
resolvedInputMethodLabel,
} from "../admin.sports-seasons.$id.simulator.helpers";
import { DEFAULT_BASE_ELO_PRIORITY } from "~/services/simulations/input-policy";
describe("projectionMethodMetadata", () => {
it("flags a row that supplies projected wins and no Elo", () => {
expect(projectionMethodMetadata(undefined, 95, undefined)).toEqual({
sourceEloMethod: "projectedWins",
});
});
it("flags a row that supplies projected table points and no Elo", () => {
expect(projectionMethodMetadata(undefined, undefined, 76.5)).toEqual({
sourceEloMethod: "projectedTablePoints",
});
});
it("leaves metadata alone when the row supplies an explicit Elo", () => {
// An explicit Elo is a direct entry and must stay trusted, even alongside a
// projection — the upsert then clears any stale generated flag.
expect(projectionMethodMetadata(1600, 95, undefined)).toBeUndefined();
});
it("leaves metadata alone for a row with neither", () => {
expect(projectionMethodMetadata(undefined, undefined, undefined)).toBeUndefined();
});
it("prefers wins over table points when a row somehow carries both", () => {
expect(projectionMethodMetadata(undefined, 95, 76.5)).toEqual({
sourceEloMethod: "projectedWins",
});
});
});
describe("parseBaseEloPriorityChoice", () => {
it("puts projections ahead of raw Elo", () => {
expect(parseBaseEloPriorityChoice("projectionsFirst", DEFAULT_BASE_ELO_PRIORITY)).toEqual([
"projectedWins",
"projectedTablePoints",
"sourceElo",
]);
});
it("puts raw Elo first for eloFirst", () => {
expect(parseBaseEloPriorityChoice("eloFirst", DEFAULT_BASE_ELO_PRIORITY)).toEqual(
DEFAULT_BASE_ELO_PRIORITY
);
});
it("keeps the stored ordering when the select was not on the form", () => {
// Simulators with no projection alternative never render the control; saving
// other config must not rewrite their ordering.
const custom: typeof DEFAULT_BASE_ELO_PRIORITY = ["projectedWins", "sourceElo"];
expect(parseBaseEloPriorityChoice(null, custom)).toEqual(custom);
});
it("preserves the relative order of the projection keys", () => {
expect(
parseBaseEloPriorityChoice("projectionsFirst", [
"projectedTablePoints",
"sourceElo",
"projectedWins",
])
).toEqual(["projectedTablePoints", "projectedWins", "sourceElo"]);
});
it("round-trips: flipping back restores Elo-first", () => {
const flipped = parseBaseEloPriorityChoice("projectionsFirst", DEFAULT_BASE_ELO_PRIORITY);
expect(parseBaseEloPriorityChoice("eloFirst", flipped)).toEqual(DEFAULT_BASE_ELO_PRIORITY);
});
});
describe("resolvedInputMethodLabel", () => {
it("badges nothing for a directly entered Elo or rating", () => {
expect(resolvedInputMethodLabel("direct")).toBeNull();
});
it("badges both projection methods the same way", () => {
expect(resolvedInputMethodLabel("projectedWins")).toBe("from projections");
expect(resolvedInputMethodLabel("projectedTablePoints")).toBe("from projections");
});
it("distinguishes futures and blended Elo", () => {
expect(resolvedInputMethodLabel("sourceOdds")).toBe("from futures");
expect(resolvedInputMethodLabel("blend")).toBe("blended");
});
it("badges every missing-input strategy as a fallback", () => {
expect(resolvedInputMethodLabel("fallbackElo")).toBe("fallback");
expect(resolvedInputMethodLabel("fallbackRating")).toBe("fallback");
expect(resolvedInputMethodLabel("averageKnown")).toBe("fallback");
expect(resolvedInputMethodLabel("worstKnownMinus")).toBe("fallback");
});
});

View file

@ -1,146 +0,0 @@
/**
* The Fix Semi-Final Pairings admin action.
*
* Elimination Final n feeds Semi-Final n, but brackets advanced before that was fixed
* crossed the two winners, and nothing re-runs advancement a completed match cannot be
* re-submitted from the UI.
*
* It moves qualifier slots only no scoring runs, so nothing reaches Discord.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { reseedAflSemiFinals } from "~/models/playoff-match";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getScoringEventById } from "~/models/scoring-event";
import { processMatchResult, recalculateAffectedLeagues } from "~/models/scoring-calculator";
import { sendDiscordWebhook } from "~/services/discord";
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
vi.mock("~/models/scoring-event", async (importOriginal) => ({
...(await importOriginal<object>()),
getScoringEventById: vi.fn(),
isReadOnlySibling: vi.fn(() => false),
}));
vi.mock("~/models/playoff-match", async (importOriginal) => ({
...(await importOriginal<object>()),
reseedAflSemiFinals: vi.fn(),
}));
vi.mock("~/models/season-participant", async (importOriginal) => ({
...(await importOriginal<object>()),
findParticipantsBySportsSeasonId: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
...(await importOriginal<object>()),
processMatchResult: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
}));
vi.mock("~/services/discord", async (importOriginal) => ({
...(await importOriginal<object>()),
sendDiscordWebhook: vi.fn(),
}));
const params = { id: "season-1", eventId: "event-1" };
const EVENT = {
id: "event-1",
name: "AFL Finals",
sportsSeasonId: "season-1",
isQualifyingEvent: false,
bracketTemplateId: "afl_10",
};
function request() {
const body = new FormData();
body.set("intent", "reseed-afl-semifinals");
return new Request("http://localhost/bracket", { method: "POST", body });
}
const run = () => action({ request: request(), params } as never);
describe("reseed-afl-semifinals", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never);
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
{ id: "geelong", name: "Geelong Cats" },
{ id: "adelaide", name: "Adelaide Crows" },
] as never);
});
it("names the teams that moved", async () => {
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
vacated: [1, 2],
filled: [
{ matchNumber: 2, participantId: "adelaide" },
{ matchNumber: 1, participantId: "geelong" },
],
});
const result = await run();
expect(reseedAflSemiFinals).toHaveBeenCalledWith("event-1");
expect(result).toEqual({
success:
"Re-seeded the Semi-Finals: match 1 now hosts Geelong Cats, " +
"match 2 now hosts Adelaide Crows.",
});
});
it("reports a slot that was emptied without being refilled", async () => {
// Un-recording an Elimination Final result takes its winner back out of the semi.
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
vacated: [1, 2],
filled: [{ matchNumber: 2, participantId: "adelaide" }],
});
expect(await run()).toEqual({
success:
"Re-seeded the Semi-Finals: match 1 is back to TBD, " +
"match 2 now hosts Adelaide Crows.",
});
});
it("says so when the pairings are already right", async () => {
vi.mocked(reseedAflSemiFinals).mockResolvedValue({ vacated: [], filled: [] });
expect(await run()).toEqual({
success: "Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
});
});
it("scores nothing and announces nothing", async () => {
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
vacated: [1, 2],
filled: [{ matchNumber: 1, participantId: "geelong" }],
});
await run();
expect(processMatchResult).not.toHaveBeenCalled();
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
expect(sendDiscordWebhook).not.toHaveBeenCalled();
});
it("refuses a bracket that is not an AFL finals bracket", async () => {
vi.mocked(getScoringEventById).mockResolvedValue({
...EVENT,
bracketTemplateId: "nfl_14",
} as never);
expect(await run()).toEqual({
error: "This action only applies to AFL finals brackets",
});
expect(reseedAflSemiFinals).not.toHaveBeenCalled();
});
it("surfaces a refusal to re-seed a game that has been played", async () => {
vi.mocked(reseedAflSemiFinals).mockRejectedValue(
new Error("Semi-Finals match 1 already has a recorded result")
);
expect(await run()).toEqual({
error: "Semi-Finals match 1 already has a recorded result",
});
});
});

View file

@ -1,133 +0,0 @@
/**
* The Re-seed Wildcard Winners admin action.
*
* Advancement pairs the Wildcard winners with 5th and 6th by ladder position on every
* result, so this action exists for brackets advanced before that rule: their winners sit
* in the wrong Elimination Finals and nothing re-runs advancement, because a completed
* match cannot be re-submitted from the UI.
*
* It moves qualifier slots only no scoring runs, so nothing reaches Discord.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { reseedAflEliminationFinals } from "~/models/playoff-match";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getScoringEventById } from "~/models/scoring-event";
import { processMatchResult, recalculateAffectedLeagues } from "~/models/scoring-calculator";
import { sendDiscordWebhook } from "~/services/discord";
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
vi.mock("~/models/scoring-event", async (importOriginal) => ({
...(await importOriginal<object>()),
getScoringEventById: vi.fn(),
isReadOnlySibling: vi.fn(() => false),
}));
vi.mock("~/models/playoff-match", async (importOriginal) => ({
...(await importOriginal<object>()),
reseedAflEliminationFinals: vi.fn(),
}));
vi.mock("~/models/season-participant", async (importOriginal) => ({
...(await importOriginal<object>()),
findParticipantsBySportsSeasonId: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
...(await importOriginal<object>()),
processMatchResult: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
}));
vi.mock("~/services/discord", async (importOriginal) => ({
...(await importOriginal<object>()),
sendDiscordWebhook: vi.fn(),
}));
const params = { id: "season-1", eventId: "event-1" };
const EVENT = {
id: "event-1",
name: "AFL Finals",
sportsSeasonId: "season-1",
isQualifyingEvent: false,
bracketTemplateId: "afl_10",
};
function request() {
const body = new FormData();
body.set("intent", "reseed-afl-wildcard");
return new Request("http://localhost/bracket", { method: "POST", body });
}
const run = () => action({ request: request(), params } as never);
describe("reseed-afl-wildcard", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never);
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
{ id: "carlton", name: "Carlton Blues" },
{ id: "bulldogs", name: "Western Bulldogs" },
] as never);
});
it("names the teams that moved", async () => {
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({
vacated: [1, 2],
filled: [
{ matchNumber: 2, participantId: "bulldogs" },
{ matchNumber: 1, participantId: "carlton" },
],
});
const result = await run();
expect(reseedAflEliminationFinals).toHaveBeenCalledWith("event-1");
expect(result).toEqual({
success:
"Re-seeded the Elimination Finals: match 1 now hosts Carlton Blues, " +
"match 2 now hosts Western Bulldogs.",
});
});
it("says so when the pairings are already right", async () => {
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({ vacated: [], filled: [] });
expect(await run()).toEqual({
success: "Elimination Finals already match the Wildcard results — nothing to re-seed.",
});
});
it("scores nothing and announces nothing", async () => {
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({
vacated: [1, 2],
filled: [{ matchNumber: 1, participantId: "carlton" }],
});
await run();
expect(processMatchResult).not.toHaveBeenCalled();
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
expect(sendDiscordWebhook).not.toHaveBeenCalled();
});
it("refuses a bracket that is not an AFL finals bracket", async () => {
vi.mocked(getScoringEventById).mockResolvedValue({
...EVENT,
bracketTemplateId: "nfl_14",
} as never);
expect(await run()).toEqual({
error: "This action only applies to AFL finals brackets",
});
expect(reseedAflEliminationFinals).not.toHaveBeenCalled();
});
it("surfaces a refusal to re-seed a game that has been played", async () => {
vi.mocked(reseedAflEliminationFinals).mockRejectedValue(
new Error("Elimination Finals match 1 already has a recorded result")
);
expect(await run()).toEqual({
error: "Elimination Finals match 1 already has a recorded result",
});
});
});

View file

@ -31,7 +31,7 @@ import {
projectedWinsToElo,
} from '~/services/probability-engine';
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
import { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from '~/models/simulator';
import { getSportsSeasonSimulatorConfig } from '~/models/simulator';
// Simulator types that use worldRanking in addition to sourceElo
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
@ -80,38 +80,13 @@ export async function loader({ params }: Route.LoaderArgs) {
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
const simulatorInputs = await getParticipantSimulatorInputs(sportsSeasonId);
// The projection a participant was actually saved with. Read it back verbatim:
// deriving the field from the stored Elo instead (as this page used to) shows the
// admin a different number than they typed, because wins → Elo rounds to an
// integer Elo and a simulation run then re-resolves that Elo through the input
// policy (clamping, and blending in futures odds when a season has them).
const projectionsByParticipant = new Map(
simulatorInputs.map((input) => [
input.participantId,
{ projectedWins: input.projectedWins, projectedTablePoints: input.projectedTablePoints },
])
);
const existingData: Record<
string,
{ elo: number | null; ranking: number | null; projectedWins: number | null; projectedTablePoints: number | null }
> = {};
for (const participant of participants) {
const projection = projectionsByParticipant.get(participant.id);
existingData[participant.id] = {
elo: null,
ranking: null,
projectedWins: projection?.projectedWins ?? null,
projectedTablePoints: projection?.projectedTablePoints ?? null,
};
}
const existingData: Record<string, { elo: number | null; ranking: number | null }> = {};
for (const ev of existingEVs) {
const existing = existingData[ev.participantId];
if (!existing) continue;
existing.elo = ev.sourceElo ?? null;
existing.ranking = ev.worldRanking ?? null;
existingData[ev.participantId] = {
elo: ev.sourceElo ?? null,
ranking: ev.worldRanking ?? null,
};
}
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
@ -277,16 +252,7 @@ export default function AdminSportsSeasonEloRatings() {
if (simulatorConfig) {
participants.forEach(p => {
const d = existingData[p.id];
// A stored projection is shown exactly as it was entered. Only fall back to
// deriving it from the Elo when this season has no projection saved (a
// season that has only ever had Elos entered still gets a useful starting
// point) — that derived value is lossy and must never overwrite a real one.
const stored = simulatorConfig.projectionInput === 'tablePoints'
? d?.projectedTablePoints
: d?.projectedWins;
if (stored !== null && stored !== undefined) {
initial[p.id] = stored.toString();
} else if (d?.elo !== null && d?.elo !== undefined) {
if (d?.elo !== null && d?.elo !== undefined) {
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
: eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
@ -299,8 +265,8 @@ export default function AdminSportsSeasonEloRatings() {
const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<{
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }>;
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }>;
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>;
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }>;
} | null>(null);
function findParticipantMatch(inputName: string) {
@ -325,8 +291,8 @@ export default function AdminSportsSeasonEloRatings() {
function parseBulkText() {
const lines = bulkText.split('\n');
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }> = [];
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }> = [];
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = [];
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }> = [];
const seen = new Set<string>();
for (const line of lines) {
@ -349,9 +315,9 @@ export default function AdminSportsSeasonEloRatings() {
const participant = findParticipantMatch(inputName);
if (participant && !seen.has(participant.id)) {
seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, projection: projectedWins, inputName });
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, inputName });
} else if (!participant) {
unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
unmatched.push({ inputName, elo, ranking: null });
}
} else {
const match = usesRanking
@ -376,9 +342,9 @@ export default function AdminSportsSeasonEloRatings() {
const participant = findParticipantMatch(inputName);
if (participant && !seen.has(participant.id)) {
seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, projection: null, inputName });
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName });
} else if (!participant) {
unmatched.push({ inputName, elo, ranking, projection: null });
unmatched.push({ inputName, elo, ranking });
}
}
}
@ -394,11 +360,11 @@ export default function AdminSportsSeasonEloRatings() {
for (const m of parseResults.matched) {
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
// The pasted number goes in as typed. Round-tripping it through the derived
// Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
// came back as 95.1 before anything was even saved.
if (inputMode === 'projectedWins' && m.projection !== null) {
newWins[m.participantId] = m.projection.toString();
if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) {
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
).toFixed(1);
}
}
setEloValues(newElos);
@ -523,10 +489,7 @@ Mark Selby, 2432`
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
<span className="text-muted-foreground">{m.inputName}</span>
<span className="font-medium">
{m.name} &rarr;{' '}
{m.projection !== null
? `${m.projection} ${projectionUnit} (Elo ${m.elo})`
: m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
{m.name} &rarr; {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
</span>
</div>
@ -546,9 +509,7 @@ Mark Selby, 2432`
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
<span>{u.inputName}</span>
<span className="font-medium">
{u.projection !== null
? `${u.projection} ${projectionUnit} (Elo ${u.elo})`
: u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
{u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
</span>
</div>
@ -579,7 +540,7 @@ Mark Selby, 2432`
</CardTitle>
<CardDescription>
{inputMode === 'projectedWins'
? `Enter each team's projected total season ${projectionUnit} — the number you enter is stored as-is and re-derives the Elo on every run. Mid-season it is treated as a projected final total, so the simulation spreads the difference over the games still to play. Saving will run the simulation and update expected values.`
? `Enter each team's projected total season ${projectionUnit}. Converted to Elo automatically. Saving will run the simulation and update expected values.`
: usesRanking
? `Enter each ${participantLabel.toLowerCase()}'s Elo${allowsRankOnly ? ' (optional)' : ''} and ${rankLabel}. Saving will automatically run the simulation and update expected values.`
: `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`}

View file

@ -16,8 +16,6 @@ import {
findPlayoffMatchById,
assignParticipantsToKnockout,
doesLoserAdvance,
reseedAflEliminationFinals,
reseedAflSemiFinals,
} from "~/models/playoff-match";
import {
createGame,
@ -868,101 +866,6 @@ export async function action({ request, params }: Route.ActionArgs) {
}
}
// Re-seed the AFL Wildcard winners into the Elimination Finals they belong in.
// Advancement does this on every Wildcard result, so this is only needed for a
// bracket advanced before that rule existed: the winners sit in the wrong games and
// no admin action re-runs advancement (a completed match cannot be re-submitted).
if (intent === "reseed-afl-wildcard") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" };
if (event.bracketTemplateId !== "afl_10") {
return { error: "This action only applies to AFL finals brackets" };
}
const participants = await findParticipantsBySportsSeasonId(params.id);
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
const reseed = await reseedAflEliminationFinals(params.eventId);
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
return {
success:
"Elimination Finals already match the Wildcard results — nothing to re-seed.",
};
}
// Only the qualifier slots move, so there is nothing to re-score: no placement,
// score or elimination changes, and so nothing to announce.
const moves = reseed.filled
.toSorted((a, b) => a.matchNumber - b.matchNumber)
.map((slot) => `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`)
.join(", ");
return {
success: `Re-seeded the Elimination Finals: ${moves}.`,
};
} catch (error) {
logger.error("Error re-seeding AFL Wildcard winners:", error);
return {
error:
error instanceof Error ? error.message : "Failed to re-seed the Elimination Finals",
};
}
}
// Put the Elimination Final winners in the Semi-Finals they belong in. Elimination
// Final n feeds Semi-Final n, but brackets advanced before that was fixed crossed the
// two winners, and no admin action re-runs advancement (a completed match cannot be
// re-submitted).
if (intent === "reseed-afl-semifinals") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" };
if (event.bracketTemplateId !== "afl_10") {
return { error: "This action only applies to AFL finals brackets" };
}
const participants = await findParticipantsBySportsSeasonId(params.id);
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
const reseed = await reseedAflSemiFinals(params.eventId);
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
return {
success:
"Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
};
}
// Only the qualifier slots move, so there is nothing to re-score: no placement,
// score or elimination changes, and so nothing to announce.
//
// A slot can be vacated without being refilled — un-recording an Elimination Final
// result takes its winner back out — so report those too rather than rendering an
// empty list.
const filled = reseed.filled.map((slot) => ({
matchNumber: slot.matchNumber,
text: `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`,
}));
const emptied = reseed.vacated
.filter((matchNumber) => !reseed.filled.some((slot) => slot.matchNumber === matchNumber))
.map((matchNumber) => ({ matchNumber, text: `match ${matchNumber} is back to TBD` }));
const moves = [...filled, ...emptied]
.toSorted((a, b) => a.matchNumber - b.matchNumber)
.map((move) => move.text)
.join(", ");
return {
success: `Re-seeded the Semi-Finals: ${moves}.`,
};
} catch (error) {
logger.error("Error re-seeding AFL Elimination Finals winners:", error);
return {
error:
error instanceof Error ? error.message : "Failed to re-seed the Semi-Finals",
};
}
}
if (intent === "reprocess-bracket") {
try {
const event = await getScoringEventById(params.eventId);

View file

@ -613,56 +613,6 @@ export default function EventBracket({
</Card>
)}
{/* Re-seed AFL Wildcard winners. Advancement pairs them by ladder position on
every Wildcard result, so this is only for a bracket advanced before that
rule existed a completed match cannot be re-submitted to re-run it. */}
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Re-seed Wildcard Winners</CardTitle>
<CardDescription>
Pair the Elimination Finals by ladder position: 5th hosts the
lower-ranked Wildcard winner and 6th the higher-ranked one. Only moves
the qualifier slots no results, scores or placements change, and
nothing is announced. Does nothing if the pairings are already right.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="reseed-afl-wildcard" />
<Button type="submit" variant="outline">
Re-seed Wildcard Winners
</Button>
</Form>
</CardContent>
</Card>
)}
{/* Fix the Semi-Final pairings. Elimination Final n feeds Semi-Final n, but
brackets advanced before that was fixed crossed the two winners, and no
admin action re-runs advancement. */}
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Fix Semi-Final Pairings</CardTitle>
<CardDescription>
Feed each Elimination Final into the Semi-Final it belongs to: EF1
winner into SF1 and EF2 winner into SF2. Only moves the qualifier slots
no results, scores or placements change, and nothing is announced.
Does nothing if the pairings are already right.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="reseed-afl-semifinals" />
<Button type="submit" variant="outline">
Fix Semi-Final Pairings
</Button>
</Form>
</CardContent>
</Card>
)}
{/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else
can rewrite a match's participants, so a wrong seeding has to be torn down
and rebuilt via the setup form below, which reappears once this runs. */}

View file

@ -1,86 +0,0 @@
/**
* Pure helpers for the Simulator Setup page, split out so they can be unit tested
* without pulling the route's server-only imports into the test.
*/
import type {
BaseEloKey,
ResolvedRating,
ResolvedSourceElo,
} from "~/services/simulations/input-policy";
/**
* Short badge text for how a participant's Elo or rating was produced, or null for a
* directly entered one the unremarkable case, which needs no badge.
*
* The preview table needs this because a generated value is deliberately hidden from
* `getParticipantSimulatorInputs`, so without the resolved value plus this label the
* row reads as "nothing saved" and a projection losing to a raw Elo is invisible.
*
* Every remaining method is a missing-input fallback (`fallbackElo`,
* `fallbackRating`, `averageKnown`, `worstKnownMinus`, `block`), which all read the
* same way to an admin: this participant had nothing usable of its own.
*/
export function resolvedInputMethodLabel(
method: ResolvedSourceElo["method"] | ResolvedRating["method"]
): string | null {
switch (method) {
case "direct":
return null;
case "projectedWins":
case "projectedTablePoints":
return "from projections";
case "sourceOdds":
return "from futures";
case "blend":
return "blended";
default:
return "fallback";
}
}
/**
* Method flag for a bulk-input row that carries a projection instead of an Elo, or
* undefined when the row says nothing about how its Elo was produced.
*
* A row supplying a projection but no explicit Elo means "derive the Elo from this
* projection". Stamping the flag marks whatever Elo is already stored as generated,
* so `getParticipantSimulatorInputs` hides it and `resolveSourceElos` re-derives
* from the projection without it, the non-destructive upsert leaves a stale
* hand-entered Elo in place, and that Elo wins the `baseEloPriority` race so the
* projection is written to the database and then ignored on every run.
*
* Returning undefined (rather than an empty object) matters: the upsert only
* preserves existing metadata, and clears a stale flag for a fresh direct Elo, when
* the incoming metadata is null.
*/
export function projectionMethodMetadata(
sourceElo: number | undefined,
projectedWins: number | undefined,
projectedTablePoints: number | undefined
): Record<string, unknown> | undefined {
if (sourceElo !== undefined) return undefined;
if (projectedWins !== undefined) return { sourceEloMethod: "projectedWins" };
if (projectedTablePoints !== undefined) return { sourceEloMethod: "projectedTablePoints" };
return undefined;
}
/**
* Translate the Base Elo Source select into a full `baseEloPriority` list. Only the
* head of the list is user-facing (raw Elo vs. projections); the remaining keys keep
* their existing relative order so a season that already has a custom ordering is
* not silently flattened.
*/
export function parseBaseEloPriorityChoice(
value: FormDataEntryValue | null,
current: BaseEloKey[]
): BaseEloKey[] {
// The select only renders for simulators that can derive Elo from a projection.
// When it was not on the form there is no choice to apply, so keep what is stored
// rather than silently rewriting the season's ordering.
if (value === null) return current;
const projections = current.filter((key) => key !== "sourceElo");
return value === "projectionsFirst"
? [...projections, "sourceElo"]
: ["sourceElo", ...projections];
}

View file

@ -32,17 +32,10 @@ import {
} from "~/services/simulations/manifest";
import {
getSimulatorInputPolicy,
resolveRatings,
resolveSourceElos,
type MissingEloStrategy,
type MissingRatingStrategy,
} from "~/services/simulations/input-policy";
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
import {
parseBaseEloPriorityChoice,
projectionMethodMetadata,
resolvedInputMethodLabel,
} from "./admin.sports-seasons.$id.simulator.helpers";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Simulator Setup - ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
@ -82,64 +75,13 @@ export async function loader({ params }: Route.LoaderArgs) {
...config.profile.requiredInputs,
...config.profile.optionalInputs,
]);
// The Elo each participant will actually run with, and which source produced it.
// Without this the preview is misleading: getParticipantSimulatorInputs blanks a
// generated Elo (so it is re-derived rather than frozen), which reads as "nothing
// saved" — and a raw Elo silently beating a projection is invisible.
//
// Keyed off `relevantInputs`, not requiredInputs: the preview renders these
// columns solely from these maps, so gating on "required" would blank a stored
// value for every simulator that treats the input as optional (playoff_bracket
// and ncaam_bracket for Elo, golf_qualifying_points for rating).
const resolvedEloRows = Object.fromEntries(
relevantInputs.has("sourceElo")
? [...resolveSourceElos(inputs, config.profile, config.config).values()].map((resolved) => [
resolved.participantId,
{ sourceElo: resolved.sourceElo, method: resolved.method },
])
: []
);
// Same for ratings, which are blanked by the same rule when generated. The
// preview's "missing a required input" marker reads both, so it agrees with
// readiness instead of flagging every participant a projection resolved.
const resolvedRatingRows = Object.fromEntries(
relevantInputs.has("rating")
? [...resolveRatings(inputs, config.profile, config.config).values()].map((resolved) => [
resolved.participantId,
{ rating: resolved.rating, method: resolved.method },
])
: []
);
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
key,
label: simulatorInputLabel(key),
required: config.profile.requiredInputs.includes(key),
}));
// The projection this simulator can derive Elo from, labelled here for the same
// reason as inputColumns: calling simulatorInputLabel from the rendered component
// would pull the manifest (and through it the registry and every simulator) into
// the client bundle.
const projectionEloKey = (config.profile.derivableInputs?.sourceElo ?? []).find(
(key) => key === "projectedWins" || key === "projectedTablePoints"
);
const projectionEloOption = projectionEloKey
? { key: projectionEloKey, label: simulatorInputLabel(projectionEloKey) }
: null;
return {
sportsSeason,
participants,
config,
inputRows,
readiness,
inputPolicy,
inputColumns,
resolvedEloRows,
resolvedRatingRows,
projectionEloOption,
};
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy, inputColumns };
}
interface ActionData {
@ -182,7 +124,6 @@ const HONORED_ENGINE_KNOBS = new Set([
"baseDrawRate",
"drawDecay",
"ratingScaleFactor",
"projectedWinsWeight",
]);
function parseOptionalNumber(value: string | undefined): number | null {
@ -291,28 +232,17 @@ function parseInputCsv(
continue;
}
const sourceElo = parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined;
const projectedWins = parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined;
const projectedTablePoints = parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined;
inputs.push({
participantId,
sportsSeasonId,
sourceElo,
sourceElo: parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined,
sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined,
worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined,
rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined,
projectedWins,
projectedTablePoints,
projectedWins: parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined,
projectedTablePoints: parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined,
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined,
region: cols[indexes.get("region") ?? -1] || undefined,
// A row that supplies a projection but no explicit Elo means "derive the Elo
// from this projection". Stamping the method flag marks whatever Elo is
// already stored as generated, so getParticipantSimulatorInputs hides it and
// resolveSourceElos re-derives from the projection instead of letting a stale
// Elo win the baseEloPriority race. Mirrors the Elo Ratings page's
// projections mode.
metadata: projectionMethodMetadata(sourceElo, projectedWins, projectedTablePoints),
});
}
@ -382,7 +312,6 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
...currentPolicy,
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
baseEloPriority: parseBaseEloPriorityChoice(formData.get("baseEloPriority"), currentPolicy.baseEloPriority),
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
@ -455,7 +384,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const isSubmitting = navigation.state === "submitting";
const setupSections = config.profile.setupSections;
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
const projectionsOutrankElo = inputPolicy.baseEloPriority[0] !== "sourceElo";
const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
const showsInputPolicy =
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
@ -469,7 +397,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
// Preview columns are resolved server-side in the loader (see note there) and
// arrive as plain data, so this client component never imports the manifest.
const { inputColumns, resolvedEloRows, resolvedRatingRows, projectionEloOption } = loaderData;
const { inputColumns } = loaderData;
const requiredInputs = config.profile.requiredInputs;
const gridTemplate = `2fr repeat(${Math.max(inputColumns.length, 1)}, 1fr)`;
// For this sport the inputs live on a dedicated page, not the shared bulk paste.
@ -481,17 +409,8 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
: null)
: null;
// A required Elo/rating counts as present when the input policy resolves one,
// not only when it is stored directly: getParticipantSimulatorInputs deliberately
// blanks a generated value so it is re-derived each run, so reading the raw input
// alone would mark every projection-configured participant as missing.
const isRowIncomplete = (participantId: string, input: (typeof inputRows)[number]["input"]) =>
requiredInputs.some((key) => {
if (input?.[key] !== null && input?.[key] !== undefined) return false;
if (key === "sourceElo") return resolvedEloRows[participantId] === undefined;
if (key === "rating") return resolvedRatingRows[participantId] === undefined;
return true;
});
const isRowIncomplete = (input: (typeof inputRows)[number]["input"]) =>
requiredInputs.some((key) => input?.[key] === null || input?.[key] === undefined);
const [search, setSearch] = useState("");
const [onlyMissing, setOnlyMissing] = useState(false);
@ -501,11 +420,11 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const normalizedSearch = normalizeName(search);
return inputRows.filter(({ participant, input }) => {
if (normalizedSearch && !normalizeName(participant.name).includes(normalizedSearch)) return false;
if (onlyMissing && !isRowIncomplete(participant.id, input)) return false;
if (onlyMissing && !isRowIncomplete(input)) return false;
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputRows, search, onlyMissing, requiredInputs, resolvedEloRows, resolvedRatingRows]);
}, [inputRows, search, onlyMissing, requiredInputs]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PARTICIPANT_PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
@ -663,26 +582,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
this Elo they are not blended again per game.
</p>
</div>
{projectionEloOption && (
<div className="space-y-2 md:col-span-5">
<Label htmlFor="baseEloPriority">Base Elo Source</Label>
<select
id="baseEloPriority"
name="baseEloPriority"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={projectionsOutrankElo ? "projectionsFirst" : "eloFirst"}
>
<option value="eloFirst">Entered Elo first, then {projectionEloOption.label}</option>
<option value="projectionsFirst">{projectionEloOption.label} first, then entered Elo</option>
</select>
<p className="text-xs text-muted-foreground">
Raw Elo and projections are substitutes the first one a participant has wins, and
the other is ignored (futures odds are separate and blend on top via the weight above).
Pick <strong>{projectionEloOption.label} first</strong> when projections are
the source of truth for this season and a previously entered Elo should not override them.
</p>
</div>
)}
{config.profile.requiredInputs.includes("sourceElo") && (
<>
<div className="space-y-2 md:col-span-2">
@ -872,7 +771,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</div>
) : (
pageRows.map(({ participant, input }) => {
const incomplete = isRowIncomplete(participant.id, input);
const incomplete = isRowIncomplete(input);
return (
<div
key={participant.id}
@ -885,34 +784,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</div>
{inputColumns.length > 0 ? (
inputColumns.map((column) => {
if (column.key === "sourceElo") {
const resolved = resolvedEloRows[participant.id];
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
return (
<div key={column.key} className="flex items-center gap-1.5">
{resolved ? resolved.sourceElo : "—"}
{methodLabel && (
<Badge variant="outline" className="text-[10px] font-normal">
{methodLabel}
</Badge>
)}
</div>
);
}
if (column.key === "rating") {
const resolved = resolvedRatingRows[participant.id];
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
return (
<div key={column.key} className="flex items-center gap-1.5">
{resolved ? resolved.rating : "—"}
{methodLabel && (
<Badge variant="outline" className="text-[10px] font-normal">
{methodLabel}
</Badge>
)}
</div>
);
}
const value = input?.[column.key];
return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>;
})

View file

@ -8,9 +8,6 @@ import * as participantEVModel from "~/models/participant-expected-value";
// Mock the dependencies
vi.mock("~/models/participant-result");
vi.mock("~/models/participant-expected-value");
vi.mock("~/models/simulator");
vi.mock("~/models/sports-season");
vi.mock("~/services/simulations/runner");
vi.mock("~/database/context", () => ({
database: () => ({
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", () => {
beforeEach(() => {
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);
});
});

View file

@ -22,7 +22,6 @@ import {
processQualifyingBracketEvent,
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import {
@ -284,11 +283,6 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
eventId: event.id,
eventName: event.name ?? undefined,
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
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
: 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 };

View file

@ -21,10 +21,6 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
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
@ -101,40 +97,6 @@ function createFinishedProbabilities(finalPosition: number): number[] {
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
*
@ -142,8 +104,7 @@ async function shouldRerunSimulator(sportsSeasonId: string): Promise<boolean> {
* 1. Get all participant results (finished participants)
* 2. Get all existing participant EVs
* 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,
* otherwise recalculate using ICM with remaining participants
* 4. For unfinished participants: recalculate using ICM with remaining participants
*
* @param sportsSeasonId Sports season to update
* @param recalculateUnfinished Whether to recalculate unfinished participants (default true)
@ -177,49 +138,39 @@ export async function updateProbabilitiesAfterResult(
.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
if (recalculateUnfinished) {
const unfinishedEVs = existingEVs.filter(
ev => !finishedMap.has(ev.participantId)
);
if (unfinishedEVs.length > 0 && (await shouldRerunSimulator(sportsSeasonId))) {
// The simulator reads the bracket, so it already knows this result: it seeds from the
// real draw and replays every completed match. Re-running it keeps each participant's
// distribution consistent with the games actually played — including the placement
// floors a bracket entry or a non-scoring-round win has already banked, which the ICM
// branch below cannot see and would value below points the league has paid out.
//
// Imported lazily: probability-updater → runner → scoring-calculator →
// probability-updater is a module cycle, and a static import leaves the binding
// undefined at module-init time.
try {
const { runSportsSeasonSimulation } = await import("~/services/simulations/runner");
// Probabilities only. Our callers recalculate standings themselves right after this,
// and recalculateAffectedLeagues detects change by diffing teamStandings across its
// own recalculation — a recalculation slipped in here empties that diff and silently
// suppresses the Discord standings post, and rolls previousRank forward a second time
// so rank movement disappears. The daily EV snapshot is not ours to write either: it
// is keyed by date, so writing it per result overwrites the day with intra-day values.
await runSportsSeasonSimulation(sportsSeasonId, {
skipStandingsRecalc: true,
skipSnapshots: true,
});
updated += unfinishedEVs.length;
} catch (error) {
// A run already in flight, failed readiness, or a bracket the simulator refuses to
// read (afl_10 seeded into only some of its slots). Leave the existing probabilities
// alone rather than falling back to ICM — for these seasons ICM is precisely the
// thing being replaced, and reintroducing it here would reintroduce sub-floor EVs.
// Completed seasons never reach this: shouldRerunSimulator excludes them.
logger.error(
`[ProbabilityUpdater] Failed to re-run simulator for sports season ${sportsSeasonId}; ` +
`leaving existing probabilities in place:`,
error
);
errors.push(`Failed to re-run simulator for sports season ${sportsSeasonId}: ${error}`);
}
} else if (unfinishedEVs.length > 0) {
if (unfinishedEVs.length > 0) {
// Get their current championship probabilities (use existing P(1st) as proxy)
const unfinishedOdds = unfinishedEVs.map(ev => {
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 {
finishedParticipants: finishedMap.size,
unfishedParticipants: existingEVs.length - finishedMap.size,

View file

@ -1,15 +1,6 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { normalizeTeamName } from "~/lib/normalize-team-name";
import {
getTeamData,
eloWinProbability,
AFLSimulator,
readAflBracketSeeds,
simAFLFinals,
type BracketMatch,
} from "../afl-simulator";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
import { calculateEV, type ProbabilityDistribution } from "~/services/ev-calculator";
import { getTeamData, eloWinProbability, AFLSimulator } from "../afl-simulator";
// ─── normalizeTeamName ────────────────────────────────────────────────────────
@ -134,82 +125,8 @@ const PARTICIPANT_ROWS = AFL_TEAMS.map((name, i) => ({
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()", () => {
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);
}
let mockDb: { select: MockInstance };
beforeEach(async () => {
const { database } = await import("~/database/context");
@ -219,11 +136,6 @@ describe("AFLSimulator.simulate()", () => {
let selectCallCount = 0;
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(() => {
selectCallCount++;
if (selectCallCount === 1) {
@ -443,259 +355,4 @@ describe("AFLSimulator.simulate()", () => {
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
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/);
});
});
// ─── simAFLFinals ─────────────────────────────────────────────────────────────
describe("simAFLFinals bracket pathways", () => {
const finalists = Array.from({ length: 10 }, (_, i) => ({
id: `s${i + 1}`,
name: `s${i + 1}`,
elo: 1500,
currentWins: 0,
remainingGames: 0,
winProb: 0.5,
}));
/**
* Play the finals with the Wildcard Round forced to the given winners (every other
* game goes to whoever was routed in first), and report who met whom.
*/
function pairingsWith(wc1Winner: string, wc2Winner: string): Map<string, [string, string]> {
const pairings = new Map<string, [string, string]>();
const play = (
round: string,
matchNumber: number,
t1: { id: string },
t2: { id: string }
) => {
pairings.set(`${round}#${matchNumber}`, [t1.id, t2.id]);
if (round === "Wildcard Round") {
const forced = matchNumber === 1 ? wc1Winner : wc2Winner;
return t1.id === forced ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
}
return { winner: t1, loser: t2 };
};
simAFLFinals(finalists as never, play as never);
return pairings;
}
it("draws the Wildcard Round 7v10 and 8v9", () => {
const pairings = pairingsWith("s7", "s8");
expect(pairings.get("Wildcard Round#1")).toEqual(["s7", "s10"]);
expect(pairings.get("Wildcard Round#2")).toEqual(["s8", "s9"]);
});
it.each([
{ wc1: "s7", wc2: "s8", ef1: "s8", ef2: "s7" },
{ wc1: "s7", wc2: "s9", ef1: "s9", ef2: "s7" },
// 10th beating 7th is where a fixed crossover misfires: it would send 10th to 6th
// and leave 5th with the stronger survivor.
{ wc1: "s10", wc2: "s8", ef1: "s10", ef2: "s8" },
{ wc1: "s10", wc2: "s9", ef1: "s10", ef2: "s9" },
])(
"pairs 5th with $ef1 and 6th with $ef2 when $wc1 and $wc2 win through",
({ wc1, wc2, ef1, ef2 }) => {
const pairings = pairingsWith(wc1, wc2);
expect(pairings.get("Elimination Finals#1")).toEqual(["s5", ef1]);
expect(pairings.get("Elimination Finals#2")).toEqual(["s6", ef2]);
}
);
// The pathway out of the Elimination Finals is fixed (EF n → SF n) — unlike the
// Wildcard Round's re-seed. The crossover lands a round later, at the Prelims, so a
// Qualifying Final loser cannot meet the side that just beat it. `play` here hands
// every non-Wildcard game to participant1, so QF1 sends s1 through and s4 down.
it("feeds each Elimination Final into the Semi-Final of the same number", () => {
const pairings = pairingsWith("s7", "s8");
expect(pairings.get("Semi-Finals#1")).toEqual(["s4", "s5"]);
expect(pairings.get("Semi-Finals#2")).toEqual(["s3", "s6"]);
});
it("crosses the Semi-Final winners over into the Preliminary Finals", () => {
const pairings = pairingsWith("s7", "s8");
expect(pairings.get("Preliminary Finals#1")).toEqual(["s1", "s3"]);
expect(pairings.get("Preliminary Finals#2")).toEqual(["s2", "s4"]);
});
});

View file

@ -26,33 +26,6 @@ describe("simulator input policy", () => {
expect(resolved.get("team-1")).toMatchObject({ sourceElo: 1600, method: "direct" });
});
it("puts projections ahead of a stored Elo when baseEloPriority says so", () => {
// The season-level escape hatch for "projections are the source of truth here":
// without it a stale hand-entered Elo silently beats a fresh projection.
const resolved = resolveSourceElos(
[{ participantId: "team-1", sourceElo: 1600, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],
profile,
{ seasonGames: 82, parityFactor: 400, inputPolicy: { baseEloPriority: ["projectedWins", "sourceElo"] } }
);
expect(resolved.get("team-1")?.method).toBe("projectedWins");
expect(resolved.get("team-1")?.sourceElo).not.toBe(1600);
});
it("still falls back to the stored Elo for participants without a projection", () => {
const resolved = resolveSourceElos(
[
{ participantId: "projected", sourceElo: 1600, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null },
{ participantId: "elo-only", sourceElo: 1600, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
],
profile,
{ seasonGames: 82, parityFactor: 400, inputPolicy: { baseEloPriority: ["projectedWins", "sourceElo"] } }
);
expect(resolved.get("projected")?.method).toBe("projectedWins");
expect(resolved.get("elo-only")).toMatchObject({ sourceElo: 1600, method: "direct" });
});
it("derives Elo from projected wins when Elo is missing", () => {
const resolved = resolveSourceElos(
[{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],

View file

@ -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", () => {
for (const simulatorType of SIMULATOR_TYPES) {
const profile = SIMULATOR_MANIFEST[simulatorType];

View file

@ -7,8 +7,6 @@ import {
rawWinRateFromElo,
rdifWinProbability,
eloToRDif,
projectionForSeeding,
seedingWinRateFor,
sampleBinomial,
simBo3,
simBo5,
@ -283,8 +281,8 @@ describe("sampleBinomial", () => {
// ─── Series simulators ────────────────────────────────────────────────────────
const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0, projectedWins: null };
const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0, projectedWins: null };
const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0 };
const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0 };
const alwaysA = () => 1.0; // team A always wins each game
const alwaysB = () => 0.0; // team B always wins each game
const coinFlip = () => 0.5;
@ -348,159 +346,9 @@ describe("eloToRDif", () => {
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
});
it("lands on the same run-differential scale as the hardcoded TEAMS_DATA rdif", () => {
// 95 projected wins out of 162 → Elo ≈ 1561. On the TEAMS_DATA scale that is a
// ~+140 run differential, right alongside the Dodgers' hardcoded +137 — not the
// ~+686 the old RDIF_DIVISOR scaling produced.
const winRate = 95 / 162;
const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate);
expect(eloToRDif(elo)).toBeGreaterThan(120);
expect(eloToRDif(elo)).toBeLessThan(160);
});
it("is compressed by winRateFromRDif for playoff matchups, like a hardcoded rdif", () => {
// The whole point of RDIF_DIVISOR: playoff series are near coin-flips between
// playoff-calibre teams. An Elo-rated team must not skip that compression.
const winRate = 95 / 162;
const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate);
const playoffRate = winRateFromRDif(eloToRDif(elo));
expect(playoffRate).toBeCloseTo(0.517, 2);
// Strictly compressed relative to the team's raw season win rate.
expect(playoffRate).toBeLessThan(rawWinRateFromElo(elo));
});
it("agrees with the hardcoded rdif path for a team of equivalent strength", () => {
// Dodgers: hardcoded +137. An Elo carrying the same seeding win rate should
// produce a comparable playoff win rate rather than a wildly more dominant one.
const dodgers = getTeamData("Los Angeles Dodgers");
const eloEquivalent = 1500 + 400 * Math.log10(
rawWinRateFromRDif(dodgers?.rdif ?? 0) / (1 - rawWinRateFromRDif(dodgers?.rdif ?? 0))
);
expect(winRateFromRDif(eloToRDif(eloEquivalent))).toBeCloseTo(
winRateFromRDif(dodgers?.rdif ?? 0),
3
);
});
});
// ─── seedingWinRateFor ────────────────────────────────────────────────────────
describe("seedingWinRateFor", () => {
const eloRate = 95 / 162; // ≈ 0.5864 — the rate a 95-win projection implies
it("is a no-op pre-season: the target equals the Elo-implied rate", () => {
expect(seedingWinRateFor(eloRate, 95, 0, 162)).toBeCloseTo(eloRate, 6);
});
it("spreads the shortfall over the remaining games mid-season", () => {
// 60-50 and projected for 95: 35 wins needed in 52 games ≈ .673, well above the
// .586 the season-long Elo implies. Without this the sim finishes around 90.5.
expect(seedingWinRateFor(eloRate, 95, 60, 52)).toBeCloseTo(35 / 52, 6);
});
it("reaches the projection in expectation", () => {
const currentWins = 60;
const remaining = 52;
const rate = seedingWinRateFor(eloRate, 95, currentWins, remaining);
expect(currentWins + rate * remaining).toBeCloseTo(95, 6);
});
it("falls back to the Elo rate once a team has passed its projection", () => {
// Clamping to a floor instead would simulate a 96-40 team to go 0-26 for the
// rest of the season and drop out of the field. The projection is stale, so it
// is dropped rather than obeyed.
expect(seedingWinRateFor(eloRate, 95, 96, 26)).toBe(eloRate);
});
it("falls back to the Elo rate when a team has exactly met its projection", () => {
expect(seedingWinRateFor(eloRate, 95, 95, 26)).toBe(eloRate);
});
it("falls back to the Elo rate when the projection is unreachable", () => {
// 40-70 projected for 95 needs better than 1.000 — the mirror image of the
// case above, and dropped for the same reason.
expect(seedingWinRateFor(eloRate, 95, 40, 52)).toBe(eloRate);
});
it("falls back to the Elo rate when the target is exactly 1.000", () => {
expect(seedingWinRateFor(eloRate, 95, 43, 52)).toBe(eloRate);
});
it("keeps a target just inside the reachable range", () => {
expect(seedingWinRateFor(eloRate, 95, 94, 26)).toBeCloseTo(1 / 26, 6);
});
it("clamps a weight above 1 rather than extrapolating past the target", () => {
const target = 35 / 52;
expect(seedingWinRateFor(eloRate, 95, 60, 52, 3)).toBeCloseTo(target, 6);
expect(seedingWinRateFor(eloRate, 95, 60, 52, 3)).toBe(
seedingWinRateFor(eloRate, 95, 60, 52, 1)
);
});
it("never returns a rate outside (0, 1) for any weight", () => {
for (const weight of [0.25, 0.5, 0.75, 1, 5]) {
for (const [current, remaining] of [[0, 162], [60, 52], [94, 26], [10, 152]]) {
const rate = seedingWinRateFor(eloRate, 95, current, remaining, weight);
expect(rate).toBeGreaterThan(0);
expect(rate).toBeLessThan(1);
}
}
});
it("falls back to the Elo rate with no projection", () => {
expect(seedingWinRateFor(eloRate, null, 60, 52)).toBe(eloRate);
});
it("falls back to the Elo rate when the season is over", () => {
expect(seedingWinRateFor(eloRate, 95, 95, 0)).toBe(eloRate);
});
it("falls back to the Elo rate at weight 0", () => {
expect(seedingWinRateFor(eloRate, 95, 60, 52, 0)).toBe(eloRate);
});
it("blends target and Elo rate at an intermediate weight", () => {
const target = 35 / 52;
expect(seedingWinRateFor(eloRate, 95, 60, 52, 0.5)).toBeCloseTo(
0.5 * target + 0.5 * eloRate,
6
);
});
});
// ─── projectionForSeeding ─────────────────────────────────────────────────────
describe("projectionForSeeding", () => {
it("uses the projection when it alone produced the resolved Elo", () => {
expect(projectionForSeeding(95, { sourceEloMethod: "projectedWins" })).toBe(95);
});
it("ignores a projection that lost the baseEloPriority race", () => {
// The season resolved its Elo from a hand-entered value. Seeding off the
// projection anyway would ignore it as the Elo source while still letting it
// dictate the standings.
expect(projectionForSeeding(95, { sourceEloMethod: "direct" })).toBeNull();
});
it("ignores a projection that was blended with futures odds", () => {
// The blend lives in the Elo; seeding off the raw projection would discard it
// and run seeding and playoff matchups on different strength scales.
expect(projectionForSeeding(95, { sourceEloMethod: "blend" })).toBeNull();
expect(projectionForSeeding(95, { sourceEloMethod: "sourceOdds" })).toBeNull();
});
it("ignores a projection on a participant resolved by a fallback", () => {
expect(projectionForSeeding(95, { sourceEloMethod: "averageKnown" })).toBeNull();
});
it("ignores a projection with no method recorded", () => {
expect(projectionForSeeding(95, null)).toBeNull();
expect(projectionForSeeding(95, undefined)).toBeNull();
expect(projectionForSeeding(95, {})).toBeNull();
});
it("passes a null projection through", () => {
expect(projectionForSeeding(null, { sourceEloMethod: "projectedWins" })).toBeNull();
it("round-trips through winRateFromRDif: winRate(eloToRDif(elo)) ≈ eloWinProb(elo, 1500)", () => {
const elo = 1620;
const expectedWinRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
expect(winRateFromRDif(eloToRDif(elo))).toBeCloseTo(expectedWinRate, 4);
});
});

View file

@ -50,8 +50,6 @@ import {
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
import { recalculateStandings } from "~/models/scoring-calculator";
import { database } from "~/database/context";
import { getSimulator } from "~/services/simulations/registry";
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" }]);
});
/** 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 () => {
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);

View file

@ -3,48 +3,29 @@
*
* 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:
* 1. Load all participants for the sports season from DB
* 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
* Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
* 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
* 5. For each simulation:
* a. Pre-bracket mode only: for each team, simulate remaining regular season games
* (TOTAL_GAMES - gamesPlayed) using Elo win probability vs. an average opponent
* (Elo 1500) projectedPoints = currentWins*4 + simulatedRemainingWins*4
* b. Pre-bracket mode only: sort all 18 teams by projected points desc + random
* 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:
* 4. For each simulation:
* a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed)
* using Elo win probability vs. an average opponent (Elo 1500)
* projectedPoints = currentWins*4 + simulatedRemainingWins*4
* b. Sort all 18 teams by projected points desc + random tiebreaker final ladder
* Top 10 advance to the AFL Finals Series
* c. Simulate AFL Finals Series (AFL_10 bracket):
*
* Wildcard Round: #7 vs #10, #8 vs #9 losers exit (0 pts)
* Qualifying Finals: #1 vs #4, #2 vs #3 winners Prelim Finals (bye)
* losers Semi-Finals (2nd chance)
* Elimination Finals: #5 vs lower WC winner, losers exit (7th/8th)
* #6 vs higher WC winner
* Semi-Finals: QF1L vs EF1w, QF2L vs EF2w losers exit (5th/6th)
* Elimination Finals: #5 vs WC2w, #6 vs WC1w losers exit (7th/8th)
* Semi-Finals: QF1L vs EF2w, QF2L vs EF1w losers exit (5th/6th)
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w losers exit (3rd/4th)
* Grand Final: PF1w vs PF2w winner 1st, loser 2nd
*
* 6. Track placement counts per scoring tier
* 7. Convert counts to probability distributions
* 5. Track placement counts per scoring tier
* 6. Convert counts to probability distributions
*
* Win probability (Elo, PARITY_FACTOR = 450):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 450))
@ -72,7 +53,7 @@
* probFifth/Sixth = Semi-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)
* 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 58 tier into two
* separate pairs (5/6 and 7/8). This is already handled by scoring-rules.ts
@ -81,7 +62,7 @@
*/
import { database } from "~/database/context";
import { and, desc, eq } from "drizzle-orm";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name";
@ -94,9 +75,6 @@ import { positiveConfigNumber } from "./config-access";
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.
* 450 reflects moderate variance lower than NHL (1000) to account for
@ -214,232 +192,6 @@ function simulateProjectedWins(entry: TeamEntry): number {
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 EF1 winner, SF2 = QF2 loser v EF2 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: the Wildcard winners are re-seeded by ladder position, so #5
// hosts whichever finished lower and #6 the other — not a fixed crossover.
const wc1Seed = wc1.winner === s7 ? 7 : 10;
const wc2Seed = wc2.winner === s8 ? 8 : 9;
const [betterWc, worseWc] =
wc1Seed < wc2Seed ? [wc1.winner, wc2.winner] : [wc2.winner, wc1.winner];
const ef1 = play("Elimination Finals", 1, s5, worseWc);
const ef2 = play("Elimination Finals", 2, s6, betterWc);
// Semi-Finals: QF losers (second chance) vs EF winners. Elimination Final n feeds
// Semi-Final n — a fixed pathway; the crossover is a round later, at the Prelims.
const sf1 = play("Semi-Finals", 1, qf1.loser, ef1.winner);
const sf2 = play("Semi-Finals", 2, qf2.loser, ef2.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 ────────────────────────────────────────────────────────────────
export class AFLSimulator implements Simulator {
@ -518,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) ─────────────────────────
/** 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 110.
*
@ -564,7 +293,70 @@ export class AFLSimulator implements Simulator {
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 58 into two
// separate pairs: Semi-Finals losers share 5th/6th (higher value), and
@ -576,12 +368,10 @@ export class AFLSimulator implements Simulator {
const sfLoserCounts = 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++) {
// With a real bracket the draw is fixed and its played games are replayed from their
// 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);
const finalists = buildFinalsList();
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists);
championCounts.set(gfWinner.id, (championCounts.get(gfWinner.id) ?? 0) + 1);
finalistCounts.set(gfLoser.id, (finalistCounts.get(gfLoser.id) ?? 0) + 1);
@ -598,7 +388,7 @@ export class AFLSimulator implements Simulator {
// 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:
// probFirst/Second → / NUM_SIMULATIONS (1 per sim)
@ -631,8 +421,8 @@ export class AFLSimulator implements Simulator {
};
});
// 8. Per-position normalization — belt-and-suspenders guard against floating-point
// division residuals. Columns are already near-exactly 1.0 after step 7.
// 6. Per-position normalization — belt-and-suspenders guard against floating-point
// division residuals. Columns are already near-exactly 1.0 after step 5.
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",

View file

@ -34,25 +34,6 @@ export interface SimulatorManifestProfile {
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
setupSections: SimulatorSetupSection[];
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 = {
@ -90,7 +71,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "futuresOdds", "bracket"],
bracketAware: true,
},
ncaam_bracket: {
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"],
derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
bracketAware: true,
},
ncaaw_bracket: {
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"],
derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
bracketAware: true,
},
nba_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
@ -114,7 +92,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
},
nhl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
@ -122,7 +99,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
},
nfl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
@ -136,10 +112,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
requiredInputs: ["sourceElo"],
optionalInputs: ["projectedWins"],
derivableInputs: { sourceElo: ["projectedWins"] },
// 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,
setupSections: ["participants", "eloRatings", "regularStandings"],
},
epl_standings: {
defaultConfig: {
@ -162,7 +135,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
requiredInputs: ["sourceElo"],
optionalInputs: ["worldRanking", "seed"],
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
bracketAware: true,
},
tennis_qualifying_points: {
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
@ -171,7 +143,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "surfaceElo", "events"],
},
mlb_bracket: {
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, projectedWinsWeight: 1, inputPolicy: { oddsWeight: 0.3 } },
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
@ -190,21 +162,18 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
bracketAware: true,
},
darts_bracket: {
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
requiredInputs: ["sourceElo", "worldRanking"],
optionalInputs: ["seed"],
setupSections: ["participants", "eloRatings", "rankings"],
bracketAware: true,
},
cs2_major_qualifying_points: {
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
requiredInputs: ["sourceElo"],
optionalInputs: ["worldRanking", "metadata"],
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
bracketAware: true,
},
ncaa_football_bracket: {
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
// exists the simulator reads the real draw and honors completed results from it.
setupSections: ["participants", "futuresOdds", "bracket"],
bracketAware: true,
},
college_hockey_bracket: {
// 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"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
bracketAware: true,
},
brackt: {
defaultConfig: { iterations: 20_000 },
@ -257,7 +224,6 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
},
mls_bracket: {
defaultConfig: {

View file

@ -8,9 +8,8 @@
* 1. Load all participants for the sports season from DB
* 2. Load current standings (wins, gamesPlayed) from regularSeasonStandings
* 3. Load sourceElo ratings from seasonParticipantExpectedValues
* 4. Load raw projected win totals from seasonParticipantSimulatorInputs
* 5. Match participant names to hardcoded team data (RDif + league/division)
* 6. For each simulation:
* 4. Match participant names to hardcoded team data (RDif + league/division)
* 5. For each simulation:
* a. For each league (AL/NL), simulate remaining regular season games for
* every team using Binomial sampling, giving final projected wins.
* b. Division winner = best record in each division (3 per league).
@ -22,8 +21,8 @@
* - Division Series (best-of-5): 1 vs lowest WC survivor, 2 vs other
* - League Championship Series (best-of-7)
* e. World Series (best-of-7): AL champ vs NL champ
* 7. Track placement counts per scoring tier
* 8. Convert counts to probability distributions
* 6. Track placement counts per scoring tier
* 7. Convert counts to probability distributions
*
* Win probability (log5 formula):
* Step 1 convert projected RDif to win rate for playoff matchups:
@ -33,24 +32,17 @@
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
*
* Regular season simulation (seeding):
* Each team's base per-game win rate is derived from sourceElo (if set) or
* Each team's raw per-game win rate is derived from sourceElo (if set) or
* from the hardcoded RDif using SEEDING_RDIF_SCALE 10 runs/win × 162 games.
* When the resolved Elo came from a projected win total and nothing else, that
* base rate is replaced by the rest-of-season rate that reaches the projection:
* target = (projectedWins currentWins) / remainingGames
* (see seedingWinRateFor; config `projectedWinsWeight` blends it back toward the
* base rate). Pre-season the two rates coincide, so this is a no-op then. A
* projection that lost the baseEloPriority race, or that was blended with futures
* odds, is left to the resolved Elo see projectionForSeeding.
* Remaining games = TOTAL_SEASON_GAMES gamesPlayed are drawn from a
* Binomial distribution. This makes playoff seeding respond to both current
* standings and user-entered projected wins.
*
* Input resolution:
* sourceElo is the single Elo produced by the shared input policy already a
* blend of any raw Elo / projections / futures odds, written by
* prepareSimulatorInputsForRun before the run. This simulator does not blend
* futures odds itself.
* Futures blending:
* If sourceOdds are stored in participantExpectedValues for this season,
* the per-game win probability for playoff series is blended:
* P(game) = RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb
* RDIF_WEIGHT = 0.7, ODDS_WEIGHT = 0.3.
*
* Placement tiers SimulationProbabilities mapping:
* probFirst = World Series champion (1 per sim)
@ -82,10 +74,9 @@ import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { configNumber, positiveConfigNumber } from "./config-access";
import { positiveConfigNumber } from "./config-access";
import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getParticipantSimulatorInputs } from "~/models/simulator";
// ─── Simulation parameters ────────────────────────────────────────────────────
@ -109,13 +100,6 @@ const RDIF_DIVISOR = 8000;
*/
const SEEDING_RDIF_SCALE = 1620;
/**
* Default weight given to a user-entered projected win total when deriving the
* rest-of-season win rate. 1 = the projection is authoritative; 0 = ignore it and
* use the Elo-implied rate. Overridable per season via config `projectedWinsWeight`.
*/
const DEFAULT_PROJECTED_WINS_WEIGHT = 1;
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
//
// rdif: Projected run differential from FanGraphs Depth Charts.
@ -222,93 +206,13 @@ export function rawWinRateFromElo(elo: number): number {
}
/**
* Convert an Elo rating to an equivalent projected run differential, on the same
* scale as the hardcoded TEAMS_DATA.rdif values.
*
* Uses the standard Elo win probability formula (parity factor 400, average Elo
* 1500) and inverts rawWinRateFromRDif: rdif = (winRate 0.5) × SEEDING_RDIF_SCALE.
*
* SEEDING_RDIF_SCALE not RDIF_DIVISOR is deliberate. Scaling by RDIF_DIVISOR
* would make this the exact algebraic inverse of winRateFromRDif, so a team with
* an Elo would skip the playoff-parity compression that every hardcoded-rdif team
* gets: a 95-win projection (Elo 1561) mapped to RDif +686 and played playoff
* games at .586 instead of the ~.517 documented on RDIF_DIVISOR. On this scale it
* maps to +140 right alongside the Dodgers' hardcoded +137 and
* winRateFromRDif then compresses it to .5175 like any other team.
*
* Convert an Elo rating to an equivalent projected run differential.
* Uses the standard Elo win probability formula (parity factor 400, average Elo 1500),
* then inverts the winRateFromRDif formula: rdif = (winRate 0.5) × RDIF_DIVISOR.
* Exported for unit testing.
*/
export function eloToRDif(elo: number): number {
return (rawWinRateFromElo(elo) - 0.5) * SEEDING_RDIF_SCALE;
}
/**
* The projected win total seeding should use, or null to leave seeding on the Elo.
*
* `prepareSimulatorInputsForRun` records which source won the base-Elo race in
* `metadata.sourceEloMethod`, and only `"projectedWins"` means the resolved Elo is
* the projection and nothing else. Every other method has to be left alone:
*
* - `"direct"` the season's `baseEloPriority` put a hand-entered Elo ahead of
* the projection. Honouring the projection here anyway would ignore it as the
* Elo source while still letting it dictate seeding.
* - `"blend"` / `"sourceOdds"` futures odds are folded into the Elo at
* `oddsWeight` (0.3 for MLB). Seeding off the raw projection would discard that
* blend and run seeding and playoff matchups on two different strength scales.
* - a fallback the participant had no usable input of its own.
*
* Exported for unit testing.
*/
export function projectionForSeeding(
projectedWins: number | null,
metadata: Record<string, unknown> | null | undefined
): number | null {
return metadata?.sourceEloMethod === "projectedWins" ? projectedWins : null;
}
/**
* Per-game win rate to use for a team's remaining regular-season games.
*
* A user-entered `projectedWins` is a projected *final* season win total, so the
* rate that reproduces it is spread over the games still to play:
*
* target = (projectedWins currentWins) / remainingGames
*
* Pre-season this is a no-op with currentWins 0 and remainingGames 162 the
* target equals projectedWins / 162, which is exactly the rate the Elo derived
* from that projection already encodes. Mid-season it is what makes the
* simulation actually land on the projection: a team at 60-50 projected for 95
* needs .673 over its last 52 games, not the .586 its season-long Elo implies.
*
* A target outside (0, 1) is proof the projection has gone stale rather than a
* reason to bet everything on it: a 96-40 team projected for 95 would need a
* negative rate, and a 40-70 team projected for 95 would need better than 1.000.
* Both fall back to the Elo rate clamping them instead would simulate a team to
* stop winning entirely, or to win out. NLL takes the same escape hatch
* (`nll-simulator.ts` clamps its prior at 0 and then uses the Elo rate outright).
*
* `weight` (config `projectedWinsWeight`, default 1) blends the target back toward
* the Elo-implied rate. At 1 the projection is authoritative wherever it is still
* reachable; lower values hedge it; 0 or less ignores it. Values above 1 are
* clamped this is a blend weight, like inputPolicy.oddsWeight, and above 1 it
* would extrapolate past the target rather than blending toward it.
*
* Exported for unit testing.
*/
export function seedingWinRateFor(
eloRate: number,
projectedWins: number | null,
currentWins: number,
remainingGames: number,
weight: number = DEFAULT_PROJECTED_WINS_WEIGHT
): number {
if (projectedWins === null || remainingGames <= 0 || weight <= 0) return eloRate;
const target = (projectedWins - currentWins) / remainingGames;
if (target <= 0 || target >= 1) return eloRate;
// Clamped here rather than at the call site so the blend cannot be turned into an
// extrapolation by a stray config value, whichever caller supplies it.
const blend = Math.min(1, weight);
return blend * target + (1 - blend) * eloRate;
return (rawWinRateFromElo(elo) - 0.5) * RDIF_DIVISOR;
}
/**
@ -366,8 +270,6 @@ interface TeamEntry {
originalSeed?: number;
currentWins: number; // from regularSeasonStandings (0 pre-season)
remainingGames: number; // TOTAL_SEASON_GAMES - gamesPlayed
/** User-entered projected *final* season win total, or null when not set. */
projectedWins: number | null;
}
/** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */
@ -541,10 +443,6 @@ function simLeagueBracket(
export class MLBSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
// configNumber (not positiveConfigNumber) so an explicit 0 — ignore projections,
// use the Elo-implied rate — is honored rather than falling back to the default.
// seedingWinRateFor clamps the upper end; the knob is free-form on the Engine card.
const projectedWinsWeight = configNumber(config, "projectedWinsWeight", DEFAULT_PROJECTED_WINS_WEIGHT);
const db = database();
// 1. Load all participants for this sports season.
@ -567,18 +465,6 @@ export class MLBSimulator implements Simulator {
const standings = await getRegularSeasonStandings(sportsSeasonId);
const standingsByParticipantId = new Map(standings.map((s) => [s.participantId, s]));
// 3. Load the raw projected win totals, keeping only those that actually
// produced the resolved Elo. The Elo encodes the projection as a season-long
// rate; the raw total is what lets seeding spread the *remaining* wins
// correctly once games have been played — see projectionForSeeding.
const simInputs = await getParticipantSimulatorInputs(sportsSeasonId);
const projectedWinsMap = new Map(
simInputs.map((input) => [
input.participantId,
projectionForSeeding(input.projectedWins, input.metadata),
])
);
const teams: TeamEntry[] = participantRows.map((r) => {
const standing = standingsByParticipantId.get(r.id);
const gamesPlayed = standing?.gamesPlayed ?? 0;
@ -588,7 +474,6 @@ export class MLBSimulator implements Simulator {
data: getTeamData(r.name),
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, TOTAL_SEASON_GAMES - gamesPlayed),
projectedWins: projectedWinsMap.get(r.id) ?? null,
};
});
@ -661,28 +546,11 @@ export class MLBSimulator implements Simulator {
/**
* Raw per-game win rate for regular-season seeding simulation.
*
* The base rate comes from sourceElo when available, else from the hardcoded
* rdif via SEEDING_RDIF_SCALE (Pythagorean approximation). A user-entered
* projected win total then re-expresses that as a rest-of-season target so the
* projection is actually reached mid-season see seedingWinRateFor.
*
* The result depends only on fixed per-team inputs, so it is resolved once here
* rather than on every one of the ~1.5M calls the seeding loop makes.
* Uses sourceElo-derived rate if available; falls back to hardcoded rdif
* with SEEDING_RDIF_SCALE (Pythagorean approximation).
*/
const seedingWinRateMap = new Map(
teams.map((team) => [
team.id,
seedingWinRateFor(
rawWinRateMap.get(team.id) ?? rawWinRateFromRDif(getEntryRDif(team)),
team.projectedWins,
team.currentWins,
team.remainingGames,
projectedWinsWeight
),
])
);
const seedingWinRate = (entry: TeamEntry): number => seedingWinRateMap.get(entry.id) ?? 0.5;
const seedingWinRate = (entry: TeamEntry): number =>
rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry));
/**
* Per-game win probability for team A over team B in a playoff series, from

View file

@ -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 {
sportsSeasonId: string;
simulatorType: SimulatorType;
@ -94,8 +71,7 @@ export interface RunSportsSeasonSimulationResult {
}
export async function runSportsSeasonSimulation(
sportsSeasonId: string,
options: RunSportsSeasonSimulationOptions = {}
sportsSeasonId: string
): Promise<RunSportsSeasonSimulationResult> {
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
@ -159,15 +135,12 @@ export async function runSportsSeasonSimulation(
})),
]);
if (!options.skipStandingsRecalc) {
const seasonSports = await database().query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
});
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
}
const snapshotDate = new Date().toISOString().slice(0, 10);
if (!options.skipSnapshots) {
await batchUpsertParticipantEvSnapshots(
results.map((r) => ({
participantId: r.participantId,
@ -185,7 +158,6 @@ export async function runSportsSeasonSimulation(
source: r.source,
}))
);
}
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });

View file

@ -91,62 +91,13 @@ Keep specialized pages when they provide real workflow value, such as Golf Skill
## Input Policies
Direct ratings are preferred by default. If a simulator declares derived inputs, readiness may also pass with those alternatives:
Direct ratings are always preferred. If a simulator declares derived inputs, readiness may also pass with those alternatives:
- `projectedWins` can become Elo using `seasonGames` and `parityFactor` from season config.
- `projectedTablePoints` can become Elo using `seasonGames`, `maxTablePoints`, and `parityFactor`.
- `sourceOdds` can become Elo through the shared futures-to-Elo conversion.
- `sourceOdds` can become a generic `rating` when the simulator declares `derivableInputs: { rating: ["sourceOdds"] }`.
### Raw Elo vs. projections
Raw Elo and projections are *substitutes*, not a blend: `inputPolicy.baseEloPriority`
lists them in order and the first source a participant has wins outright. The
default is `["sourceElo", "projectedWins", "projectedTablePoints"]`, so a stored Elo
beats a projection. Set the Base Elo Source control on the simulator page (or
`baseEloPriority` directly) to `["projectedWins", "sourceElo"]` when projections are
the season's source of truth. Futures odds are separate — they blend on top of
whichever base won, weighted by `inputPolicy.oddsWeight`.
Whenever you write a projection without an explicit Elo, stamp
`metadata.sourceEloMethod` (`"projectedWins"` / `"projectedTablePoints"`) on the
row. `getParticipantSimulatorInputs` reads that flag and returns `sourceElo: null`
so the Elo is re-derived from the projection on every run. Skip it and the
non-destructive upsert leaves the previous Elo in place as a *direct* value, which
then wins the priority race — the projection is stored and silently ignored. Both
the Elo Ratings page's projections mode and the simulator page's CSV importer do
this; any new importer must too.
Projections are stored and displayed exactly as entered. Never round-trip one
through its derived Elo for display: the conversion rounds to an integer Elo, and a
run re-resolves that Elo through the input policy (clamping, plus any futures
blend), so the number the admin sees drifts away from the number they typed.
### Mid-season projections
A projected win total is a projected *final* total. A simulator that seeds from
projections mid-season must spread the difference over the games still to play —
`(projectedWins - currentWins) / remainingGames` — rather than reusing the
season-long rate the derived Elo encodes, or it will never reach the projection.
See `seedingWinRateFor` in `mlb-simulator.ts` (config knob `projectedWinsWeight`,
1 = the projection is authoritative) and `simulateRegularSeasonSeeds` in
`nll-simulator.ts` (which additionally decays a preseason prior as the season
completes).
Two guards belong on any such rest-of-season rate:
- **A target outside `(0, 1)` means the projection is stale** — the team has
already met it, or can no longer reach it. Fall back to the Elo rate. Clamping to
a floor or ceiling instead simulates a team to stop winning entirely, or to win
out, and collapses its seeding variance.
- **Only apply a projection that actually produced the resolved Elo.** Check
`metadata.sourceEloMethod === "projectedWins"` (see `projectionForSeeding` in
`mlb-simulator.ts`). Any other method means the Elo represents something else: a
hand-entered Elo that won the `baseEloPriority` race, or a futures blend. Seeding
off the raw projection in those cases makes the projection simultaneously ignored
as the Elo source and authoritative for the standings, and runs seeding and
playoff matchups on two different strength scales.
Missing tail participants must remain blocked unless the season config explicitly chooses an `inputPolicy.missingEloStrategy`:
```json

View file

@ -5,6 +5,12 @@ Sentry.init({
enabled: process.env.NODE_ENV === "production",
sendDefaultPii: true,
tracesSampleRate: 0,
ignoreErrors: [
/No route matches URL ".*\.css"/,
/No route matches URL ".*\.js"/,
/No route matches URL ".*\.(php|env|xml|aspx|asp|bak|sql|ini)"/i,
/No route matches URL ".*\/(wp-admin|wp-login|phpmyadmin|xmlrpc)"/i,
],
beforeSend(event) {
const msg = event.exception?.values?.[0]?.value ?? "";
// Drop React Flight protocol probe errors (e.g. $1:aa:aa in multipart body)

View file

@ -1,137 +0,0 @@
/**
* Repair: re-seed AFL Wildcard winners into the Elimination Finals they belong in.
*
* Brackets advanced before the re-seeding fix crossed each Wildcard winner into a fixed
* Elimination Final the 7v10 winner always met 6th and the 8v9 winner always met 5th
* instead of pairing them by ladder position (5th hosts the lower-ranked winner). The
* fix only changes how new results advance, so an already-advanced bracket keeps its
* wrong pairings until this runs. The admin UI cannot re-trigger it: a completed match
* renders as "Complete", with no way to re-submit the winner.
*
* This runs the same reseedAflEliminationFinals the advancement path now uses, so it
* makes exactly the correction a fresh bracket would have. It only ever moves Wildcard
* teams between the two Elimination Final slots no results, scores or placements are
* touched, and nothing else in the bracket is written. A bracket that is already correct
* is left alone.
*
* Admin the event's bracket has a "Re-seed Wildcard Winners" button that does exactly
* this for one event; use this script to sweep every afl_10 event, or where the UI is not
* to hand.
*
* If an Elimination Final has already been played, its qualifier cannot be moved without
* rewriting who contested a recorded result; the script reports that event and skips it.
* Clear and regenerate that bracket in Admin instead, then Reprocess Bracket.
*
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
*
* npx tsx scripts/fix-afl-wildcard-reseed.ts # apply to every afl_10 event
* npx tsx scripts/fix-afl-wildcard-reseed.ts --dry # report only
* npx tsx scripts/fix-afl-wildcard-reseed.ts --event <id> # one event
*/
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { eq } from "drizzle-orm";
import * as schema from "../database/schema.js";
import { DatabaseContext, database } from "../database/context.js";
import {
findPlayoffMatchesByEventIdAndRound,
reseedAflEliminationFinals,
} from "../app/models/playoff-match.js";
import { findParticipantsBySportsSeasonId } from "../app/models/season-participant.js";
const DRY = process.argv.includes("--dry");
const eventFlag = process.argv.indexOf("--event");
const ONLY_EVENT = eventFlag === -1 ? null : process.argv[eventFlag + 1];
const log = (...a: unknown[]) => console.log(...a);
async function run() {
const db = database();
const events = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.bracketTemplateId, "afl_10"),
});
const targets = ONLY_EVENT ? events.filter((e) => e.id === ONLY_EVENT) : events;
if (ONLY_EVENT && targets.length === 0) {
log(`No afl_10 event with id ${ONLY_EVENT}.`);
return;
}
log(`afl_10 events to check: ${targets.length}`);
let fixed = 0;
let alreadyRight = 0;
let skipped = 0;
for (const event of targets) {
const name = event.name ?? event.id;
const participants = await findParticipantsBySportsSeasonId(event.sportsSeasonId);
const nameOf = (id: string | null) =>
id === null ? "TBD" : participants.find((p) => p.id === id)?.name ?? id;
/** "M1: <host> vs <qualifier>" for both Elimination Finals. */
const pairings = async () => {
const efMatches = await findPlayoffMatchesByEventIdAndRound(event.id, "Elimination Finals");
return efMatches
.toSorted((a, b) => a.matchNumber - b.matchNumber)
.map((m) => `M${m.matchNumber}: ${nameOf(m.participant1Id)} vs ${nameOf(m.participant2Id)}`)
.join(", ");
};
try {
const before = await pairings();
// The dry run still resolves the pairings — it just reports them instead of writing.
if (DRY) {
const efMatches = await findPlayoffMatchesByEventIdAndRound(event.id, "Elimination Finals");
const wcMatches = await findPlayoffMatchesByEventIdAndRound(event.id, "Wildcard Round");
const decided = wcMatches.filter((m) => m.isComplete && m.winnerId).length;
log(` ${name}: ${before} (${decided}/${wcMatches.length} Wildcard results, ` +
`${efMatches.filter((m) => m.isComplete).length} Elimination Final(s) played)`);
continue;
}
const reseed = await reseedAflEliminationFinals(event.id);
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
alreadyRight += 1;
log(` = ${name}: already correct — ${before}`);
continue;
}
fixed += 1;
log(` ~ ${name}:`);
log(` was: ${before}`);
log(` now: ${await pairings()}`);
} catch (e) {
skipped += 1;
log(` ! ${name}: ${(e as Error).message}`);
}
}
if (DRY) {
log("\nDry run — no writes. Re-run without --dry to apply.");
return;
}
log(`\nDone. re-seeded=${fixed}, already correct=${alreadyRight}, skipped=${skipped}.`);
}
async function main() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("ERROR: DATABASE_URL is required");
process.exit(1);
}
const client = postgres(dbUrl, { max: 1 });
const db = drizzle(client, { schema });
try {
await DatabaseContext.run(db, run);
} finally {
await client.end();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -11,11 +11,9 @@ export const app = express();
app.use((_, __, next) => DatabaseContext.run(db, next));
// Block common bot probe paths before React Router (and Sentry) see them.
// `blog` is here only because scanners hammer /blog/wp/v2/* — drop it from this
// list if a real blog route is ever added.
// Block common bot probe paths before React Router (and Sentry) see them
const BOT_PROBE_RE =
/\.(php|env|htaccess|aspx|asp|jsp|config|bak|sql|ini|swp|DS_Store)$|^\/(wp-admin|wp-login|phpmyadmin|xmlrpc|server-status|cgi-bin|shell|cmd|console|actuator|blog)(\/|$)/i;
/\.(php|env|htaccess|aspx|asp|jsp|config|bak|sql|ini|swp|DS_Store)$|^\/(wp-admin|wp-login|phpmyadmin|xmlrpc|server-status|cgi-bin|shell|cmd|console|actuator)(\/|$)/i;
app.use((req, res, next) => {
if (BOT_PROBE_RE.test(req.path)) {