diff --git a/app/models/__tests__/afl-wildcard-advancement.test.ts b/app/models/__tests__/afl-wildcard-advancement.test.ts index 19ee384..8c537fe 100644 --- a/app/models/__tests__/afl-wildcard-advancement.test.ts +++ b/app/models/__tests__/afl-wildcard-advancement.test.ts @@ -76,7 +76,7 @@ const db = { vi.mock("~/database/context", () => ({ database: () => db })); -const { advanceWinnerTemplate } = await import("../playoff-match"); +const { advanceWinnerTemplate, reseedAflEliminationFinals } = await import("../playoff-match"); const EVENT = "event-1"; @@ -223,6 +223,40 @@ describe("AFL Wildcard Round advancement", () => { 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)); diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts index 1b3a0f6..f6e1379 100644 --- a/app/models/playoff-match.ts +++ b/app/models/playoff-match.ts @@ -870,6 +870,129 @@ async function generateAFL10Bracket( return await createManyPlayoffMatches(matches); } +/** What a re-seed changed, by Elimination Finals match number. */ +export interface AflEliminationReseed { + vacated: number[]; + filled: Array<{ matchNumber: number; participantId: string }>; +} + +/** + * Put the decided Wildcard winners in the Elimination Finals they belong in. + * + * The two winners are re-seeded by ladder position — 5th meets the lower-ranked one and + * 6th the higher-ranked one — rather than crossing over from a fixed Wildcard match. That + * destination depends on both games, so this reconciles both slots against the results + * recorded so far every time it runs: it places a winner whose slot only became certain + * once the other game was decided, and moves one that an earlier (or corrected) result, + * or a bracket advanced before this rule existed, put in the other slot. + * + * `pending` supplies a result that may not be in the database yet — the row read back + * while advancing a match can predate the winner being written to it. + * + * Idempotent: pairings that are already right do no writes. + */ +export async function reseedAflEliminationFinals( + eventId: string, + pending?: { matchId: string; winnerId: string } +): Promise { + 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(); + 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(); + 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: 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; +} + /** * AFL-specific advancement logic for the complex double-chance system * Phase 3.3: Handles both winners and losers advancing to different rounds @@ -888,95 +1011,10 @@ async function advanceAFLWinner( ): Promise { const eventId = match.scoringEventId; - // 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. + // 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") { - const [wcMatches, efMatches] = await Promise.all([ - findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"), - findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"), - ]); - - // 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); - } - - 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)); - } - }); + await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId }); return; } 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 new file mode 100644 index 0000000..da30392 --- /dev/null +++ b/scripts/fix-afl-wildcard-reseed.ts @@ -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 # 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: vs " 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); +});