Merge pull request 'claude/admiring-bohr-2zvef9' (#149) from claude/admiring-bohr-2zvef9 into main
Reviewed-on: #149
This commit is contained in:
commit
2639102f06
7 changed files with 651 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)
|
* - 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
|
* - 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
|
* position — 5th draws the lower-ranked winner, 6th the higher-ranked one
|
||||||
* - Semi-Finals: QF losers vs EF winners
|
* - Semi-Finals: SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner
|
||||||
* - Preliminary Finals: QF winners vs SF winners
|
* - 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
|
* - Grand Final: PF winners
|
||||||
*/
|
*/
|
||||||
async function generateAFL10Bracket(
|
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++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
matches.push({
|
matches.push({
|
||||||
scoringEventId: eventId,
|
scoringEventId: eventId,
|
||||||
|
|
@ -993,15 +994,134 @@ export async function reseedAflEliminationFinals(
|
||||||
return reseed;
|
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
|
* 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
|
||||||
*
|
*
|
||||||
* Advancement rules:
|
* Advancement rules:
|
||||||
* - Wildcard Round: Winner → Elimination Finals (re-seeded by ladder position)
|
* - Wildcard Round: Winner → Elimination Finals (re-seeded by ladder position)
|
||||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals (QF n → PF n, SF n)
|
||||||
* - Elimination Finals: Winner → Semi-Finals
|
* - Elimination Finals: Winner → Semi-Finals (EF n → SF n, a fixed pathway)
|
||||||
* - Semi-Finals: Winner → Preliminary Finals
|
* - Semi-Finals: Winner → Preliminary Finals (SF n crosses over: SF1 → PF2, SF2 → PF1)
|
||||||
* - Preliminary Finals: Winner → Grand Final
|
* - Preliminary Finals: Winner → Grand Final
|
||||||
*/
|
*/
|
||||||
async function advanceAFLWinner(
|
async function advanceAFLWinner(
|
||||||
|
|
@ -1042,18 +1162,11 @@ async function advanceAFLWinner(
|
||||||
return;
|
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") {
|
if (match.round === "Elimination Finals") {
|
||||||
// EF Match 1 winner → SF2 participant2
|
await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
|
||||||
// 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 });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
/**
|
||||||
|
* 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("reports a slot that was emptied without being refilled", async () => {
|
||||||
|
// Un-recording an Elimination Final result takes its winner back out of the semi.
|
||||||
|
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
||||||
|
vacated: [1, 2],
|
||||||
|
filled: [{ matchNumber: 2, participantId: "adelaide" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await run()).toEqual({
|
||||||
|
success:
|
||||||
|
"Re-seeded the Semi-Finals: match 1 is back to TBD, " +
|
||||||
|
"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,
|
assignParticipantsToKnockout,
|
||||||
doesLoserAdvance,
|
doesLoserAdvance,
|
||||||
reseedAflEliminationFinals,
|
reseedAflEliminationFinals,
|
||||||
|
reseedAflSemiFinals,
|
||||||
} from "~/models/playoff-match";
|
} from "~/models/playoff-match";
|
||||||
import {
|
import {
|
||||||
createGame,
|
createGame,
|
||||||
|
|
@ -909,6 +910,59 @@ 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.
|
||||||
|
//
|
||||||
|
// A slot can be vacated without being refilled — un-recording an Elimination Final
|
||||||
|
// result takes its winner back out — so report those too rather than rendering an
|
||||||
|
// empty list.
|
||||||
|
const filled = reseed.filled.map((slot) => ({
|
||||||
|
matchNumber: slot.matchNumber,
|
||||||
|
text: `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`,
|
||||||
|
}));
|
||||||
|
const emptied = reseed.vacated
|
||||||
|
.filter((matchNumber) => !reseed.filled.some((slot) => slot.matchNumber === matchNumber))
|
||||||
|
.map((matchNumber) => ({ matchNumber, text: `match ${matchNumber} is back to TBD` }));
|
||||||
|
const moves = [...filled, ...emptied]
|
||||||
|
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||||
|
.map((move) => move.text)
|
||||||
|
.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") {
|
if (intent === "reprocess-bracket") {
|
||||||
try {
|
try {
|
||||||
const event = await getScoringEventById(params.eventId);
|
const event = await getScoringEventById(params.eventId);
|
||||||
|
|
|
||||||
|
|
@ -638,6 +638,31 @@ export default function EventBracket({
|
||||||
</Card>
|
</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
|
{/* 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. */}
|
||||||
|
|
|
||||||
|
|
@ -627,7 +627,7 @@ describe("readAflBracketSeeds", () => {
|
||||||
|
|
||||||
// ─── simAFLFinals ─────────────────────────────────────────────────────────────
|
// ─── simAFLFinals ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("simAFLFinals Elimination Finals re-seeding", () => {
|
describe("simAFLFinals bracket pathways", () => {
|
||||||
const finalists = Array.from({ length: 10 }, (_, i) => ({
|
const finalists = Array.from({ length: 10 }, (_, i) => ({
|
||||||
id: `s${i + 1}`,
|
id: `s${i + 1}`,
|
||||||
name: `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]);
|
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)
|
* losers → Semi-Finals (2nd chance)
|
||||||
* Elimination Finals: #5 vs lower WC winner, → losers exit (7th/8th)
|
* Elimination Finals: #5 vs lower WC winner, → losers exit (7th/8th)
|
||||||
* #6 vs higher WC winner
|
* #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)
|
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
||||||
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
* 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
|
* Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a
|
||||||
* recorded result is looked up against the game it was actually played in:
|
* 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.
|
* PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner.
|
||||||
*
|
*
|
||||||
* Returns the placement for each team:
|
* Returns the placement for each team:
|
||||||
|
|
@ -419,9 +419,10 @@ export function simAFLFinals(
|
||||||
const ef1 = play("Elimination Finals", 1, s5, worseWc);
|
const ef1 = play("Elimination Finals", 1, s5, worseWc);
|
||||||
const ef2 = play("Elimination Finals", 2, s6, betterWc);
|
const ef2 = play("Elimination Finals", 2, s6, betterWc);
|
||||||
|
|
||||||
// Semi-Finals: QF losers (second chance) vs EF winners
|
// Semi-Finals: QF losers (second chance) vs EF winners. Elimination Final n feeds
|
||||||
const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner);
|
// Semi-Final n — a fixed pathway; the crossover is a round later, at the Prelims.
|
||||||
const sf2 = play("Semi-Finals", 2, qf2.loser, ef1.winner);
|
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
|
// Preliminary Finals: QF winners vs SF winners
|
||||||
const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);
|
const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue