Compare commits
No commits in common. "main" and "claude/llws-ev-calculation-bug-30xpxg" have entirely different histories.
main
...
claude/llw
37 changed files with 264 additions and 3931 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,15 +637,13 @@ 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) {
|
||||
logger.error(
|
||||
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
try {
|
||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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} →{' '}
|
||||
{m.projection !== null
|
||||
? `${m.projection} ${projectionUnit} (Elo ${m.elo})`
|
||||
: m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
||||
{m.name} → {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.`}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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>;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 5–8 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 1–10.
|
||||
*
|
||||
|
|
@ -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 5–8 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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,33 +135,29 @@ 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 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,
|
||||
sportsSeasonId,
|
||||
snapshotDate,
|
||||
probFirst: r.probabilities.probFirst,
|
||||
probSecond: r.probabilities.probSecond,
|
||||
probThird: r.probabilities.probThird,
|
||||
probFourth: r.probabilities.probFourth,
|
||||
probFifth: r.probabilities.probFifth,
|
||||
probSixth: r.probabilities.probSixth,
|
||||
probSeventh: r.probabilities.probSeventh,
|
||||
probEighth: r.probabilities.probEighth,
|
||||
calculatedEV: calculateEV(r.probabilities, persistence.scoringRules),
|
||||
source: r.source,
|
||||
}))
|
||||
);
|
||||
}
|
||||
await batchUpsertParticipantEvSnapshots(
|
||||
results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
snapshotDate,
|
||||
probFirst: r.probabilities.probFirst,
|
||||
probSecond: r.probabilities.probSecond,
|
||||
probThird: r.probabilities.probThird,
|
||||
probFourth: r.probabilities.probFourth,
|
||||
probFifth: r.probabilities.probFifth,
|
||||
probSixth: r.probabilities.probSixth,
|
||||
probSeventh: r.probabilities.probSeventh,
|
||||
probEighth: r.probabilities.probEighth,
|
||||
calculatedEV: calculateEV(r.probabilities, persistence.scoringRules),
|
||||
source: r.source,
|
||||
}))
|
||||
);
|
||||
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue