Re-seed AFL Wildcard winners into the Elimination Finals
The two Wildcard Round winners were crossed into the Elimination Finals by which game they came out of — the 7v10 winner always met 6th and the 8v9 winner always met 5th. The AFL pairs those games by ladder position, as the classic final eight pairs 5v8 and 6v7: the higher host draws the lower-ranked survivor. So when 10th wins through, 5th should meet 10th, not the 8v9 winner. Placement now resolves both games together. A 7v10 winner is placed as soon as it is decided (7th outranks either possible opponent, 10th is outranked by both), while an 8v9 winner is held until the other game is decided rather than being placed and later moved, so results can be entered in either order. The simulator paired the Elimination Finals the same fixed way, which biased every projection that ran off an undecided Wildcard Round; it now re-seeds too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
This commit is contained in:
parent
f3e00fcacf
commit
b566c5f1a5
7 changed files with 518 additions and 21 deletions
117
app/lib/__tests__/afl-wildcard-reseed.test.ts
Normal file
117
app/lib/__tests__/afl-wildcard-reseed.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* The AFL Wildcard winners are re-seeded into the Elimination Finals by ladder position
|
||||
* (5th draws the lower-ranked winner, 6th the higher-ranked one) rather than crossing
|
||||
* over from a fixed Wildcard match. These tests pin that mapping for every combination
|
||||
* of results, and for either order of entry.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveAflWildcardPlacements,
|
||||
AFL_WILDCARD_DRAW,
|
||||
AFL_ELIMINATION_HOSTS,
|
||||
type AflWildcardResult,
|
||||
} from "../afl-wildcard-reseed";
|
||||
|
||||
/** Both Wildcard games decided, addressed by the seed that won each. */
|
||||
function bothDecided(match1Winner: 7 | 10, match2Winner: 8 | 9): AflWildcardResult[] {
|
||||
return [
|
||||
{ matchNumber: 1, winnerSlot: match1Winner === 7 ? 1 : 2 },
|
||||
{ matchNumber: 2, winnerSlot: match2Winner === 8 ? 1 : 2 },
|
||||
];
|
||||
}
|
||||
|
||||
/** Elimination Finals match number each winning seed was sent to. */
|
||||
function slotsBySeed(results: AflWildcardResult[]): Record<number, number> {
|
||||
return Object.fromEntries(
|
||||
resolveAflWildcardPlacements(results).map((p) => [p.seed, p.eliminationMatchNumber])
|
||||
);
|
||||
}
|
||||
|
||||
describe("AFL Wildcard draw constants", () => {
|
||||
it("draws 7v10 and 8v9", () => {
|
||||
expect(AFL_WILDCARD_DRAW[1]).toEqual([7, 10]);
|
||||
expect(AFL_WILDCARD_DRAW[2]).toEqual([8, 9]);
|
||||
});
|
||||
|
||||
it("hosts the Elimination Finals with seeds 5 and 6", () => {
|
||||
expect(AFL_ELIMINATION_HOSTS[1]).toBe(5);
|
||||
expect(AFL_ELIMINATION_HOSTS[2]).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAflWildcardPlacements", () => {
|
||||
it("sends the higher-ranked winner to 6th and the lower to 5th (7 and 8 win)", () => {
|
||||
expect(slotsBySeed(bothDecided(7, 8))).toEqual({ 7: 2, 8: 1 });
|
||||
});
|
||||
|
||||
it("re-seeds when the lower seed wins the 7v10 game (10 and 8 win)", () => {
|
||||
// The bug this replaces sent the 7v10 winner to 6th regardless, pairing 5th with
|
||||
// 8th and handing 6th the weakest survivor.
|
||||
expect(slotsBySeed(bothDecided(10, 8))).toEqual({ 8: 2, 10: 1 });
|
||||
});
|
||||
|
||||
it("re-seeds when the lower seed wins the 8v9 game (7 and 9 win)", () => {
|
||||
expect(slotsBySeed(bothDecided(7, 9))).toEqual({ 7: 2, 9: 1 });
|
||||
});
|
||||
|
||||
it("re-seeds when both lower seeds win (10 and 9 win)", () => {
|
||||
expect(slotsBySeed(bothDecided(10, 9))).toEqual({ 9: 2, 10: 1 });
|
||||
});
|
||||
|
||||
it("places the 7v10 winner alone, since its rank is settled either way", () => {
|
||||
// 7th outranks both possible 8v9 winners; 10th is outranked by both.
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: 1 },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
])).toEqual({ 7: 2 });
|
||||
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: 2 },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
])).toEqual({ 10: 1 });
|
||||
});
|
||||
|
||||
it("holds an 8v9 winner back until the 7v10 game is decided", () => {
|
||||
// 8th and 9th both sit between 7th and 10th, so either slot is still possible.
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: null },
|
||||
{ matchNumber: 2, winnerSlot: 1 },
|
||||
])).toEqual({});
|
||||
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: null },
|
||||
{ matchNumber: 2, winnerSlot: 2 },
|
||||
])).toEqual({});
|
||||
});
|
||||
|
||||
it("places nothing while both games are undecided", () => {
|
||||
expect(resolveAflWildcardPlacements([
|
||||
{ matchNumber: 1, winnerSlot: null },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
])).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives the same answer whichever result is entered first", () => {
|
||||
for (const m1 of [7, 10] as const) {
|
||||
for (const m2 of [8, 9] as const) {
|
||||
const final = slotsBySeed(bothDecided(m1, m2));
|
||||
|
||||
// Whatever a single result places must survive the second result unchanged.
|
||||
const m1First = slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: m1 === 7 ? 1 : 2 },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
]);
|
||||
for (const [seed, slot] of Object.entries(m1First)) {
|
||||
expect(final[Number(seed)]).toBe(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a match number outside the Wildcard draw", () => {
|
||||
expect(() => resolveAflWildcardPlacements([{ matchNumber: 3, winnerSlot: 1 }])).toThrow(
|
||||
/Unknown AFL Wildcard Round match number 3/
|
||||
);
|
||||
});
|
||||
});
|
||||
99
app/lib/afl-wildcard-reseed.ts
Normal file
99
app/lib/afl-wildcard-reseed.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* AFL Wildcard Round → Elimination Finals re-seeding.
|
||||
*
|
||||
* The Wildcard Round is drawn 7 v 10 and 8 v 9, and its two winners fill the open slots
|
||||
* in the Elimination Finals opposite the 5th and 6th seeds. Those slots are NOT a fixed
|
||||
* crossover: the winners are re-seeded by ladder position, exactly as the classic final
|
||||
* eight pairs 5 v 8 and 6 v 7 — the higher seed of the two hosts meets the lower-ranked
|
||||
* winner. So 5th plays whichever winner finished further down the ladder and 6th plays
|
||||
* the other, whichever Wildcard game each came out of.
|
||||
*
|
||||
* Worked example: 10th beats 7th and 9th beats 8th. A fixed crossover would send the
|
||||
* 7v10 winner (10th) to 6th and the 8v9 winner (9th) to 5th — handing the higher host
|
||||
* the better opponent. Re-seeded, 5th plays 10th and 6th plays 9th.
|
||||
*/
|
||||
|
||||
/** Seeds drawn into each Wildcard Round match, in [participant1, participant2] order. */
|
||||
export const AFL_WILDCARD_DRAW: Readonly<Record<number, readonly [number, number]>> = {
|
||||
1: [7, 10],
|
||||
2: [8, 9],
|
||||
};
|
||||
|
||||
/** Seed hosting each Elimination Finals match (its participant1 slot). */
|
||||
export const AFL_ELIMINATION_HOSTS: Readonly<Record<number, number>> = {
|
||||
1: 5,
|
||||
2: 6,
|
||||
};
|
||||
|
||||
export interface AflWildcardResult {
|
||||
matchNumber: number;
|
||||
/** Slot the winner occupied, or null while the match is still to be played. */
|
||||
winnerSlot: 1 | 2 | null;
|
||||
}
|
||||
|
||||
export interface AflWildcardPlacement {
|
||||
wildcardMatchNumber: number;
|
||||
/** Seed of the Wildcard winner being placed. */
|
||||
seed: number;
|
||||
eliminationMatchNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which Elimination Final each decided Wildcard winner belongs in.
|
||||
*
|
||||
* A winner is only placed once its destination is settled whichever way the other
|
||||
* Wildcard game falls, so results can be entered in either order:
|
||||
* - 7th winning match 1 outranks both possible match 2 winners → always meets 6th.
|
||||
* - 10th winning match 1 is outranked by both → always meets 5th.
|
||||
* - A match 2 winner (8th or 9th) sits between them, so it is held back until match 1
|
||||
* is decided rather than being placed and then moved.
|
||||
*
|
||||
* Undecided winners are simply omitted; the caller fills the slots it is handed and
|
||||
* leaves the rest TBD.
|
||||
*/
|
||||
export function resolveAflWildcardPlacements(
|
||||
results: readonly AflWildcardResult[]
|
||||
): AflWildcardPlacement[] {
|
||||
const entries = results.map((result) => {
|
||||
const draw = AFL_WILDCARD_DRAW[result.matchNumber];
|
||||
if (!draw) {
|
||||
throw new Error(`Unknown AFL Wildcard Round match number ${result.matchNumber}`);
|
||||
}
|
||||
return {
|
||||
matchNumber: result.matchNumber,
|
||||
seed: result.winnerSlot === null ? null : draw[result.winnerSlot - 1],
|
||||
// Every seed the match could still send through — one entry once it is decided.
|
||||
possibleSeeds: result.winnerSlot === null ? [...draw] : [draw[result.winnerSlot - 1]],
|
||||
};
|
||||
});
|
||||
|
||||
// Best-ranked winner takes the weakest host, so order the hosts worst seed first.
|
||||
const hostsWorstFirst = Object.keys(AFL_ELIMINATION_HOSTS)
|
||||
.map(Number)
|
||||
.toSorted((a, b) => AFL_ELIMINATION_HOSTS[b] - AFL_ELIMINATION_HOSTS[a]);
|
||||
|
||||
const placements: AflWildcardPlacement[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const seed = entry.seed;
|
||||
if (seed === null) continue;
|
||||
|
||||
const others = entries.filter((other) => other !== entry);
|
||||
const outranks = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s < seed);
|
||||
const outrankedBy = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s > seed);
|
||||
|
||||
// This winner's rank is only knowable while every other one sits wholly above or
|
||||
// wholly below it — an undecided game straddling this seed leaves it unplaceable.
|
||||
if (!others.every((other) => outranks(other) || outrankedBy(other))) continue;
|
||||
|
||||
const rank = others.filter(outranks).length;
|
||||
const eliminationMatchNumber = hostsWorstFirst[rank];
|
||||
if (eliminationMatchNumber === undefined) {
|
||||
throw new Error(`No Elimination Finals slot for AFL Wildcard winner ranked ${rank + 1}`);
|
||||
}
|
||||
|
||||
placements.push({ wildcardMatchNumber: entry.matchNumber, seed, eliminationMatchNumber });
|
||||
}
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
|
@ -703,7 +703,8 @@ export const NFL_14: BracketTemplate = {
|
|||
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
|
||||
* - Week 1 Finals:
|
||||
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
|
||||
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th)
|
||||
* - Elimination Finals: the two Wildcard winners are re-seeded by ladder position, so
|
||||
* 5th hosts the lower-ranked winner and 6th the higher-ranked one (losers share 7th-8th)
|
||||
* - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th)
|
||||
* - Week 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
|
||||
* - Week 4: Grand Final (1st vs 2nd)
|
||||
|
|
|
|||
173
app/models/__tests__/afl-wildcard-advancement.test.ts
Normal file
173
app/models/__tests__/afl-wildcard-advancement.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* Advancing an AFL Wildcard Round winner into the Elimination Finals.
|
||||
*
|
||||
* The two winners are re-seeded by ladder position — 5th hosts the lower-ranked winner,
|
||||
* 6th the higher-ranked one — so the destination is not a fixed crossover from a given
|
||||
* Wildcard match, and results can be recorded in either order.
|
||||
*/
|
||||
|
||||
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>) => ({
|
||||
where: (where: unknown) => ({
|
||||
returning: () => {
|
||||
const values = whereValues(where);
|
||||
const row = rows.find((r) => values.includes(r.id));
|
||||
if (row) Object.assign(row, data);
|
||||
return Promise.resolve([row]);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: () => db }));
|
||||
|
||||
const { advanceWinnerTemplate } = await import("../playoff-match");
|
||||
|
||||
const EVENT = "event-1";
|
||||
|
||||
/** Ladder seed n → participant id. */
|
||||
const seed = (n: number) => `seed-${n}`;
|
||||
|
||||
/** A freshly generated afl_10 Wildcard Round (7v10, 8v9) and Elimination Finals (5, 6). */
|
||||
function bracket(): MatchRow[] {
|
||||
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
|
||||
return [
|
||||
{ ...base, id: "wc1", round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
|
||||
{ ...base, id: "wc2", round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
|
||||
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
|
||||
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), 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 Wildcard result the way setMatchWinner does, then advance it. */
|
||||
async function winWildcard(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);
|
||||
}
|
||||
|
||||
describe("AFL Wildcard Round advancement", () => {
|
||||
beforeEach(() => {
|
||||
rows = bracket();
|
||||
});
|
||||
|
||||
it("sends 5th the lower-ranked winner and 6th the higher-ranked one", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(8));
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
});
|
||||
|
||||
it("re-seeds when the lower seed wins through", async () => {
|
||||
// The reported bug: 10th beating 7th used to be crossed straight to 6th, leaving
|
||||
// 5th with the better survivor.
|
||||
await winWildcard("wc1", seed(10));
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("re-seeds a 9th-placed winner above a 10th-placed one", async () => {
|
||||
await winWildcard("wc1", seed(10));
|
||||
await winWildcard("wc2", seed(9));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(9));
|
||||
});
|
||||
|
||||
it("places the same pairings whichever result is entered first", async () => {
|
||||
await winWildcard("wc2", seed(8));
|
||||
await winWildcard("wc1", seed(10));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("places the 7v10 winner immediately, since its slot is settled either way", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
expect(row("ef1").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("holds an 8v9 winner back until the 7v10 game is decided", async () => {
|
||||
// 8th and 9th sit between 7th and 10th, so placing one now could need undoing.
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBeNull();
|
||||
expect(row("ef2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("does not disturb a winner it already placed", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(9));
|
||||
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
expect(row("ef1").participant2Id).toBe(seed(9));
|
||||
});
|
||||
|
||||
it("refuses to overwrite a slot already holding someone else", async () => {
|
||||
row("ef1").participant2Id = "stranger";
|
||||
|
||||
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already filled/);
|
||||
expect(row("ef1").participant2Id).toBe("stranger");
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,10 @@ import {
|
|||
resolveLLWSAdvancement,
|
||||
type LLWSResolvedDestination,
|
||||
} from "~/lib/llws-bracket";
|
||||
import {
|
||||
resolveAflWildcardPlacements,
|
||||
type AflWildcardResult,
|
||||
} from "~/lib/afl-wildcard-reseed";
|
||||
|
||||
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
||||
|
|
@ -742,7 +746,8 @@ async function generateNFL14Bracket(
|
|||
* Structure:
|
||||
* - Wildcard Round: 7v10, 8v9
|
||||
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
||||
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
|
||||
* - 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
|
||||
* - Grand Final: PF winners
|
||||
|
|
@ -796,14 +801,16 @@ async function generateAFL10Bracket(
|
|||
});
|
||||
}
|
||||
|
||||
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
|
||||
// Elimination Finals: 5th and 6th host the two Wildcard winners. Which winner lands
|
||||
// where is decided by ladder position once both games are played (see
|
||||
// resolveAflWildcardPlacements), not by a fixed crossover from a Wildcard match.
|
||||
const eliminationSeeding = [
|
||||
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
|
||||
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
|
||||
{ higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
|
||||
{ higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
|
||||
];
|
||||
|
||||
for (let i = 0; i < eliminationSeeding.length; i++) {
|
||||
const { higher, wildcard } = eliminationSeeding[i];
|
||||
const { higher, opponent } = eliminationSeeding[i];
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Elimination Finals",
|
||||
|
|
@ -813,7 +820,7 @@ async function generateAFL10Bracket(
|
|||
isComplete: false,
|
||||
isScoring: true, // Losers share 7th-8th
|
||||
templateRound: "Elimination Finals",
|
||||
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null,
|
||||
seedInfo: participantIds ? `${higher + 1} vs ${opponent}` : null,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -868,7 +875,7 @@ async function generateAFL10Bracket(
|
|||
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
||||
*
|
||||
* Advancement rules:
|
||||
* - Wildcard Round: Winner → Elimination Finals
|
||||
* - 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
|
||||
|
|
@ -881,18 +888,52 @@ async function advanceAFLWinner(
|
|||
): Promise<void> {
|
||||
const eventId = match.scoringEventId;
|
||||
|
||||
// Wildcard Round: Winner advances to Elimination Finals
|
||||
// Wildcard Round: Winners are re-seeded into the Elimination Finals — 5th meets the
|
||||
// lower-ranked winner and 6th the higher-ranked one, not a fixed 7v10-to-6th crossover.
|
||||
// Both games are re-resolved on every result, so a winner whose slot only becomes
|
||||
// certain once the other game is decided is placed then.
|
||||
if (match.round === "Wildcard Round") {
|
||||
// Wildcard Match 1 winner → EF Match 2, participant2Id
|
||||
// Wildcard Match 2 winner → EF Match 1, participant2Id
|
||||
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||||
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
|
||||
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
|
||||
const [wcMatches, efMatches] = await Promise.all([
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"),
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
|
||||
]);
|
||||
|
||||
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
|
||||
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
|
||||
// The row for the match being advanced may predate this result, so use the winner
|
||||
// passed in rather than whatever the read returned.
|
||||
const winnerById = new Map<number, string>();
|
||||
for (const wc of wcMatches) {
|
||||
const decidedWinner = wc.id === match.id ? winnerId : wc.isComplete ? wc.winnerId : null;
|
||||
if (decidedWinner) winnerById.set(wc.matchNumber, decidedWinner);
|
||||
}
|
||||
|
||||
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
|
||||
const results: AflWildcardResult[] = wcMatches.map((wc) => {
|
||||
const decidedWinner = winnerById.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`
|
||||
);
|
||||
});
|
||||
|
||||
for (const placement of resolveAflWildcardPlacements(results)) {
|
||||
const placedWinner = winnerById.get(placement.wildcardMatchNumber);
|
||||
if (!placedWinner) continue;
|
||||
|
||||
const efMatch = efMatches.find((m) => m.matchNumber === placement.eliminationMatchNumber);
|
||||
if (!efMatch) {
|
||||
throw new Error(`Elimination Finals match ${placement.eliminationMatchNumber} not found`);
|
||||
}
|
||||
// Already placed by an earlier Wildcard result — re-resolving is idempotent.
|
||||
if (efMatch.participant2Id === placedWinner) continue;
|
||||
if (efMatch.participant2Id) {
|
||||
throw new Error(
|
||||
`EF ${placement.eliminationMatchNumber} participant2 already filled`
|
||||
);
|
||||
}
|
||||
|
||||
await updatePlayoffMatch(efMatch.id, { participant2Id: placedWinner });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
eloWinProbability,
|
||||
AFLSimulator,
|
||||
readAflBracketSeeds,
|
||||
simAFLFinals,
|
||||
type BracketMatch,
|
||||
} from "../afl-simulator";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
|
|
@ -623,3 +624,62 @@ describe("readAflBracketSeeds", () => {
|
|||
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/not in this sports season/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── simAFLFinals ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("simAFLFinals Elimination Finals re-seeding", () => {
|
||||
const finalists = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `s${i + 1}`,
|
||||
name: `s${i + 1}`,
|
||||
elo: 1500,
|
||||
currentWins: 0,
|
||||
remainingGames: 0,
|
||||
winProb: 0.5,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Play the finals with the Wildcard Round forced to the given winners (every other
|
||||
* game goes to whoever was routed in first), and report who met whom.
|
||||
*/
|
||||
function pairingsWith(wc1Winner: string, wc2Winner: string): Map<string, [string, string]> {
|
||||
const pairings = new Map<string, [string, string]>();
|
||||
const play = (
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
t1: { id: string },
|
||||
t2: { id: string }
|
||||
) => {
|
||||
pairings.set(`${round}#${matchNumber}`, [t1.id, t2.id]);
|
||||
if (round === "Wildcard Round") {
|
||||
const forced = matchNumber === 1 ? wc1Winner : wc2Winner;
|
||||
return t1.id === forced ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
|
||||
}
|
||||
return { winner: t1, loser: t2 };
|
||||
};
|
||||
|
||||
simAFLFinals(finalists as never, play as never);
|
||||
return pairings;
|
||||
}
|
||||
|
||||
it("draws the Wildcard Round 7v10 and 8v9", () => {
|
||||
const pairings = pairingsWith("s7", "s8");
|
||||
expect(pairings.get("Wildcard Round#1")).toEqual(["s7", "s10"]);
|
||||
expect(pairings.get("Wildcard Round#2")).toEqual(["s8", "s9"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ wc1: "s7", wc2: "s8", ef1: "s8", ef2: "s7" },
|
||||
{ wc1: "s7", wc2: "s9", ef1: "s9", ef2: "s7" },
|
||||
// 10th beating 7th is where a fixed crossover misfires: it would send 10th to 6th
|
||||
// and leave 5th with the stronger survivor.
|
||||
{ wc1: "s10", wc2: "s8", ef1: "s10", ef2: "s8" },
|
||||
{ wc1: "s10", wc2: "s9", ef1: "s10", ef2: "s9" },
|
||||
])(
|
||||
"pairs 5th with $ef1 and 6th with $ef2 when $wc1 and $wc2 win through",
|
||||
({ wc1, wc2, ef1, ef2 }) => {
|
||||
const pairings = pairingsWith(wc1, wc2);
|
||||
expect(pairings.get("Elimination Finals#1")).toEqual(["s5", ef1]);
|
||||
expect(pairings.get("Elimination Finals#2")).toEqual(["s6", ef2]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@
|
|||
* Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts)
|
||||
* Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye)
|
||||
* losers → Semi-Finals (2nd chance)
|
||||
* Elimination Finals: #5 vs WC2w, #6 vs WC1w → losers exit (7th/8th)
|
||||
* 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)
|
||||
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
||||
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
||||
|
|
@ -409,9 +410,14 @@ export function simAFLFinals(
|
|||
const qf1 = play("Qualifying Finals", 1, s1, s4);
|
||||
const qf2 = play("Qualifying Finals", 2, s2, s3);
|
||||
|
||||
// Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner
|
||||
const ef1 = play("Elimination Finals", 1, s5, wc2.winner);
|
||||
const ef2 = play("Elimination Finals", 2, s6, wc1.winner);
|
||||
// Elimination Finals: the Wildcard winners are re-seeded by ladder position, so #5
|
||||
// hosts whichever finished lower and #6 the other — not a fixed crossover.
|
||||
const wc1Seed = wc1.winner === s7 ? 7 : 10;
|
||||
const wc2Seed = wc2.winner === s8 ? 8 : 9;
|
||||
const [betterWc, worseWc] =
|
||||
wc1Seed < wc2Seed ? [wc1.winner, wc2.winner] : [wc2.winner, wc1.winner];
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue