Compare commits
No commits in common. "95acc6fcbacba29ef21e8f9c091b4619a2714a8b" and "a7b7921b9bb11aef96d5b77e43139726a738a43e" have entirely different histories.
95acc6fcba
...
a7b7921b9b
6 changed files with 89 additions and 499 deletions
|
|
@ -76,7 +76,7 @@ const db = {
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({ database: () => db }));
|
vi.mock("~/database/context", () => ({ database: () => db }));
|
||||||
|
|
||||||
const { advanceWinnerTemplate, reseedAflEliminationFinals } = await import("../playoff-match");
|
const { advanceWinnerTemplate } = await import("../playoff-match");
|
||||||
|
|
||||||
const EVENT = "event-1";
|
const EVENT = "event-1";
|
||||||
|
|
||||||
|
|
@ -223,40 +223,6 @@ describe("AFL Wildcard Round advancement", () => {
|
||||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
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 () => {
|
it("leaves the bracket alone when the pairings are already right", async () => {
|
||||||
await winWildcard("wc1", seed(7));
|
await winWildcard("wc1", seed(7));
|
||||||
await winWildcard("wc2", seed(8));
|
await winWildcard("wc2", seed(8));
|
||||||
|
|
|
||||||
|
|
@ -870,129 +870,6 @@ async function generateAFL10Bracket(
|
||||||
return await createManyPlayoffMatches(matches);
|
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<AflEliminationReseed> {
|
|
||||||
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<number, string>();
|
|
||||||
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<number, string>();
|
|
||||||
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<string>();
|
|
||||||
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
|
* AFL-specific advancement logic for the complex double-chance system
|
||||||
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
||||||
|
|
@ -1011,10 +888,95 @@ async function advanceAFLWinner(
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const eventId = match.scoringEventId;
|
const eventId = match.scoringEventId;
|
||||||
|
|
||||||
// Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
|
// Wildcard Round: Winners are re-seeded into the Elimination Finals — 5th meets the
|
||||||
// position, so every result re-resolves both slots.
|
// 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.
|
||||||
if (match.round === "Wildcard Round") {
|
if (match.round === "Wildcard Round") {
|
||||||
await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId });
|
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<number, string>();
|
||||||
|
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<number, string>();
|
||||||
|
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<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`);
|
||||||
|
}
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
/**
|
|
||||||
* 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<object>()),
|
|
||||||
getScoringEventById: vi.fn(),
|
|
||||||
isReadOnlySibling: vi.fn(() => false),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
reseedAflEliminationFinals: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
findParticipantsBySportsSeasonId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
processMatchResult: vi.fn(),
|
|
||||||
recalculateAffectedLeagues: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/services/discord", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -16,7 +16,6 @@ import {
|
||||||
findPlayoffMatchById,
|
findPlayoffMatchById,
|
||||||
assignParticipantsToKnockout,
|
assignParticipantsToKnockout,
|
||||||
doesLoserAdvance,
|
doesLoserAdvance,
|
||||||
reseedAflEliminationFinals,
|
|
||||||
} from "~/models/playoff-match";
|
} from "~/models/playoff-match";
|
||||||
import {
|
import {
|
||||||
createGame,
|
createGame,
|
||||||
|
|
@ -867,48 +866,6 @@ 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") {
|
if (intent === "reprocess-bracket") {
|
||||||
try {
|
try {
|
||||||
const event = await getScoringEventById(params.eventId);
|
const event = await getScoringEventById(params.eventId);
|
||||||
|
|
|
||||||
|
|
@ -613,31 +613,6 @@ export default function EventBracket({
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Re-seed Wildcard Winners</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
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.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Form method="post">
|
|
||||||
<input type="hidden" name="intent" value="reseed-afl-wildcard" />
|
|
||||||
<Button type="submit" variant="outline">
|
|
||||||
Re-seed Wildcard Winners
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else
|
{/* 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
|
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. */}
|
and rebuilt via the setup form below, which reappears once this runs. */}
|
||||||
|
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 <id> # 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: <host> vs <qualifier>" 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);
|
|
||||||
});
|
|
||||||
Loading…
Add table
Reference in a new issue