Compare commits

...
Sign in to create a new pull request.

15 commits

Author SHA1 Message Date
8006d514a4 Merge pull request 'claude/gifted-allen-npeql8' (#150) from claude/gifted-allen-npeql8 into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m55s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m23s
🚀 Deploy / 🐳 Build (push) Successful in 1m15s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Reviewed-on: #150
2026-09-14 23:14:31 +00:00
Claude
ed9c62571b
Fix three defects in the Sentry 404 filter
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m21s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m20s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Code review of 41a0237 found the same-origin Referer exception never
fires in production, which defeats the guarantee that change was built
on: broken internal links stay visible while scanners are dropped.

@react-router/express builds request.url from req.protocol and nothing
sets `trust proxy`, so request.url reads http:// inside the container
while browsers send an https:// Referer. Comparing full origins never
matched, so every router-internal 4xx was dropped. Compare host instead
-- protocol says nothing about whether a link was ours, and this is the
same mismatch $leagueId.server.ts already works around for invite URLs.
Adding `trust proxy` would fix it too but changes req.ip and req.secure
server-wide for one comparison.

Removing ignoreErrors also dropped the .css/.js filters with nothing to
replace them. Stale clients request the previous deploy's hashed bundles
with a same-host Referer, so once the Referer check worked, every deploy
would spike Sentry. Assets now never report, referer or not.

Narrow the dropped statuses from all 4xx to 404 and 405, the two that
mean nothing matched the request. React Router's internal 400 (route
missing a loader) and 403 (route does not match URL) are real
misconfigurations and were being swallowed whenever no Referer was sent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESrxBMZMx2BD9rcKTCgLe4
2026-09-14 21:18:33 +00:00
Claude
41a0237a87
Stop reporting bot-scanner 404s to Sentry
Automated scanners probing for WordPress paths (/blog/wp/v2/posts/*,
POST /) generate a React Router 404 or 405 on every hit, and handleError
forwarded all of them to Sentry, exhausting the quota. The existing
defence was a list of ignoreErrors regexes that needed a new entry for
each scanner pattern.

Filter on what the error is instead: shouldReportServerError drops 4xx
responses React Router generated itself (internal: true) for requests
that matched nothing. Responses the app throws deliberately still report,
as do all 5xx and real exceptions.

A request carrying a same-origin Referer is still reported, so a broken
internal link remains visible in Sentry -- only cold scanner hits are
dropped. No HTTP response changes; every URL returns the status and page
it did before.

Also adds /blog to the Express bot-probe filter so the highest-volume
path 404s without an SSR render, and removes the now-redundant
route-404 ignoreErrors entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESrxBMZMx2BD9rcKTCgLe4
2026-09-14 20:11:46 +00:00
2639102f06 Merge pull request 'claude/admiring-bohr-2zvef9' (#149) from claude/admiring-bohr-2zvef9 into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m3s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
🚀 Deploy / 🐳 Build (push) Successful in 1m12s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
Reviewed-on: #149
2026-09-11 19:34:33 +00:00
Claude
caff5093ae
Report a Semi-Final slot that was emptied without being refilled
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m5s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
The re-seed banner listed only the slots that were filled, so a re-seed that
just vacated one — un-recording an Elimination Final result takes its winner
back out of the semi — rendered as "Re-seeded the Semi-Finals: ." with nothing
after the colon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDeNWAXvK7nznJqjxn7Jo
2026-09-11 18:25:15 +00:00
Claude
cdf86d1682
Feed each AFL Elimination Final into the Semi-Final of the same number
The Elimination Final winners were crossed into the Semi-Finals — EF1's winner
met the QF2 loser and EF2's the QF1 loser. The AFL feeds them straight through:
SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the
EF2 winner. The crossover in this system lands a round later, at Semi-Final →
Preliminary Final, so a Qualifying Final loser cannot meet the side that just
beat it — that part was already right and is unchanged.

In 2026 that drew Fremantle v Adelaide and Brisbane v Geelong, when Fremantle
played Geelong and Brisbane played Adelaide.

Placement now reconciles both Semi-Final slots on every Elimination Final
result rather than writing the one it was called for, so correcting a recorded
result moves the qualifier instead of leaving the beaten team alive in a semi.
A slot held by anyone who never played an Elimination Final still raises
"already filled", and a Semi-Final that has been played refuses the move rather
than rewriting who contested it.

The simulator paired the Semi-Finals the same crossed way, which biased every
projection running off an undecided Elimination Final; it now feeds straight
through too.

Brackets already advanced under the crossover keep their wrong pairings, since
no admin action re-runs advancement — a completed match cannot be re-submitted.
Admin → the event's bracket gains a "Fix Semi-Final Pairings" button that runs
the same reconciliation over a bracket as it stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDeNWAXvK7nznJqjxn7Jo
2026-09-11 18:10:30 +00:00
10363da100 Merge pull request 'claude/session-crx3tm' (#148) from claude/session-crx3tm into main
All checks were successful
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
🚀 Deploy / 🧪 Test (push) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
Reviewed-on: #148
2026-09-04 22:06:00 +00:00
Claude
95acc6fcba
Add a Re-seed Wildcard Winners button to the bracket admin
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m7s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m17s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Repairing a bracket advanced before the re-seeding rule needed a script and
a shell. Add the same repair as an admin action on the event's bracket page,
shown for afl_10 brackets: it runs reseedAflEliminationFinals and reports
which team each Elimination Final now hosts, or says the pairings were
already right.

Only the qualifier slots move, so no scoring runs and nothing is announced —
a test asserts the action calls neither the scoring path nor Discord. A
bracket whose Elimination Final has already been played still refuses, with
the model's message surfaced to the admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 21:40:08 +00:00
Claude
4e48a23f6b
Add a repair script for brackets advanced before the re-seed fix
The fix only changes how new results advance, so a bracket already advanced
under the fixed crossover keeps its wrong Elimination Finals pairings — and
the admin UI cannot re-trigger advancement, since a completed match renders
as "Complete" with no way to re-submit the winner.

Extract the reconcile step as reseedAflEliminationFinals, which resolves the
pairings from the results recorded so far (the winner being advanced is
passed in, since that row can predate the write), and add a script that runs
it over afl_10 events. It moves only Wildcard teams between the two
Elimination Final slots, leaves a correct bracket untouched, and reports
rather than rewrites an Elimination Final that has already been played.

  npx tsx scripts/fix-afl-wildcard-reseed.ts --dry

An event with no Wildcard/Elimination Finals matches now raises instead of
reporting nothing to do — a mistyped event id should not read as success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 21:34:56 +00:00
e7c5b38953 Merge pull request 'claude/session-crx3tm' (#147) from claude/session-crx3tm into main
All checks were successful
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
🚀 Deploy / 🧪 Test (push) Successful in 3m8s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m30s
Reviewed-on: #147
2026-09-04 21:05:42 +00:00
Claude
a7b7921b9b
Rename a shadowed variable in the AFL advancement test mock
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m9s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
The db mock's local match lookup shadowed the test helper of the same name,
which oxlint's no-shadow rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 20:49:10 +00:00
Claude
a747d73a4c
Refuse to re-seed an Elimination Final that has been played
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 46s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Reconciling both slots on every Wildcard result means a late correction
could otherwise rewrite who contested a game that already has a recorded
winner. Raise instead — with a message callers do not swallow, so the admin
sees it and can clear and regenerate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 15:30:05 +00:00
Claude
273735e572
Re-resolve both Elimination Final slots on every Wildcard result
Now that a Wildcard winner's destination depends on both games, guarding
only the slot about to be written is not enough. Correcting a recorded
result placed the new winner in the other Elimination Final and left the
beaten team alive in the one it had been written to, and a bracket already
advanced under the old fixed crossover ended up with one team in both
Elimination Finals once the second result came in — the second write hit
"already filled", which every caller swallows.

Each result now reconciles both slots against the resolved pairings:
a Wildcard team sitting in the wrong slot is vacated, the right ones are
written, and a slot held by anyone outside the Wildcard Round still raises
"already filled" rather than being taken. Clearing and filling share one
transaction, so a half-applied re-seed cannot leave a team in both games,
and pairings that are already correct do no writes at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 15:27:16 +00:00
Claude
b566c5f1a5
Re-seed AFL Wildcard winners into the Elimination Finals
The two Wildcard Round winners were crossed into the Elimination Finals by
which game they came out of — the 7v10 winner always met 6th and the 8v9
winner always met 5th. The AFL pairs those games by ladder position, as the
classic final eight pairs 5v8 and 6v7: the higher host draws the
lower-ranked survivor. So when 10th wins through, 5th should meet 10th, not
the 8v9 winner.

Placement now resolves both games together. A 7v10 winner is placed as soon
as it is decided (7th outranks either possible opponent, 10th is outranked
by both), while an 8v9 winner is held until the other game is decided rather
than being placed and later moved, so results can be entered in either
order.

The simulator paired the Elimination Finals the same fixed way, which biased
every projection that ran off an undecided Wildcard Round; it now re-seeds
too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 15:13:13 +00:00
f3e00fcacf Merge pull request 'claude/mlb-simulator-projected-wins-elo-siess8' (#146) from claude/mlb-simulator-projected-wins-elo-siess8 into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🐳 Build (push) Successful in 1m6s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Reviewed-on: #146
2026-08-29 07:53:08 +00:00
18 changed files with 2018 additions and 55 deletions

View file

@ -1,18 +1,26 @@
import * as Sentry from "@sentry/react-router"; import * as Sentry from "@sentry/react-router";
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { shouldReportServerError } from "~/lib/error-reporting";
import type { AppLoadContext, EntryContext } from "react-router"; import type { AppLoadContext, EntryContext, HandleErrorFunction } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node"; import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router"; import { ServerRouter } from "react-router";
import { isbot } from "isbot"; import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server"; import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server";
export const handleError = Sentry.createSentryHandleError({ const sentryHandleError = Sentry.createSentryHandleError({
logErrors: true, 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; export const streamTimeout = 5_000;
async function handleRequest( async function handleRequest(

View file

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

View file

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

View file

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

View file

@ -703,7 +703,8 @@ export const NFL_14: BracketTemplate = {
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points) * - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
* - Week 1 Finals: * - Week 1 Finals:
* - Qualifying Finals: 1v4, 2v3 (losers get second chance) * - Qualifying Finals: 1v4, 2v3 (losers get second chance)
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th) * - 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)
* - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th) * - 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 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
* - Week 4: Grand Final (1st vs 2nd) * - Week 4: Grand Final (1st vs 2nd)

View file

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

View file

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

View file

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

View file

@ -15,6 +15,10 @@ import {
resolveLLWSAdvancement, resolveLLWSAdvancement,
type LLWSResolvedDestination, type LLWSResolvedDestination,
} from "~/lib/llws-bracket"; } from "~/lib/llws-bracket";
import {
resolveAflWildcardPlacements,
type AflWildcardResult,
} from "~/lib/afl-wildcard-reseed";
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect; export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert; export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
@ -742,9 +746,11 @@ async function generateNFL14Bracket(
* Structure: * Structure:
* - Wildcard Round: 7v10, 8v9 * - Wildcard Round: 7v10, 8v9
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals) * - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners) * - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder
* - Semi-Finals: QF losers vs EF winners * position 5th draws the lower-ranked winner, 6th the higher-ranked one
* - Preliminary Finals: QF winners vs SF winners * - 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)
* - Grand Final: PF winners * - Grand Final: PF winners
*/ */
async function generateAFL10Bracket( async function generateAFL10Bracket(
@ -796,14 +802,16 @@ async function generateAFL10Bracket(
}); });
} }
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner) // 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.
const eliminationSeeding = [ const eliminationSeeding = [
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner { higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner { higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
]; ];
for (let i = 0; i < eliminationSeeding.length; i++) { for (let i = 0; i < eliminationSeeding.length; i++) {
const { higher, wildcard } = eliminationSeeding[i]; const { higher, opponent } = eliminationSeeding[i];
matches.push({ matches.push({
scoringEventId: eventId, scoringEventId: eventId,
round: "Elimination Finals", round: "Elimination Finals",
@ -813,11 +821,11 @@ async function generateAFL10Bracket(
isComplete: false, isComplete: false,
isScoring: true, // Losers share 7th-8th isScoring: true, // Losers share 7th-8th
templateRound: "Elimination Finals", templateRound: "Elimination Finals",
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null, seedInfo: participantIds ? `${higher + 1} vs ${opponent}` : null,
}); });
} }
// Semi-Finals: QF losers vs EF winners (TBD vs TBD) // Semi-Finals: SF n = QF n loser vs EF n winner (TBD vs TBD)
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
matches.push({ matches.push({
scoringEventId: eventId, scoringEventId: eventId,
@ -863,15 +871,257 @@ async function generateAFL10Bracket(
return await createManyPlayoffMatches(matches); 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 * AFL-specific advancement logic for the complex double-chance system
* Phase 3.3: Handles both winners and losers advancing to different rounds * Phase 3.3: Handles both winners and losers advancing to different rounds
* *
* Advancement rules: * Advancement rules:
* - Wildcard Round: Winner Elimination Finals * - Wildcard Round: Winner Elimination Finals (re-seeded by ladder position)
* - Qualifying Finals: Winner Preliminary Finals, Loser Semi-Finals * - Qualifying Finals: Winner Preliminary Finals, Loser Semi-Finals (QF n PF n, SF n)
* - Elimination Finals: Winner Semi-Finals * - Elimination Finals: Winner Semi-Finals (EF n SF n, a fixed pathway)
* - Semi-Finals: Winner Preliminary Finals * - Semi-Finals: Winner Preliminary Finals (SF n crosses over: SF1 PF2, SF2 PF1)
* - Preliminary Finals: Winner Grand Final * - Preliminary Finals: Winner Grand Final
*/ */
async function advanceAFLWinner( async function advanceAFLWinner(
@ -881,18 +1131,10 @@ async function advanceAFLWinner(
): Promise<void> { ): Promise<void> {
const eventId = match.scoringEventId; const eventId = match.scoringEventId;
// Wildcard Round: Winner advances to Elimination Finals // Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
// position, so every result re-resolves both slots.
if (match.round === "Wildcard Round") { if (match.round === "Wildcard Round") {
// Wildcard Match 1 winner → EF Match 2, participant2Id await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId });
// 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; return;
} }
@ -920,18 +1162,11 @@ async function advanceAFLWinner(
return; return;
} }
// Elimination Finals: Winner → Semi-Finals // 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.
if (match.round === "Elimination Finals") { if (match.round === "Elimination Finals") {
// EF Match 1 winner → SF2 participant2 await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
// 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; return;
} }

View file

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

View file

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

View file

@ -16,6 +16,8 @@ import {
findPlayoffMatchById, findPlayoffMatchById,
assignParticipantsToKnockout, assignParticipantsToKnockout,
doesLoserAdvance, doesLoserAdvance,
reseedAflEliminationFinals,
reseedAflSemiFinals,
} from "~/models/playoff-match"; } from "~/models/playoff-match";
import { import {
createGame, createGame,
@ -866,6 +868,101 @@ 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") { if (intent === "reprocess-bracket") {
try { try {
const event = await getScoringEventById(params.eventId); const event = await getScoringEventById(params.eventId);

View file

@ -613,6 +613,56 @@ export default function EventBracket({
</Card> </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 {/* 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 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. */} and rebuilt via the setup form below, which reappears once this runs. */}

View file

@ -5,6 +5,7 @@ import {
eloWinProbability, eloWinProbability,
AFLSimulator, AFLSimulator,
readAflBracketSeeds, readAflBracketSeeds,
simAFLFinals,
type BracketMatch, type BracketMatch,
} from "../afl-simulator"; } from "../afl-simulator";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types"; import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
@ -623,3 +624,78 @@ describe("readAflBracketSeeds", () => {
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/not in this sports season/); expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/not in this sports season/);
}); });
}); });
// ─── simAFLFinals ─────────────────────────────────────────────────────────────
describe("simAFLFinals bracket pathways", () => {
const finalists = Array.from({ length: 10 }, (_, i) => ({
id: `s${i + 1}`,
name: `s${i + 1}`,
elo: 1500,
currentWins: 0,
remainingGames: 0,
winProb: 0.5,
}));
/**
* Play the finals with the Wildcard Round forced to the given winners (every other
* game goes to whoever was routed in first), and report who met whom.
*/
function pairingsWith(wc1Winner: string, wc2Winner: string): Map<string, [string, string]> {
const pairings = new Map<string, [string, string]>();
const play = (
round: string,
matchNumber: number,
t1: { id: string },
t2: { id: string }
) => {
pairings.set(`${round}#${matchNumber}`, [t1.id, t2.id]);
if (round === "Wildcard Round") {
const forced = matchNumber === 1 ? wc1Winner : wc2Winner;
return t1.id === forced ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
}
return { winner: t1, loser: t2 };
};
simAFLFinals(finalists as never, play as never);
return pairings;
}
it("draws the Wildcard Round 7v10 and 8v9", () => {
const pairings = pairingsWith("s7", "s8");
expect(pairings.get("Wildcard Round#1")).toEqual(["s7", "s10"]);
expect(pairings.get("Wildcard Round#2")).toEqual(["s8", "s9"]);
});
it.each([
{ wc1: "s7", wc2: "s8", ef1: "s8", ef2: "s7" },
{ wc1: "s7", wc2: "s9", ef1: "s9", ef2: "s7" },
// 10th beating 7th is where a fixed crossover misfires: it would send 10th to 6th
// and leave 5th with the stronger survivor.
{ wc1: "s10", wc2: "s8", ef1: "s10", ef2: "s8" },
{ wc1: "s10", wc2: "s9", ef1: "s10", ef2: "s9" },
])(
"pairs 5th with $ef1 and 6th with $ef2 when $wc1 and $wc2 win through",
({ wc1, wc2, ef1, ef2 }) => {
const pairings = pairingsWith(wc1, wc2);
expect(pairings.get("Elimination Finals#1")).toEqual(["s5", ef1]);
expect(pairings.get("Elimination Finals#2")).toEqual(["s6", ef2]);
}
);
// The pathway out of the Elimination Finals is fixed (EF n → SF n) — unlike the
// Wildcard Round's re-seed. The crossover lands a round later, at the Prelims, so a
// Qualifying Final loser cannot meet the side that just beat it. `play` here hands
// every non-Wildcard game to participant1, so QF1 sends s1 through and s4 down.
it("feeds each Elimination Final into the Semi-Final of the same number", () => {
const pairings = pairingsWith("s7", "s8");
expect(pairings.get("Semi-Finals#1")).toEqual(["s4", "s5"]);
expect(pairings.get("Semi-Finals#2")).toEqual(["s3", "s6"]);
});
it("crosses the Semi-Final winners over into the Preliminary Finals", () => {
const pairings = pairingsWith("s7", "s8");
expect(pairings.get("Preliminary Finals#1")).toEqual(["s1", "s3"]);
expect(pairings.get("Preliminary Finals#2")).toEqual(["s2", "s4"]);
});
});

View file

@ -37,8 +37,9 @@
* Wildcard Round: #7 vs #10, #8 vs #9 losers exit (0 pts) * Wildcard Round: #7 vs #10, #8 vs #9 losers exit (0 pts)
* Qualifying Finals: #1 vs #4, #2 vs #3 winners Prelim Finals (bye) * Qualifying Finals: #1 vs #4, #2 vs #3 winners Prelim Finals (bye)
* losers Semi-Finals (2nd chance) * losers Semi-Finals (2nd chance)
* Elimination Finals: #5 vs WC2w, #6 vs WC1w losers exit (7th/8th) * Elimination Finals: #5 vs lower WC winner, losers exit (7th/8th)
* Semi-Finals: QF1L vs EF2w, QF2L vs EF1w losers exit (5th/6th) * #6 vs higher WC winner
* Semi-Finals: QF1L vs EF1w, QF2L vs EF2w losers exit (5th/6th)
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w losers exit (3rd/4th) * Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w losers exit (3rd/4th)
* Grand Final: PF1w vs PF2w winner 1st, loser 2nd * Grand Final: PF1w vs PF2w winner 1st, loser 2nd
* *
@ -378,7 +379,7 @@ export function makePlayGame(bracket: LoadedBracket | null, parityFactor: number
* *
* Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a * Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a
* recorded result is looked up against the game it was actually played in: * recorded result is looked up against the game it was actually played in:
* SF1 = QF1 loser v EF2 winner, SF2 = QF2 loser v EF1 winner, * SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner,
* PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner. * PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner.
* *
* Returns the placement for each team: * Returns the placement for each team:
@ -409,13 +410,19 @@ export function simAFLFinals(
const qf1 = play("Qualifying Finals", 1, s1, s4); const qf1 = play("Qualifying Finals", 1, s1, s4);
const qf2 = play("Qualifying Finals", 2, s2, s3); const qf2 = play("Qualifying Finals", 2, s2, s3);
// Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner // Elimination Finals: the Wildcard winners are re-seeded by ladder position, so #5
const ef1 = play("Elimination Finals", 1, s5, wc2.winner); // hosts whichever finished lower and #6 the other — not a fixed crossover.
const ef2 = play("Elimination Finals", 2, s6, wc1.winner); 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 // Semi-Finals: QF losers (second chance) vs EF winners. Elimination Final n feeds
const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner); // Semi-Final n — a fixed pathway; the crossover is a round later, at the Prelims.
const sf2 = play("Semi-Finals", 2, qf2.loser, ef1.winner); 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 // Preliminary Finals: QF winners vs SF winners
const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner); const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);

View file

@ -5,12 +5,6 @@ Sentry.init({
enabled: process.env.NODE_ENV === "production", enabled: process.env.NODE_ENV === "production",
sendDefaultPii: true, sendDefaultPii: true,
tracesSampleRate: 0, 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) { beforeSend(event) {
const msg = event.exception?.values?.[0]?.value ?? ""; const msg = event.exception?.values?.[0]?.value ?? "";
// Drop React Flight protocol probe errors (e.g. $1:aa:aa in multipart body) // Drop React Flight protocol probe errors (e.g. $1:aa:aa in multipart body)

View file

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

View file

@ -11,9 +11,11 @@ export const app = express();
app.use((_, __, next) => DatabaseContext.run(db, next)); app.use((_, __, next) => DatabaseContext.run(db, next));
// Block common bot probe paths before React Router (and Sentry) see them // 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.
const BOT_PROBE_RE = 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)(\/|$)/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|blog)(\/|$)/i;
app.use((req, res, next) => { app.use((req, res, next) => {
if (BOT_PROBE_RE.test(req.path)) { if (BOT_PROBE_RE.test(req.path)) {