From 273735e57238d329312d4cc8c91c0f5c283709eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:27:16 +0000 Subject: [PATCH] Re-resolve both Elimination Final slots on every Wildcard result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that a Wildcard winner's destination depends on both games, guarding only the slot about to be written is not enough. Correcting a recorded result placed the new winner in the other Elimination Final and left the beaten team alive in the one it had been written to, and a bracket already advanced under the old fixed crossover ended up with one team in both Elimination Finals once the second result came in — the second write hit "already filled", which every caller swallows. Each result now reconciles both slots against the resolved pairings: a Wildcard team sitting in the wrong slot is vacated, the right ones are written, and a slot held by anyone outside the Wildcard Round still raises "already filled" rather than being taken. Clearing and filling share one transaction, so a half-applied re-seed cannot leave a team in both games, and pairings that are already correct do no writes at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK --- .../afl-wildcard-advancement.test.ts | 74 +++++++++++++++--- app/models/playoff-match.ts | 76 ++++++++++++++----- 2 files changed, 120 insertions(+), 30 deletions(-) diff --git a/app/models/__tests__/afl-wildcard-advancement.test.ts b/app/models/__tests__/afl-wildcard-advancement.test.ts index bfd6172..ee563c1 100644 --- a/app/models/__tests__/afl-wildcard-advancement.test.ts +++ b/app/models/__tests__/afl-wildcard-advancement.test.ts @@ -54,17 +54,24 @@ const db = { }, }, update: vi.fn(() => ({ - set: (data: Partial) => ({ - where: (where: unknown) => ({ - returning: () => { - const values = whereValues(where); - const row = rows.find((r) => values.includes(r.id)); - if (row) Object.assign(row, data); - return Promise.resolve([row]); + set: (data: Partial) => { + const applyTo = (where: unknown) => { + const values = whereValues(where); + const row = rows.find((r) => values.includes(r.id)); + if (row) Object.assign(row, data); + return row; + }; + // 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 })); @@ -170,4 +177,53 @@ describe("AFL Wildcard Round advancement", () => { 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("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 6a088b6..f2b5233 100644 --- a/app/models/playoff-match.ts +++ b/app/models/playoff-match.ts @@ -890,8 +890,9 @@ async function advanceAFLWinner( // 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. - // Both games are re-resolved on every result, so a winner whose slot only becomes - // certain once the other game is decided is placed then. + // 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") { const [wcMatches, efMatches] = await Promise.all([ findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"), @@ -900,14 +901,14 @@ async function advanceAFLWinner( // The row for the match being advanced may predate this result, so use the winner // passed in rather than whatever the read returned. - const winnerById = new Map(); + const winnerByMatchNumber = new Map(); for (const wc of wcMatches) { const decidedWinner = wc.id === match.id ? winnerId : wc.isComplete ? wc.winnerId : null; - if (decidedWinner) winnerById.set(wc.matchNumber, decidedWinner); + if (decidedWinner) winnerByMatchNumber.set(wc.matchNumber, decidedWinner); } const results: AflWildcardResult[] = wcMatches.map((wc) => { - const decidedWinner = winnerById.get(wc.matchNumber) ?? null; + 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 }; @@ -916,24 +917,57 @@ async function advanceAFLWinner( ); }); + const wanted = new Map(); for (const placement of resolveAflWildcardPlacements(results)) { - const placedWinner = winnerById.get(placement.wildcardMatchNumber); - if (!placedWinner) continue; - - const efMatch = efMatches.find((m) => m.matchNumber === placement.eliminationMatchNumber); - if (!efMatch) { - throw new Error(`Elimination Finals match ${placement.eliminationMatchNumber} not found`); - } - // Already placed by an earlier Wildcard result — re-resolving is idempotent. - if (efMatch.participant2Id === placedWinner) continue; - if (efMatch.participant2Id) { - throw new Error( - `EF ${placement.eliminationMatchNumber} participant2 already filled` - ); - } - - await updatePlayoffMatch(efMatch.id, { participant2Id: placedWinner }); + 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`); + } + // 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; }