Re-resolve both Elimination Final slots on every Wildcard result

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
This commit is contained in:
Claude 2026-09-04 15:27:16 +00:00
parent b566c5f1a5
commit 273735e572
No known key found for this signature in database
2 changed files with 120 additions and 30 deletions

View file

@ -54,17 +54,24 @@ const db = {
}, },
}, },
update: vi.fn(() => ({ update: vi.fn(() => ({
set: (data: Partial<MatchRow>) => ({ set: (data: Partial<MatchRow>) => {
where: (where: unknown) => ({ const applyTo = (where: unknown) => {
returning: () => { const values = whereValues(where);
const values = whereValues(where); const row = rows.find((r) => values.includes(r.id));
const row = rows.find((r) => values.includes(r.id)); if (row) Object.assign(row, data);
if (row) Object.assign(row, data); return row;
return Promise.resolve([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<unknown>) => fn(db)),
}; };
vi.mock("~/database/context", () => ({ database: () => 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/); await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already filled/);
expect(row("ef1").participant2Id).toBe("stranger"); 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));
});
}); });

View file

@ -890,8 +890,9 @@ async function advanceAFLWinner(
// Wildcard Round: Winners are re-seeded into the Elimination Finals — 5th meets the // 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. // 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 // Because the destination depends on both games, every result re-resolves both slots:
// certain once the other game is decided is placed then. // 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") { if (match.round === "Wildcard Round") {
const [wcMatches, efMatches] = await Promise.all([ const [wcMatches, efMatches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"), 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 // The row for the match being advanced may predate this result, so use the winner
// passed in rather than whatever the read returned. // passed in rather than whatever the read returned.
const winnerById = new Map<number, string>(); const winnerByMatchNumber = new Map<number, string>();
for (const wc of wcMatches) { for (const wc of wcMatches) {
const decidedWinner = wc.id === match.id ? winnerId : wc.isComplete ? wc.winnerId : null; 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 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 === null) return { matchNumber: wc.matchNumber, winnerSlot: null };
if (decidedWinner === wc.participant1Id) return { matchNumber: wc.matchNumber, winnerSlot: 1 }; if (decidedWinner === wc.participant1Id) return { matchNumber: wc.matchNumber, winnerSlot: 1 };
if (decidedWinner === wc.participant2Id) return { matchNumber: wc.matchNumber, winnerSlot: 2 }; if (decidedWinner === wc.participant2Id) return { matchNumber: wc.matchNumber, winnerSlot: 2 };
@ -916,24 +917,57 @@ async function advanceAFLWinner(
); );
}); });
const wanted = new Map<number, string>();
for (const placement of resolveAflWildcardPlacements(results)) { for (const placement of resolveAflWildcardPlacements(results)) {
const placedWinner = winnerById.get(placement.wildcardMatchNumber); const placedWinner = winnerByMatchNumber.get(placement.wildcardMatchNumber);
if (!placedWinner) continue; if (placedWinner) wanted.set(placement.eliminationMatchNumber, placedWinner);
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 });
} }
// 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<string>();
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; return;
} }