diff --git a/app/models/__tests__/afl-semifinal-pairing.test.ts b/app/models/__tests__/afl-semifinal-pairing.test.ts new file mode 100644 index 0000000..6c50770 --- /dev/null +++ b/app/models/__tests__/afl-semifinal-pairing.test.ts @@ -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; + 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) => { + 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) => 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" + ); + }); +}); diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts index f6e1379..8f7db52 100644 --- a/app/models/playoff-match.ts +++ b/app/models/playoff-match.ts @@ -748,8 +748,9 @@ async function generateNFL14Bracket( * - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals) * - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder * position — 5th draws the lower-ranked winner, 6th the higher-ranked one - * - Semi-Finals: QF losers vs EF winners - * - 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 */ async function generateAFL10Bracket( @@ -824,7 +825,7 @@ async function generateAFL10Bracket( }); } - // 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++) { matches.push({ scoringEventId: eventId, @@ -993,15 +994,134 @@ export async function reseedAflEliminationFinals( 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 { + 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(); + 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(); + for (const ef of efMatches) { + if (ef.participant1Id) eliminationParticipants.add(ef.participant1Id); + if (ef.participant2Id) eliminationParticipants.add(ef.participant2Id); + } + + const slotsToClear: Array<{ id: string; matchNumber: number }> = []; + const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = []; + + for (const sfMatch of sfMatches) { + const occupant = sfMatch.participant2Id; + const belongsHere = wanted.get(sfMatch.matchNumber) ?? null; + if (occupant === belongsHere) continue; + + if (occupant !== null && !eliminationParticipants.has(occupant)) { + throw new Error(`SF ${sfMatch.matchNumber} participant2 already filled`); + } + // Re-seeding a game that has already been played would rewrite who contested a + // recorded result. Surface that (this message is not one callers swallow) rather + // than quietly corrupting the bracket. + if (occupant !== null && (sfMatch.isComplete || sfMatch.winnerId)) { + throw new Error( + `Semi-Finals match ${sfMatch.matchNumber} already has a recorded result, ` + + `so its Elimination Finals qualifier cannot be re-seeded — clear and regenerate the bracket` + ); + } + if (occupant !== null) slotsToClear.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber }); + if (belongsHere !== null) { + slotsToFill.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber, participantId: belongsHere }); + } + } + + const reseed: AflSemiFinalReseed = { + vacated: slotsToClear.map((slot) => slot.matchNumber), + filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })), + }; + + if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed; + + // One transaction, vacating before filling: a half-applied re-seed would leave the + // same team in both Semi-Finals. + const db = database(); + await db.transaction(async (tx) => { + const now = new Date(); + for (const slot of slotsToClear) { + await tx + .update(schema.playoffMatches) + .set({ participant2Id: null, updatedAt: now }) + .where(eq(schema.playoffMatches.id, slot.id)); + } + for (const slot of slotsToFill) { + await tx + .update(schema.playoffMatches) + .set({ participant2Id: slot.participantId, updatedAt: now }) + .where(eq(schema.playoffMatches.id, slot.id)); + } + }); + + return reseed; +} + /** * AFL-specific advancement logic for the complex double-chance system * Phase 3.3: Handles both winners and losers advancing to different rounds * * Advancement rules: * - Wildcard Round: Winner → Elimination Finals (re-seeded by ladder position) - * - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals - * - Elimination Finals: Winner → Semi-Finals - * - Semi-Finals: Winner → Preliminary Finals + * - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals (QF n → PF n, SF n) + * - Elimination Finals: Winner → Semi-Finals (EF n → SF n, a fixed pathway) + * - Semi-Finals: Winner → Preliminary Finals (SF n crosses over: SF1 → PF2, SF2 → PF1) * - Preliminary Finals: Winner → Grand Final */ async function advanceAFLWinner( @@ -1042,18 +1162,11 @@ async function advanceAFLWinner( 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") { - // EF Match 1 winner → SF2 participant2 - // EF Match 2 winner → SF1 participant2 - const sfMatchNumber = match.matchNumber === 1 ? 2 : 1; - const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals"); - const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber); - - if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`); - if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`); - - await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId }); + await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId }); return; } diff --git a/app/routes/__tests__/admin.sports-seasons.bracket.reseed-afl-semifinals.test.ts b/app/routes/__tests__/admin.sports-seasons.bracket.reseed-afl-semifinals.test.ts new file mode 100644 index 0000000..f05434c --- /dev/null +++ b/app/routes/__tests__/admin.sports-seasons.bracket.reseed-afl-semifinals.test.ts @@ -0,0 +1,132 @@ +/** + * 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()), + getScoringEventById: vi.fn(), + isReadOnlySibling: vi.fn(() => false), +})); +vi.mock("~/models/playoff-match", async (importOriginal) => ({ + ...(await importOriginal()), + reseedAflSemiFinals: 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-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("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", + }); + }); +}); 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 63943bf..897b03f 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 @@ -17,6 +17,7 @@ import { assignParticipantsToKnockout, doesLoserAdvance, reseedAflEliminationFinals, + reseedAflSemiFinals, } from "~/models/playoff-match"; import { createGame, @@ -909,6 +910,48 @@ export async function action({ request, params }: Route.ActionArgs) { } } + // 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. + 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 Semi-Finals: ${moves}.`, + }; + } catch (error) { + logger.error("Error re-seeding AFL Elimination Finals winners:", error); + return { + error: + error instanceof Error ? error.message : "Failed to re-seed the Semi-Finals", + }; + } + } + if (intent === "reprocess-bracket") { try { const event = await getScoringEventById(params.eventId); 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 92d5b96..0906676 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -638,6 +638,31 @@ export default function EventBracket({ )} + {/* 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 && ( + + + Fix Semi-Final Pairings + + 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. + + + +
+ + +
+
+
+ )} + {/* 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/app/services/simulations/__tests__/afl-simulator.test.ts b/app/services/simulations/__tests__/afl-simulator.test.ts index 80b7d56..3a11aeb 100644 --- a/app/services/simulations/__tests__/afl-simulator.test.ts +++ b/app/services/simulations/__tests__/afl-simulator.test.ts @@ -627,7 +627,7 @@ describe("readAflBracketSeeds", () => { // ─── simAFLFinals ───────────────────────────────────────────────────────────── -describe("simAFLFinals Elimination Finals re-seeding", () => { +describe("simAFLFinals bracket pathways", () => { const finalists = Array.from({ length: 10 }, (_, i) => ({ id: `s${i + 1}`, name: `s${i + 1}`, @@ -682,4 +682,20 @@ describe("simAFLFinals Elimination Finals re-seeding", () => { 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"]); + }); }); diff --git a/app/services/simulations/afl-simulator.ts b/app/services/simulations/afl-simulator.ts index 9fd5033..c66ccb4 100644 --- a/app/services/simulations/afl-simulator.ts +++ b/app/services/simulations/afl-simulator.ts @@ -39,7 +39,7 @@ * losers → Semi-Finals (2nd chance) * Elimination Finals: #5 vs lower WC winner, → losers exit (7th/8th) * #6 vs higher WC winner - * Semi-Finals: QF1L vs EF2w, QF2L vs EF1w → losers exit (5th/6th) + * Semi-Finals: QF1L vs EF1w, QF2L vs EF2w → losers exit (5th/6th) * Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th) * Grand Final: PF1w vs PF2w → winner 1st, loser 2nd * @@ -379,7 +379,7 @@ export function makePlayGame(bracket: LoadedBracket | null, parityFactor: number * * Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a * recorded result is looked up against the game it was actually played in: - * SF1 = QF1 loser v 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. * * Returns the placement for each team: @@ -419,9 +419,10 @@ export function simAFLFinals( 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 - const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner); - const sf2 = play("Semi-Finals", 2, qf2.loser, ef1.winner); + // Semi-Finals: QF losers (second chance) vs EF winners. Elimination Final n feeds + // Semi-Final n — a fixed pathway; the crossover is a round later, at the Prelims. + const sf1 = play("Semi-Finals", 1, qf1.loser, ef1.winner); + const sf2 = play("Semi-Finals", 2, qf2.loser, ef2.winner); // Preliminary Finals: QF winners vs SF winners const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);