diff --git a/app/routes/__tests__/admin.sports-seasons.bracket.reseed-afl.test.ts b/app/routes/__tests__/admin.sports-seasons.bracket.reseed-afl.test.ts new file mode 100644 index 0000000..1cda784 --- /dev/null +++ b/app/routes/__tests__/admin.sports-seasons.bracket.reseed-afl.test.ts @@ -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()), + getScoringEventById: vi.fn(), + isReadOnlySibling: vi.fn(() => false), +})); +vi.mock("~/models/playoff-match", async (importOriginal) => ({ + ...(await importOriginal()), + reseedAflEliminationFinals: vi.fn(), +})); +vi.mock("~/models/season-participant", async (importOriginal) => ({ + ...(await importOriginal()), + findParticipantsBySportsSeasonId: vi.fn(), +})); +vi.mock("~/models/scoring-calculator", async (importOriginal) => ({ + ...(await importOriginal()), + processMatchResult: vi.fn(), + recalculateAffectedLeagues: vi.fn(), +})); +vi.mock("~/services/discord", async (importOriginal) => ({ + ...(await importOriginal()), + 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", + }); + }); +}); diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index bb8c04e..63943bf 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -16,6 +16,7 @@ import { findPlayoffMatchById, assignParticipantsToKnockout, doesLoserAdvance, + reseedAflEliminationFinals, } from "~/models/playoff-match"; import { createGame, @@ -866,6 +867,48 @@ 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", + }; + } + } + if (intent === "reprocess-bracket") { try { const event = await getScoringEventById(params.eventId); diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx index 6442f20..92d5b96 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -613,6 +613,31 @@ export default function EventBracket({ )} + {/* 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 && ( + + + Re-seed Wildcard Winners + + 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. + + + +
+ + +
+
+
+ )} + {/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else can rewrite a match's participants, so a wrong seeding has to be torn down and rebuilt via the setup form below, which reappears once this runs. */} diff --git a/scripts/fix-afl-wildcard-reseed.ts b/scripts/fix-afl-wildcard-reseed.ts index 2d054a9..da30392 100644 --- a/scripts/fix-afl-wildcard-reseed.ts +++ b/scripts/fix-afl-wildcard-reseed.ts @@ -14,6 +14,10 @@ * 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.