diff --git a/app/lib/__tests__/afl-wildcard-reseed.test.ts b/app/lib/__tests__/afl-wildcard-reseed.test.ts new file mode 100644 index 0000000..6103b65 --- /dev/null +++ b/app/lib/__tests__/afl-wildcard-reseed.test.ts @@ -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 { + 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/ + ); + }); +}); diff --git a/app/lib/afl-wildcard-reseed.ts b/app/lib/afl-wildcard-reseed.ts new file mode 100644 index 0000000..b851c74 --- /dev/null +++ b/app/lib/afl-wildcard-reseed.ts @@ -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> = { + 1: [7, 10], + 2: [8, 9], +}; + +/** Seed hosting each Elimination Finals match (its participant1 slot). */ +export const AFL_ELIMINATION_HOSTS: Readonly> = { + 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; +} diff --git a/app/lib/bracket-templates.ts b/app/lib/bracket-templates.ts index 288cc67..28f234d 100644 --- a/app/lib/bracket-templates.ts +++ b/app/lib/bracket-templates.ts @@ -703,7 +703,8 @@ export const NFL_14: BracketTemplate = { * - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points) * - Week 1 Finals: * - Qualifying Finals: 1v4, 2v3 (losers get second chance) - * - Elimination Finals: 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 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th) * - Week 4: Grand Final (1st vs 2nd) diff --git a/app/models/__tests__/afl-wildcard-advancement.test.ts b/app/models/__tests__/afl-wildcard-advancement.test.ts new file mode 100644 index 0000000..19ee384 --- /dev/null +++ b/app/models/__tests__/afl-wildcard-advancement.test.ts @@ -0,0 +1,237 @@ +/** + * 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; + 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 } = 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("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)); + }); +}); diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts index 440f326..1b3a0f6 100644 --- a/app/models/playoff-match.ts +++ b/app/models/playoff-match.ts @@ -15,6 +15,10 @@ import { resolveLLWSAdvancement, type LLWSResolvedDestination, } from "~/lib/llws-bracket"; +import { + resolveAflWildcardPlacements, + type AflWildcardResult, +} from "~/lib/afl-wildcard-reseed"; export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect; export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert; @@ -742,7 +746,8 @@ async function generateNFL14Bracket( * Structure: * - Wildcard Round: 7v10, 8v9 * - 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 + * 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 * - Grand Final: PF winners @@ -796,14 +801,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 = [ - { higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner - { higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner + { higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4) + { higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5) ]; for (let i = 0; i < eliminationSeeding.length; i++) { - const { higher, wildcard } = eliminationSeeding[i]; + const { higher, opponent } = eliminationSeeding[i]; matches.push({ scoringEventId: eventId, round: "Elimination Finals", @@ -813,7 +820,7 @@ async function generateAFL10Bracket( isComplete: false, isScoring: true, // Losers share 7th-8th templateRound: "Elimination Finals", - seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null, + seedInfo: participantIds ? `${higher + 1} vs ${opponent}` : null, }); } @@ -868,7 +875,7 @@ async function generateAFL10Bracket( * Phase 3.3: Handles both winners and losers advancing to different rounds * * 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 * - Elimination Finals: Winner → Semi-Finals * - Semi-Finals: Winner → Preliminary Finals @@ -881,18 +888,95 @@ async function advanceAFLWinner( ): Promise { const eventId = match.scoringEventId; - // Wildcard Round: Winner advances to Elimination Finals + // Wildcard Round: Winners are re-seeded into the Elimination Finals — 5th meets the + // lower-ranked winner and 6th the higher-ranked one, not a fixed 7v10-to-6th crossover. + // Because the destination depends on both games, every result re-resolves both slots: + // that places a winner whose slot only became certain once the other game was decided, + // and it moves one that an earlier (or corrected) result had put in the other slot. if (match.round === "Wildcard Round") { - // Wildcard Match 1 winner → EF Match 2, participant2Id - // Wildcard Match 2 winner → EF Match 1, participant2Id - const efMatchNumber = match.matchNumber === 1 ? 2 : 1; - const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"); - const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber); + const [wcMatches, efMatches] = await Promise.all([ + findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"), + findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"), + ]); - if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`); - if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`); + // The row for the match being advanced may predate this result, so use the winner + // passed in rather than whatever the read returned. + const winnerByMatchNumber = new Map(); + for (const wc of wcMatches) { + const decidedWinner = wc.id === match.id ? winnerId : wc.isComplete ? wc.winnerId : null; + if (decidedWinner) winnerByMatchNumber.set(wc.matchNumber, decidedWinner); + } - await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId }); + 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(); + 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(); + for (const wc of wcMatches) { + if (wc.participant1Id) wildcardParticipants.add(wc.participant1Id); + if (wc.participant2Id) wildcardParticipants.add(wc.participant2Id); + } + + const slotsToClear: string[] = []; + const slotsToFill: Array<{ id: string; 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` + ); + } + // A Wildcard team in the wrong slot is a placement this result supersedes: a + // corrected Wildcard winner, or one placed before the re-seeding rule existed. + if (occupant !== null) slotsToClear.push(efMatch.id); + if (belongsHere !== null) slotsToFill.push({ id: efMatch.id, participantId: belongsHere }); + } + + if (slotsToClear.length === 0 && slotsToFill.length === 0) return; + + // 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 id of slotsToClear) { + await tx + .update(schema.playoffMatches) + .set({ participant2Id: null, updatedAt: now }) + .where(eq(schema.playoffMatches.id, id)); + } + for (const { id, participantId } of slotsToFill) { + await tx + .update(schema.playoffMatches) + .set({ participant2Id: participantId, updatedAt: now }) + .where(eq(schema.playoffMatches.id, id)); + } + }); return; } diff --git a/app/services/simulations/__tests__/afl-simulator.test.ts b/app/services/simulations/__tests__/afl-simulator.test.ts index 0b63307..80b7d56 100644 --- a/app/services/simulations/__tests__/afl-simulator.test.ts +++ b/app/services/simulations/__tests__/afl-simulator.test.ts @@ -5,6 +5,7 @@ import { eloWinProbability, AFLSimulator, readAflBracketSeeds, + simAFLFinals, type BracketMatch, } from "../afl-simulator"; import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types"; @@ -623,3 +624,62 @@ describe("readAflBracketSeeds", () => { expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/not in this sports season/); }); }); + +// ─── simAFLFinals ───────────────────────────────────────────────────────────── + +describe("simAFLFinals Elimination Finals re-seeding", () => { + 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 { + const pairings = new Map(); + 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]); + } + ); +}); diff --git a/app/services/simulations/afl-simulator.ts b/app/services/simulations/afl-simulator.ts index 174e91a..9fd5033 100644 --- a/app/services/simulations/afl-simulator.ts +++ b/app/services/simulations/afl-simulator.ts @@ -37,7 +37,8 @@ * Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts) * Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye) * losers → Semi-Finals (2nd chance) - * Elimination Finals: #5 vs WC2w, #6 vs WC1w → losers exit (7th/8th) + * 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) * Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th) * Grand Final: PF1w vs PF2w → winner 1st, loser 2nd @@ -409,9 +410,14 @@ export function simAFLFinals( const qf1 = play("Qualifying Finals", 1, s1, s4); const qf2 = play("Qualifying Finals", 2, s2, s3); - // Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner - const ef1 = play("Elimination Finals", 1, s5, wc2.winner); - const ef2 = play("Elimination Finals", 2, s6, wc1.winner); + // Elimination Finals: the Wildcard winners are re-seeded by ladder position, so #5 + // hosts whichever finished lower and #6 the other — not a fixed crossover. + const wc1Seed = wc1.winner === s7 ? 7 : 10; + const wc2Seed = wc2.winner === s8 ? 8 : 9; + const [betterWc, worseWc] = + wc1Seed < wc2Seed ? [wc1.winner, wc2.winner] : [wc2.winner, wc1.winner]; + const ef1 = play("Elimination Finals", 1, s5, worseWc); + const ef2 = play("Elimination Finals", 2, s6, betterWc); // Semi-Finals: QF losers (second chance) vs EF winners const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner);