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(() => ({
|
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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue