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:
parent
b566c5f1a5
commit
273735e572
2 changed files with 120 additions and 30 deletions
|
|
@ -54,17 +54,24 @@ const db = {
|
|||
},
|
||||
},
|
||||
update: vi.fn(() => ({
|
||||
set: (data: Partial<MatchRow>) => ({
|
||||
where: (where: unknown) => ({
|
||||
returning: () => {
|
||||
set: (data: Partial<MatchRow>) => {
|
||||
const applyTo = (where: unknown) => {
|
||||
const values = whereValues(where);
|
||||
const row = rows.find((r) => values.includes(r.id));
|
||||
if (row) Object.assign(row, data);
|
||||
return Promise.resolve([row]);
|
||||
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<unknown>) => 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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<number, string>();
|
||||
const winnerByMatchNumber = new Map<number, string>();
|
||||
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<number, string>();
|
||||
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`
|
||||
);
|
||||
const placedWinner = winnerByMatchNumber.get(placement.wildcardMatchNumber);
|
||||
if (placedWinner) wanted.set(placement.eliminationMatchNumber, placedWinner);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue