Compare commits
2 commits
f3e00fcacf
...
273735e572
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
273735e572 | ||
|
|
b566c5f1a5 |
7 changed files with 608 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)
|
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
|
||||||
* - Week 1 Finals:
|
* - Week 1 Finals:
|
||||||
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
|
* - 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 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 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
|
||||||
* - Week 4: Grand Final (1st vs 2nd)
|
* - Week 4: Grand Final (1st vs 2nd)
|
||||||
|
|
|
||||||
229
app/models/__tests__/afl-wildcard-advancement.test.ts
Normal file
229
app/models/__tests__/afl-wildcard-advancement.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
/**
|
||||||
|
* 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>) => {
|
||||||
|
const applyTo = (where: unknown) => {
|
||||||
|
const values = whereValues(where);
|
||||||
|
const row = rows.find((r) => values.includes(r.id));
|
||||||
|
if (row) Object.assign(row, data);
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
// Advancement writes through the query builder with and without .returning().
|
||||||
|
return {
|
||||||
|
where: (where: unknown) => {
|
||||||
|
const applied = Promise.resolve([applyTo(where)]);
|
||||||
|
return Object.assign(applied, { returning: () => applied });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
// No rollback: the tests assert the writes that were attempted, in order.
|
||||||
|
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("~/database/context", () => ({ database: () => db }));
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the winner when a recorded Wildcard result is corrected", async () => {
|
||||||
|
await winWildcard("wc1", seed(7));
|
||||||
|
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||||
|
|
||||||
|
// The result was wrong: 10th won. 7th must not be left alive in the other slot.
|
||||||
|
await winWildcard("wc1", seed(10));
|
||||||
|
|
||||||
|
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||||
|
expect(row("ef2").participant2Id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-seeds a pairing left behind by the old fixed crossover", async () => {
|
||||||
|
// Pre-fix state: the 7v10 winner was crossed to 6th whatever its ladder position.
|
||||||
|
row("wc1").winnerId = seed(10);
|
||||||
|
row("wc1").loserId = seed(7);
|
||||||
|
row("wc1").isComplete = true;
|
||||||
|
row("ef2").participant2Id = seed(10);
|
||||||
|
|
||||||
|
await winWildcard("wc2", seed(8));
|
||||||
|
|
||||||
|
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||||
|
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swaps both winners when re-resolving an already-placed pair", async () => {
|
||||||
|
row("wc1").winnerId = seed(10);
|
||||||
|
row("wc1").loserId = seed(7);
|
||||||
|
row("wc1").isComplete = true;
|
||||||
|
row("ef2").participant2Id = seed(10);
|
||||||
|
row("ef1").participant2Id = seed(8);
|
||||||
|
|
||||||
|
await winWildcard("wc2", seed(8));
|
||||||
|
|
||||||
|
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||||
|
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the bracket alone when the pairings are already right", async () => {
|
||||||
|
await winWildcard("wc1", seed(7));
|
||||||
|
await winWildcard("wc2", seed(8));
|
||||||
|
db.transaction.mockClear();
|
||||||
|
|
||||||
|
await winWildcard("wc2", seed(8));
|
||||||
|
|
||||||
|
expect(db.transaction).not.toHaveBeenCalled();
|
||||||
|
expect(row("ef1").participant2Id).toBe(seed(8));
|
||||||
|
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -15,6 +15,10 @@ import {
|
||||||
resolveLLWSAdvancement,
|
resolveLLWSAdvancement,
|
||||||
type LLWSResolvedDestination,
|
type LLWSResolvedDestination,
|
||||||
} from "~/lib/llws-bracket";
|
} from "~/lib/llws-bracket";
|
||||||
|
import {
|
||||||
|
resolveAflWildcardPlacements,
|
||||||
|
type AflWildcardResult,
|
||||||
|
} from "~/lib/afl-wildcard-reseed";
|
||||||
|
|
||||||
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||||
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
||||||
|
|
@ -742,7 +746,8 @@ async function generateNFL14Bracket(
|
||||||
* Structure:
|
* Structure:
|
||||||
* - Wildcard Round: 7v10, 8v9
|
* - Wildcard Round: 7v10, 8v9
|
||||||
* - 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: 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
|
* - Semi-Finals: QF losers vs EF winners
|
||||||
* - Preliminary Finals: QF winners vs SF winners
|
* - Preliminary Finals: QF winners vs SF winners
|
||||||
* - Grand Final: PF 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 = [
|
const eliminationSeeding = [
|
||||||
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
|
{ higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
|
||||||
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
|
{ higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = 0; i < eliminationSeeding.length; i++) {
|
for (let i = 0; i < eliminationSeeding.length; i++) {
|
||||||
const { higher, wildcard } = eliminationSeeding[i];
|
const { higher, opponent } = eliminationSeeding[i];
|
||||||
matches.push({
|
matches.push({
|
||||||
scoringEventId: eventId,
|
scoringEventId: eventId,
|
||||||
round: "Elimination Finals",
|
round: "Elimination Finals",
|
||||||
|
|
@ -813,7 +820,7 @@ async function generateAFL10Bracket(
|
||||||
isComplete: false,
|
isComplete: false,
|
||||||
isScoring: true, // Losers share 7th-8th
|
isScoring: true, // Losers share 7th-8th
|
||||||
templateRound: "Elimination Finals",
|
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
|
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
||||||
*
|
*
|
||||||
* Advancement rules:
|
* 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
|
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
||||||
* - Elimination Finals: Winner → Semi-Finals
|
* - Elimination Finals: Winner → Semi-Finals
|
||||||
* - Semi-Finals: Winner → Preliminary Finals
|
* - Semi-Finals: Winner → Preliminary Finals
|
||||||
|
|
@ -881,18 +888,86 @@ async function advanceAFLWinner(
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const eventId = match.scoringEventId;
|
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.
|
||||||
|
// Because the destination depends on both games, every result re-resolves both slots:
|
||||||
|
// that places a winner whose slot only became certain once the other game was decided,
|
||||||
|
// and it moves one that an earlier (or corrected) result had put in the other slot.
|
||||||
if (match.round === "Wildcard Round") {
|
if (match.round === "Wildcard Round") {
|
||||||
// Wildcard Match 1 winner → EF Match 2, participant2Id
|
const [wcMatches, efMatches] = await Promise.all([
|
||||||
// Wildcard Match 2 winner → EF Match 1, participant2Id
|
findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"),
|
||||||
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
|
||||||
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
|
]);
|
||||||
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
|
|
||||||
|
|
||||||
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
|
// The row for the match being advanced may predate this result, so use the winner
|
||||||
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
|
// passed in rather than whatever the read returned.
|
||||||
|
const winnerByMatchNumber = new Map<number, string>();
|
||||||
|
for (const wc of wcMatches) {
|
||||||
|
const decidedWinner = wc.id === match.id ? winnerId : wc.isComplete ? wc.winnerId : null;
|
||||||
|
if (decidedWinner) winnerByMatchNumber.set(wc.matchNumber, decidedWinner);
|
||||||
|
}
|
||||||
|
|
||||||
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
|
const results: AflWildcardResult[] = wcMatches.map((wc) => {
|
||||||
|
const decidedWinner = winnerByMatchNumber.get(wc.matchNumber) ?? null;
|
||||||
|
if (decidedWinner === null) return { matchNumber: wc.matchNumber, winnerSlot: null };
|
||||||
|
if (decidedWinner === wc.participant1Id) return { matchNumber: wc.matchNumber, winnerSlot: 1 };
|
||||||
|
if (decidedWinner === wc.participant2Id) return { matchNumber: wc.matchNumber, winnerSlot: 2 };
|
||||||
|
throw new Error(
|
||||||
|
`Wildcard Round match ${wc.matchNumber} winner is not one of its participants`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const wanted = new Map<number, string>();
|
||||||
|
for (const placement of resolveAflWildcardPlacements(results)) {
|
||||||
|
const placedWinner = winnerByMatchNumber.get(placement.wildcardMatchNumber);
|
||||||
|
if (placedWinner) wanted.set(placement.eliminationMatchNumber, placedWinner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only these teams can legitimately be moved between the two Elimination Finals;
|
||||||
|
// anyone else in a slot came from somewhere this function knows nothing about.
|
||||||
|
const wildcardParticipants = new Set<string>();
|
||||||
|
for (const wc of wcMatches) {
|
||||||
|
if (wc.participant1Id) wildcardParticipants.add(wc.participant1Id);
|
||||||
|
if (wc.participant2Id) wildcardParticipants.add(wc.participant2Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotsToClear: string[] = [];
|
||||||
|
const slotsToFill: Array<{ id: string; participantId: string }> = [];
|
||||||
|
|
||||||
|
for (const efMatch of efMatches) {
|
||||||
|
const occupant = efMatch.participant2Id;
|
||||||
|
const belongsHere = wanted.get(efMatch.matchNumber) ?? null;
|
||||||
|
if (occupant === belongsHere) continue;
|
||||||
|
|
||||||
|
if (occupant !== null && !wildcardParticipants.has(occupant)) {
|
||||||
|
throw new Error(`EF ${efMatch.matchNumber} participant2 already filled`);
|
||||||
|
}
|
||||||
|
// A Wildcard team in the wrong slot is a placement this result supersedes: a
|
||||||
|
// corrected Wildcard winner, or one placed before the re-seeding rule existed.
|
||||||
|
if (occupant !== null) slotsToClear.push(efMatch.id);
|
||||||
|
if (belongsHere !== null) slotsToFill.push({ id: efMatch.id, participantId: belongsHere });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slotsToClear.length === 0 && slotsToFill.length === 0) return;
|
||||||
|
|
||||||
|
// One transaction, vacating before filling: a half-applied re-seed would leave the
|
||||||
|
// same team in both Elimination Finals.
|
||||||
|
const db = database();
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const now = new Date();
|
||||||
|
for (const id of slotsToClear) {
|
||||||
|
await tx
|
||||||
|
.update(schema.playoffMatches)
|
||||||
|
.set({ participant2Id: null, updatedAt: now })
|
||||||
|
.where(eq(schema.playoffMatches.id, id));
|
||||||
|
}
|
||||||
|
for (const { id, participantId } of slotsToFill) {
|
||||||
|
await tx
|
||||||
|
.update(schema.playoffMatches)
|
||||||
|
.set({ participant2Id: participantId, updatedAt: now })
|
||||||
|
.where(eq(schema.playoffMatches.id, id));
|
||||||
|
}
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
eloWinProbability,
|
eloWinProbability,
|
||||||
AFLSimulator,
|
AFLSimulator,
|
||||||
readAflBracketSeeds,
|
readAflBracketSeeds,
|
||||||
|
simAFLFinals,
|
||||||
type BracketMatch,
|
type BracketMatch,
|
||||||
} from "../afl-simulator";
|
} from "../afl-simulator";
|
||||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
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/);
|
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)
|
* Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts)
|
||||||
* Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye)
|
* Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye)
|
||||||
* losers → Semi-Finals (2nd chance)
|
* 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)
|
* Semi-Finals: QF1L vs EF2w, QF2L vs EF1w → 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
|
||||||
|
|
@ -409,9 +410,14 @@ export function simAFLFinals(
|
||||||
const qf1 = play("Qualifying Finals", 1, s1, s4);
|
const qf1 = play("Qualifying Finals", 1, s1, s4);
|
||||||
const qf2 = play("Qualifying Finals", 2, s2, s3);
|
const qf2 = play("Qualifying Finals", 2, s2, s3);
|
||||||
|
|
||||||
// Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner
|
// Elimination Finals: the Wildcard winners are re-seeded by ladder position, so #5
|
||||||
const ef1 = play("Elimination Finals", 1, s5, wc2.winner);
|
// hosts whichever finished lower and #6 the other — not a fixed crossover.
|
||||||
const ef2 = play("Elimination Finals", 2, s6, wc1.winner);
|
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
|
// Semi-Finals: QF losers (second chance) vs EF winners
|
||||||
const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner);
|
const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue