Feed each AFL Elimination Final into the Semi-Final of the same number
The Elimination Final winners were crossed into the Semi-Finals — EF1's winner met the QF2 loser and EF2's the QF1 loser. The AFL feeds them straight through: SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the EF2 winner. The crossover in this system lands a round later, at Semi-Final → Preliminary Final, so a Qualifying Final loser cannot meet the side that just beat it — that part was already right and is unchanged. In 2026 that drew Fremantle v Adelaide and Brisbane v Geelong, when Fremantle played Geelong and Brisbane played Adelaide. Placement now reconciles both Semi-Final slots on every Elimination Final result rather than writing the one it was called for, so correcting a recorded result moves the qualifier instead of leaving the beaten team alive in a semi. A slot held by anyone who never played an Elimination Final still raises "already filled", and a Semi-Final that has been played refuses the move rather than rewriting who contested it. The simulator paired the Semi-Finals the same crossed way, which biased every projection running off an undecided Elimination Final; it now feeds straight through too. Brackets already advanced under the crossover keep their wrong pairings, since no admin action re-runs advancement — a completed match cannot be re-submitted. Admin → the event's bracket gains a "Fix Semi-Final Pairings" button that runs the same reconciliation over a bracket as it stands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSDeNWAXvK7nznJqjxn7Jo
This commit is contained in:
parent
10363da100
commit
cdf86d1682
7 changed files with 626 additions and 23 deletions
273
app/models/__tests__/afl-semifinal-pairing.test.ts
Normal file
273
app/models/__tests__/afl-semifinal-pairing.test.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
/**
|
||||
* Advancing an AFL Elimination Finals winner into the Semi-Finals.
|
||||
*
|
||||
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
|
||||
* n. The crossover comes a round later, at Semi-Finals → Preliminary Finals, so that a
|
||||
* Qualifying Final loser cannot meet the side that just beat it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AFL_10 } from "~/lib/bracket-templates";
|
||||
|
||||
interface MatchRow {
|
||||
id: string;
|
||||
scoringEventId: string;
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
isComplete: boolean;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
}
|
||||
|
||||
let rows: MatchRow[] = [];
|
||||
|
||||
/**
|
||||
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
|
||||
* mock needs to tell one lookup from another — there is no query engine behind it.
|
||||
*/
|
||||
function whereValues(node: unknown, depth = 0): string[] {
|
||||
if (!node || depth > 10) return [];
|
||||
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
|
||||
if (typeof node !== "object") return [];
|
||||
const obj = node as Record<string, unknown>;
|
||||
const own = typeof obj.value === "string" ? [obj.value] : [];
|
||||
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
|
||||
}
|
||||
|
||||
const db = {
|
||||
query: {
|
||||
playoffMatches: {
|
||||
findFirst: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(rows.find((r) => values.includes(r.id)));
|
||||
}),
|
||||
findMany: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(
|
||||
rows
|
||||
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
);
|
||||
}),
|
||||
},
|
||||
},
|
||||
update: vi.fn(() => ({
|
||||
set: (data: Partial<MatchRow>) => {
|
||||
const applyTo = (where: unknown) => {
|
||||
const values = whereValues(where);
|
||||
const target = rows.find((r) => values.includes(r.id));
|
||||
if (target) Object.assign(target, data);
|
||||
return target;
|
||||
};
|
||||
// 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 }));
|
||||
|
||||
const { advanceWinnerTemplate, reseedAflSemiFinals } = await import("../playoff-match");
|
||||
|
||||
const EVENT = "event-1";
|
||||
|
||||
/**
|
||||
* The real 2026 finals, which is what surfaced the crossover bug. Ladder: 1 Fremantle,
|
||||
* 2 Sydney, 3 Brisbane, 4 Hawthorn, 5 Geelong, 6 Adelaide, 7 Melbourne, 8 Bulldogs,
|
||||
* 9 Collingwood, 10 Carlton. Carlton (10th) and the Bulldogs (8th) came through the
|
||||
* Wildcard Round, so 5th hosts Carlton and 6th hosts the Bulldogs.
|
||||
*/
|
||||
const FREO = "fremantle";
|
||||
const SYDNEY = "sydney";
|
||||
const BRISBANE = "brisbane";
|
||||
const HAWTHORN = "hawthorn";
|
||||
const GEELONG = "geelong";
|
||||
const ADELAIDE = "adelaide";
|
||||
const BULLDOGS = "bulldogs";
|
||||
const CARLTON = "carlton";
|
||||
|
||||
/** An afl_10 bracket with week one played: Freo and Brisbane lost their Qualifying Finals. */
|
||||
function bracket(): MatchRow[] {
|
||||
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
|
||||
return [
|
||||
{ ...base, id: "qf1", round: "Qualifying Finals", matchNumber: 1, participant1Id: FREO, participant2Id: HAWTHORN, isComplete: true, winnerId: HAWTHORN, loserId: FREO },
|
||||
{ ...base, id: "qf2", round: "Qualifying Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: BRISBANE, isComplete: true, winnerId: SYDNEY, loserId: BRISBANE },
|
||||
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: GEELONG, participant2Id: CARLTON },
|
||||
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: ADELAIDE, participant2Id: BULLDOGS },
|
||||
// Filled by the Qualifying Final losers, as advancement already does.
|
||||
{ ...base, id: "sf1", round: "Semi-Finals", matchNumber: 1, participant1Id: FREO, participant2Id: null },
|
||||
{ ...base, id: "sf2", round: "Semi-Finals", matchNumber: 2, participant1Id: BRISBANE, participant2Id: null },
|
||||
{ ...base, id: "pf1", round: "Preliminary Finals", matchNumber: 1, participant1Id: HAWTHORN, participant2Id: null },
|
||||
{ ...base, id: "pf2", round: "Preliminary Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: null },
|
||||
];
|
||||
}
|
||||
|
||||
function row(id: string): MatchRow {
|
||||
const found = rows.find((r) => r.id === id);
|
||||
if (!found) throw new Error(`No such match ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Record a result the way setMatchWinner does, then advance it. */
|
||||
async function win(id: string, winnerId: string) {
|
||||
const match = row(id);
|
||||
match.winnerId = winnerId;
|
||||
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||||
match.isComplete = true;
|
||||
await advanceWinnerTemplate(id, winnerId, AFL_10);
|
||||
}
|
||||
|
||||
const pairing = () => ({
|
||||
sf1: [row("sf1").participant1Id, row("sf1").participant2Id],
|
||||
sf2: [row("sf2").participant1Id, row("sf2").participant2Id],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
rows = bracket();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Elimination Finals → Semi-Finals advancement", () => {
|
||||
it("feeds Elimination Final 1 into Semi-Final 1", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
|
||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
||||
expect(row("sf2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("feeds Elimination Final 2 into Semi-Final 2", async () => {
|
||||
await win("ef2", ADELAIDE);
|
||||
|
||||
expect(row("sf2").participant2Id).toBe(ADELAIDE);
|
||||
expect(row("sf1").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the real 2026 Semi-Finals: Freo v Geelong and Brisbane v Adelaide", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
await win("ef2", ADELAIDE);
|
||||
|
||||
expect(pairing()).toEqual({
|
||||
sf1: [FREO, GEELONG],
|
||||
sf2: [BRISBANE, ADELAIDE],
|
||||
});
|
||||
});
|
||||
|
||||
it("draws the same Semi-Finals whichever order the results are entered", async () => {
|
||||
await win("ef2", ADELAIDE);
|
||||
await win("ef1", GEELONG);
|
||||
|
||||
expect(pairing()).toEqual({
|
||||
sf1: [FREO, GEELONG],
|
||||
sf2: [BRISBANE, ADELAIDE],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the Preliminary Finals crossover so a QF loser dodges the side that beat it", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
await win("ef2", ADELAIDE);
|
||||
// Freo (lost QF1 to Hawthorn) wins its semi, so it must land in Sydney's Prelim.
|
||||
await win("sf1", FREO);
|
||||
|
||||
expect(row("pf2").participant2Id).toBe(FREO);
|
||||
expect(row("pf1").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("pulls the beaten team back out when an Elimination Final result is corrected", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
||||
|
||||
await win("ef1", CARLTON);
|
||||
|
||||
expect(row("sf1").participant2Id).toBe(CARLTON);
|
||||
expect(row("sf2").participant2Id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reseedAflSemiFinals", () => {
|
||||
it("repairs a bracket left crossed by the old fixed crossover", async () => {
|
||||
// What advancement wrote before the fix: EF1 winner into SF2, EF2 winner into SF1.
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
|
||||
row("sf1").participant2Id = ADELAIDE;
|
||||
row("sf2").participant2Id = GEELONG;
|
||||
|
||||
const reseed = await reseedAflSemiFinals(EVENT);
|
||||
|
||||
expect(pairing()).toEqual({
|
||||
sf1: [FREO, GEELONG],
|
||||
sf2: [BRISBANE, ADELAIDE],
|
||||
});
|
||||
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
|
||||
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
|
||||
{ matchNumber: 1, participantId: GEELONG },
|
||||
{ matchNumber: 2, participantId: ADELAIDE },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes nothing when the pairings are already right", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
|
||||
row("sf1").participant2Id = GEELONG;
|
||||
row("sf2").participant2Id = ADELAIDE;
|
||||
|
||||
const reseed = await reseedAflSemiFinals(EVENT);
|
||||
|
||||
expect(reseed).toEqual({ vacated: [], filled: [] });
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves an undecided Elimination Final's slot TBD", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
|
||||
await reseedAflSemiFinals(EVENT);
|
||||
|
||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
||||
expect(row("sf2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a slot held by someone who never played an Elimination Final", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
row("sf1").participant2Id = SYDNEY;
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow("SF 1 participant2 already filled");
|
||||
});
|
||||
|
||||
it("refuses to move a qualifier out of a Semi-Final that has been played", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
Object.assign(row("sf1"), {
|
||||
participant2Id: ADELAIDE,
|
||||
isComplete: true,
|
||||
winnerId: FREO,
|
||||
loserId: ADELAIDE,
|
||||
});
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
||||
"Semi-Finals match 1 already has a recorded result"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an Elimination Final winner who is not one of its participants", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: SYDNEY, loserId: CARLTON });
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
||||
"Elimination Finals match 1 winner is not one of its participants"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on an event with no Semi-Finals to re-seed", async () => {
|
||||
rows = rows.filter((r) => r.round !== "Semi-Finals");
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
||||
"no AFL Elimination Finals / Semi-Finals matches to re-seed"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -748,8 +748,9 @@ async function generateNFL14Bracket(
|
|||
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
||||
* - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder
|
||||
* position — 5th draws the lower-ranked winner, 6th the higher-ranked one
|
||||
* - Semi-Finals: QF losers vs EF winners
|
||||
* - Preliminary Finals: QF winners vs SF winners
|
||||
* - Semi-Finals: SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner
|
||||
* - Preliminary Finals: PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner
|
||||
* (the crossover keeps a QF loser away from the side that just beat it)
|
||||
* - Grand Final: PF winners
|
||||
*/
|
||||
async function generateAFL10Bracket(
|
||||
|
|
@ -824,7 +825,7 @@ async function generateAFL10Bracket(
|
|||
});
|
||||
}
|
||||
|
||||
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
|
||||
// Semi-Finals: SF n = QF n loser vs EF n winner (TBD vs TBD)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
|
|
@ -993,15 +994,134 @@ export async function reseedAflEliminationFinals(
|
|||
return reseed;
|
||||
}
|
||||
|
||||
/** What a Semi-Finals re-seed changed, by Semi-Finals match number. */
|
||||
export interface AflSemiFinalReseed {
|
||||
vacated: number[];
|
||||
filled: Array<{ matchNumber: number; participantId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the decided Elimination Final winners in the Semi-Finals they belong in.
|
||||
*
|
||||
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
|
||||
* n, so SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the EF2
|
||||
* winner. The crossover in this system comes a round later, at Semi-Final → Preliminary
|
||||
* Final, so that a Qualifying Final loser cannot meet the side that just beat it.
|
||||
*
|
||||
* Brackets advanced before this was fixed crossed the two winners — the EF1 winner went
|
||||
* to SF2 and the EF2 winner to SF1 — which is why this reconciles both slots against the
|
||||
* results recorded so far rather than writing the one it was called for: a winner sitting
|
||||
* in the wrong Semi-Final is vacated, and a corrected Elimination Final result pulls the
|
||||
* beaten team back out instead of leaving it alive.
|
||||
*
|
||||
* `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 reseedAflSemiFinals(
|
||||
eventId: string,
|
||||
pending?: { matchId: string; winnerId: string }
|
||||
): Promise<AflSemiFinalReseed> {
|
||||
const [efMatches, sfMatches] = await Promise.all([
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals"),
|
||||
]);
|
||||
|
||||
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
|
||||
if (efMatches.length === 0 || sfMatches.length === 0) {
|
||||
throw new Error(
|
||||
`Event ${eventId} has no AFL Elimination Finals / Semi-Finals matches to re-seed`
|
||||
);
|
||||
}
|
||||
|
||||
// Elimination Final n feeds Semi-Final n, so a decided winner's destination never
|
||||
// depends on the other game.
|
||||
const wanted = new Map<number, string>();
|
||||
for (const ef of efMatches) {
|
||||
const decidedWinner =
|
||||
pending && ef.id === pending.matchId ? pending.winnerId : ef.isComplete ? ef.winnerId : null;
|
||||
if (!decidedWinner) continue;
|
||||
if (decidedWinner !== ef.participant1Id && decidedWinner !== ef.participant2Id) {
|
||||
throw new Error(
|
||||
`Elimination Finals match ${ef.matchNumber} winner is not one of its participants`
|
||||
);
|
||||
}
|
||||
wanted.set(ef.matchNumber, decidedWinner);
|
||||
}
|
||||
|
||||
// Only these teams can legitimately be moved between the two Semi-Finals; anyone else
|
||||
// in a slot came from somewhere this function knows nothing about.
|
||||
const eliminationParticipants = new Set<string>();
|
||||
for (const ef of efMatches) {
|
||||
if (ef.participant1Id) eliminationParticipants.add(ef.participant1Id);
|
||||
if (ef.participant2Id) eliminationParticipants.add(ef.participant2Id);
|
||||
}
|
||||
|
||||
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
|
||||
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
|
||||
|
||||
for (const sfMatch of sfMatches) {
|
||||
const occupant = sfMatch.participant2Id;
|
||||
const belongsHere = wanted.get(sfMatch.matchNumber) ?? null;
|
||||
if (occupant === belongsHere) continue;
|
||||
|
||||
if (occupant !== null && !eliminationParticipants.has(occupant)) {
|
||||
throw new Error(`SF ${sfMatch.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 && (sfMatch.isComplete || sfMatch.winnerId)) {
|
||||
throw new Error(
|
||||
`Semi-Finals match ${sfMatch.matchNumber} already has a recorded result, ` +
|
||||
`so its Elimination Finals qualifier cannot be re-seeded — clear and regenerate the bracket`
|
||||
);
|
||||
}
|
||||
if (occupant !== null) slotsToClear.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber });
|
||||
if (belongsHere !== null) {
|
||||
slotsToFill.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber, participantId: belongsHere });
|
||||
}
|
||||
}
|
||||
|
||||
const reseed: AflSemiFinalReseed = {
|
||||
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 Semi-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
|
||||
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
||||
*
|
||||
* Advancement rules:
|
||||
* - Wildcard Round: Winner → Elimination Finals (re-seeded by ladder position)
|
||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
||||
* - Elimination Finals: Winner → Semi-Finals
|
||||
* - Semi-Finals: Winner → Preliminary Finals
|
||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals (QF n → PF n, SF n)
|
||||
* - Elimination Finals: Winner → Semi-Finals (EF n → SF n, a fixed pathway)
|
||||
* - Semi-Finals: Winner → Preliminary Finals (SF n crosses over: SF1 → PF2, SF2 → PF1)
|
||||
* - Preliminary Finals: Winner → Grand Final
|
||||
*/
|
||||
async function advanceAFLWinner(
|
||||
|
|
@ -1042,18 +1162,11 @@ async function advanceAFLWinner(
|
|||
return;
|
||||
}
|
||||
|
||||
// Elimination Finals: Winner → Semi-Finals
|
||||
// Elimination Finals: Winner → Semi-Finals. EF n feeds SF n — the crossover in this
|
||||
// system is a round later, at Semi-Finals → Preliminary Finals. Reconcile both slots so
|
||||
// a corrected result moves the qualifier instead of leaving the beaten team alive.
|
||||
if (match.round === "Elimination Finals") {
|
||||
// EF Match 1 winner → SF2 participant2
|
||||
// EF Match 2 winner → SF1 participant2
|
||||
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||||
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
|
||||
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
|
||||
|
||||
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
|
||||
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
|
||||
|
||||
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
|
||||
await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* The Fix Semi-Final Pairings admin action.
|
||||
*
|
||||
* Elimination Final n feeds Semi-Final n, but brackets advanced before that was fixed
|
||||
* crossed the two winners, and nothing re-runs advancement — 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 { reseedAflSemiFinals } 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>()),
|
||||
reseedAflSemiFinals: 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-semifinals");
|
||||
return new Request("http://localhost/bracket", { method: "POST", body });
|
||||
}
|
||||
|
||||
const run = () => action({ request: request(), params } as never);
|
||||
|
||||
describe("reseed-afl-semifinals", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never);
|
||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
|
||||
{ id: "geelong", name: "Geelong Cats" },
|
||||
{ id: "adelaide", name: "Adelaide Crows" },
|
||||
] as never);
|
||||
});
|
||||
|
||||
it("names the teams that moved", async () => {
|
||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
||||
vacated: [1, 2],
|
||||
filled: [
|
||||
{ matchNumber: 2, participantId: "adelaide" },
|
||||
{ matchNumber: 1, participantId: "geelong" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(reseedAflSemiFinals).toHaveBeenCalledWith("event-1");
|
||||
expect(result).toEqual({
|
||||
success:
|
||||
"Re-seeded the Semi-Finals: match 1 now hosts Geelong Cats, " +
|
||||
"match 2 now hosts Adelaide Crows.",
|
||||
});
|
||||
});
|
||||
|
||||
it("says so when the pairings are already right", async () => {
|
||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({ vacated: [], filled: [] });
|
||||
|
||||
expect(await run()).toEqual({
|
||||
success: "Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("scores nothing and announces nothing", async () => {
|
||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
||||
vacated: [1, 2],
|
||||
filled: [{ matchNumber: 1, participantId: "geelong" }],
|
||||
});
|
||||
|
||||
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(reseedAflSemiFinals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a refusal to re-seed a game that has been played", async () => {
|
||||
vi.mocked(reseedAflSemiFinals).mockRejectedValue(
|
||||
new Error("Semi-Finals match 1 already has a recorded result")
|
||||
);
|
||||
|
||||
expect(await run()).toEqual({
|
||||
error: "Semi-Finals match 1 already has a recorded result",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
assignParticipantsToKnockout,
|
||||
doesLoserAdvance,
|
||||
reseedAflEliminationFinals,
|
||||
reseedAflSemiFinals,
|
||||
} from "~/models/playoff-match";
|
||||
import {
|
||||
createGame,
|
||||
|
|
@ -909,6 +910,48 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
// Put the Elimination Final winners in the Semi-Finals they belong in. Elimination
|
||||
// Final n feeds Semi-Final n, but brackets advanced before that was fixed crossed the
|
||||
// two winners, and no admin action re-runs advancement (a completed match cannot be
|
||||
// re-submitted).
|
||||
if (intent === "reseed-afl-semifinals") {
|
||||
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 reseedAflSemiFinals(params.eventId);
|
||||
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
|
||||
return {
|
||||
success:
|
||||
"Semi-Finals already match the Elimination Finals 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 Semi-Finals: ${moves}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error re-seeding AFL Elimination Finals winners:", error);
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to re-seed the Semi-Finals",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "reprocess-bracket") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
|
|
|
|||
|
|
@ -638,6 +638,31 @@ export default function EventBracket({
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Fix the Semi-Final pairings. Elimination Final n feeds Semi-Final n, but
|
||||
brackets advanced before that was fixed crossed the two winners, and no
|
||||
admin action re-runs advancement. */}
|
||||
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fix Semi-Final Pairings</CardTitle>
|
||||
<CardDescription>
|
||||
Feed each Elimination Final into the Semi-Final it belongs to: EF1
|
||||
winner into SF1 and EF2 winner into SF2. 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-semifinals" />
|
||||
<Button type="submit" variant="outline">
|
||||
Fix Semi-Final Pairings
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
and rebuilt via the setup form below, which reappears once this runs. */}
|
||||
|
|
|
|||
|
|
@ -627,7 +627,7 @@ describe("readAflBracketSeeds", () => {
|
|||
|
||||
// ─── simAFLFinals ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("simAFLFinals Elimination Finals re-seeding", () => {
|
||||
describe("simAFLFinals bracket pathways", () => {
|
||||
const finalists = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `s${i + 1}`,
|
||||
name: `s${i + 1}`,
|
||||
|
|
@ -682,4 +682,20 @@ describe("simAFLFinals Elimination Finals re-seeding", () => {
|
|||
expect(pairings.get("Elimination Finals#2")).toEqual(["s6", ef2]);
|
||||
}
|
||||
);
|
||||
|
||||
// The pathway out of the Elimination Finals is fixed (EF n → SF n) — unlike the
|
||||
// Wildcard Round's re-seed. The crossover lands a round later, at the Prelims, so a
|
||||
// Qualifying Final loser cannot meet the side that just beat it. `play` here hands
|
||||
// every non-Wildcard game to participant1, so QF1 sends s1 through and s4 down.
|
||||
it("feeds each Elimination Final into the Semi-Final of the same number", () => {
|
||||
const pairings = pairingsWith("s7", "s8");
|
||||
expect(pairings.get("Semi-Finals#1")).toEqual(["s4", "s5"]);
|
||||
expect(pairings.get("Semi-Finals#2")).toEqual(["s3", "s6"]);
|
||||
});
|
||||
|
||||
it("crosses the Semi-Final winners over into the Preliminary Finals", () => {
|
||||
const pairings = pairingsWith("s7", "s8");
|
||||
expect(pairings.get("Preliminary Finals#1")).toEqual(["s1", "s3"]);
|
||||
expect(pairings.get("Preliminary Finals#2")).toEqual(["s2", "s4"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
* losers → Semi-Finals (2nd chance)
|
||||
* Elimination Finals: #5 vs lower WC winner, → losers exit (7th/8th)
|
||||
* #6 vs higher WC winner
|
||||
* Semi-Finals: QF1L vs EF2w, QF2L vs EF1w → losers exit (5th/6th)
|
||||
* Semi-Finals: QF1L vs EF1w, QF2L vs EF2w → losers exit (5th/6th)
|
||||
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
||||
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
||||
*
|
||||
|
|
@ -379,7 +379,7 @@ export function makePlayGame(bracket: LoadedBracket | null, parityFactor: number
|
|||
*
|
||||
* Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a
|
||||
* recorded result is looked up against the game it was actually played in:
|
||||
* SF1 = QF1 loser v EF2 winner, SF2 = QF2 loser v EF1 winner,
|
||||
* SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner,
|
||||
* PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner.
|
||||
*
|
||||
* Returns the placement for each team:
|
||||
|
|
@ -419,9 +419,10 @@ export function simAFLFinals(
|
|||
const ef1 = play("Elimination Finals", 1, s5, worseWc);
|
||||
const ef2 = play("Elimination Finals", 2, s6, betterWc);
|
||||
|
||||
// Semi-Finals: QF losers (second chance) vs EF winners
|
||||
const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner);
|
||||
const sf2 = play("Semi-Finals", 2, qf2.loser, ef1.winner);
|
||||
// Semi-Finals: QF losers (second chance) vs EF winners. Elimination Final n feeds
|
||||
// Semi-Final n — a fixed pathway; the crossover is a round later, at the Prelims.
|
||||
const sf1 = play("Semi-Finals", 1, qf1.loser, ef1.winner);
|
||||
const sf2 = play("Semi-Finals", 2, qf2.loser, ef2.winner);
|
||||
|
||||
// Preliminary Finals: QF winners vs SF winners
|
||||
const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue