claude/session-crx3tm #148

Merged
chrisp merged 2 commits from claude/session-crx3tm into main 2026-09-04 22:06:01 +00:00
3 changed files with 294 additions and 89 deletions
Showing only changes of commit 4e48a23f6b - Show all commits

View file

@ -76,7 +76,7 @@ const db = {
vi.mock("~/database/context", () => ({ database: () => db }));
const { advanceWinnerTemplate } = await import("../playoff-match");
const { advanceWinnerTemplate, reseedAflEliminationFinals } = await import("../playoff-match");
const EVENT = "event-1";
@ -223,6 +223,40 @@ describe("AFL Wildcard Round advancement", () => {
expect(row("ef2").participant2Id).toBe(seed(7));
});
it("repairs an already-advanced bracket from the recorded results alone", async () => {
// What scripts/fix-afl-wildcard-reseed.ts does: no new result, just the rows a
// bracket advanced under the old fixed crossover left behind.
Object.assign(row("wc1"), { isComplete: true, winnerId: seed(10), loserId: seed(7) });
Object.assign(row("wc2"), { isComplete: true, winnerId: seed(8), loserId: seed(9) });
row("ef2").participant2Id = seed(10);
row("ef1").participant2Id = seed(8);
const reseed = await reseedAflEliminationFinals(EVENT);
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
{ matchNumber: 1, participantId: seed(10) },
{ matchNumber: 2, participantId: seed(8) },
]);
});
it("reports no change when a repair run finds the pairings correct", async () => {
await winWildcard("wc1", seed(7));
await winWildcard("wc2", seed(8));
db.transaction.mockClear();
const reseed = await reseedAflEliminationFinals(EVENT);
expect(reseed).toEqual({ vacated: [], filled: [] });
expect(db.transaction).not.toHaveBeenCalled();
});
it("rejects an event with no AFL bracket rather than reporting nothing to do", async () => {
await expect(reseedAflEliminationFinals("no-such-event")).rejects.toThrow(/no AFL Wildcard/);
});
it("leaves the bracket alone when the pairings are already right", async () => {
await winWildcard("wc1", seed(7));
await winWildcard("wc2", seed(8));

View file

@ -870,40 +870,47 @@ async function generateAFL10Bracket(
return await createManyPlayoffMatches(matches);
}
/**
* AFL-specific advancement logic for the complex double-chance system
* Phase 3.3: Handles both winners and losers advancing to different rounds
*
* Advancement rules:
* - Wildcard Round: Winner Elimination Finals (re-seeded by ladder position)
* - Qualifying Finals: Winner Preliminary Finals, Loser Semi-Finals
* - Elimination Finals: Winner Semi-Finals
* - Semi-Finals: Winner Preliminary Finals
* - Preliminary Finals: Winner Grand Final
*/
async function advanceAFLWinner(
match: PlayoffMatch,
winnerId: string,
loserId: string
): Promise<void> {
const eventId = match.scoringEventId;
/** What a re-seed changed, by Elimination Finals match number. */
export interface AflEliminationReseed {
vacated: number[];
filled: Array<{ matchNumber: number; participantId: string }>;
}
// 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") {
/**
* Put the decided Wildcard winners in the Elimination Finals they belong in.
*
* The two winners are re-seeded by ladder position 5th meets the lower-ranked one and
* 6th the higher-ranked one rather than crossing over from a fixed Wildcard match. That
* destination depends on both games, so this reconciles both slots against the results
* recorded so far every time it runs: it places a winner whose slot only became certain
* once the other game was decided, and moves one that an earlier (or corrected) result,
* or a bracket advanced before this rule existed, put in the other slot.
*
* `pending` supplies a result that may not be in the database yet the row read back
* while advancing a match can predate the winner being written to it.
*
* Idempotent: pairings that are already right do no writes.
*/
export async function reseedAflEliminationFinals(
eventId: string,
pending?: { matchId: string; winnerId: string }
): Promise<AflEliminationReseed> {
const [wcMatches, efMatches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"),
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
]);
// The row for the match being advanced may predate this result, so use the winner
// passed in rather than whatever the read returned.
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
if (wcMatches.length === 0 || efMatches.length === 0) {
throw new Error(
`Event ${eventId} has no AFL Wildcard Round / Elimination Finals matches to re-seed`
);
}
const winnerByMatchNumber = new Map<number, string>();
for (const wc of wcMatches) {
const decidedWinner = wc.id === match.id ? winnerId : wc.isComplete ? wc.winnerId : null;
const decidedWinner =
pending && wc.id === pending.matchId ? pending.winnerId : wc.isComplete ? wc.winnerId : null;
if (decidedWinner) winnerByMatchNumber.set(wc.matchNumber, decidedWinner);
}
@ -931,8 +938,8 @@ async function advanceAFLWinner(
if (wc.participant2Id) wildcardParticipants.add(wc.participant2Id);
}
const slotsToClear: string[] = [];
const slotsToFill: Array<{ id: string; participantId: string }> = [];
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
for (const efMatch of efMatches) {
const occupant = efMatch.participant2Id;
@ -951,32 +958,63 @@ async function advanceAFLWinner(
`so its Wildcard qualifier cannot be re-seeded — clear and regenerate the bracket`
);
}
// A Wildcard team in the wrong slot is a placement this result supersedes: a
// corrected Wildcard winner, or one placed before the re-seeding rule existed.
if (occupant !== null) slotsToClear.push(efMatch.id);
if (belongsHere !== null) slotsToFill.push({ id: efMatch.id, participantId: belongsHere });
if (occupant !== null) slotsToClear.push({ id: efMatch.id, matchNumber: efMatch.matchNumber });
if (belongsHere !== null) {
slotsToFill.push({ id: efMatch.id, matchNumber: efMatch.matchNumber, participantId: belongsHere });
}
}
if (slotsToClear.length === 0 && slotsToFill.length === 0) return;
const reseed: AflEliminationReseed = {
vacated: slotsToClear.map((slot) => slot.matchNumber),
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
};
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
// One transaction, vacating before filling: a half-applied re-seed would leave the
// same team in both Elimination Finals.
const db = database();
await db.transaction(async (tx) => {
const now = new Date();
for (const id of slotsToClear) {
for (const slot of slotsToClear) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: null, updatedAt: now })
.where(eq(schema.playoffMatches.id, id));
.where(eq(schema.playoffMatches.id, slot.id));
}
for (const { id, participantId } of slotsToFill) {
for (const slot of slotsToFill) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: participantId, updatedAt: now })
.where(eq(schema.playoffMatches.id, id));
.set({ participant2Id: slot.participantId, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
});
return reseed;
}
/**
* AFL-specific advancement logic for the complex double-chance system
* Phase 3.3: Handles both winners and losers advancing to different rounds
*
* Advancement rules:
* - Wildcard Round: Winner Elimination Finals (re-seeded by ladder position)
* - Qualifying Finals: Winner Preliminary Finals, Loser Semi-Finals
* - Elimination Finals: Winner Semi-Finals
* - Semi-Finals: Winner Preliminary Finals
* - Preliminary Finals: Winner Grand Final
*/
async function advanceAFLWinner(
match: PlayoffMatch,
winnerId: string,
loserId: string
): Promise<void> {
const eventId = match.scoringEventId;
// Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
// position, so every result re-resolves both slots.
if (match.round === "Wildcard Round") {
await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId });
return;
}

View file

@ -0,0 +1,133 @@
/**
* Repair: re-seed AFL Wildcard winners into the Elimination Finals they belong in.
*
* Brackets advanced before the re-seeding fix crossed each Wildcard winner into a fixed
* Elimination Final the 7v10 winner always met 6th and the 8v9 winner always met 5th
* instead of pairing them by ladder position (5th hosts the lower-ranked winner). The
* fix only changes how new results advance, so an already-advanced bracket keeps its
* wrong pairings until this runs. The admin UI cannot re-trigger it: a completed match
* renders as "Complete", with no way to re-submit the winner.
*
* This runs the same reseedAflEliminationFinals the advancement path now uses, so it
* makes exactly the correction a fresh bracket would have. It only ever moves Wildcard
* teams between the two Elimination Final slots no results, scores or placements are
* touched, and nothing else in the bracket is written. A bracket that is already correct
* is left alone.
*
* If an Elimination Final has already been played, its qualifier cannot be moved without
* rewriting who contested a recorded result; the script reports that event and skips it.
* Clear and regenerate that bracket in Admin instead, then Reprocess Bracket.
*
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
*
* npx tsx scripts/fix-afl-wildcard-reseed.ts # apply to every afl_10 event
* npx tsx scripts/fix-afl-wildcard-reseed.ts --dry # report only
* npx tsx scripts/fix-afl-wildcard-reseed.ts --event <id> # one event
*/
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { eq } from "drizzle-orm";
import * as schema from "../database/schema.js";
import { DatabaseContext, database } from "../database/context.js";
import {
findPlayoffMatchesByEventIdAndRound,
reseedAflEliminationFinals,
} from "../app/models/playoff-match.js";
import { findParticipantsBySportsSeasonId } from "../app/models/season-participant.js";
const DRY = process.argv.includes("--dry");
const eventFlag = process.argv.indexOf("--event");
const ONLY_EVENT = eventFlag === -1 ? null : process.argv[eventFlag + 1];
const log = (...a: unknown[]) => console.log(...a);
async function run() {
const db = database();
const events = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.bracketTemplateId, "afl_10"),
});
const targets = ONLY_EVENT ? events.filter((e) => e.id === ONLY_EVENT) : events;
if (ONLY_EVENT && targets.length === 0) {
log(`No afl_10 event with id ${ONLY_EVENT}.`);
return;
}
log(`afl_10 events to check: ${targets.length}`);
let fixed = 0;
let alreadyRight = 0;
let skipped = 0;
for (const event of targets) {
const name = event.name ?? event.id;
const participants = await findParticipantsBySportsSeasonId(event.sportsSeasonId);
const nameOf = (id: string | null) =>
id === null ? "TBD" : participants.find((p) => p.id === id)?.name ?? id;
/** "M1: <host> vs <qualifier>" for both Elimination Finals. */
const pairings = async () => {
const efMatches = await findPlayoffMatchesByEventIdAndRound(event.id, "Elimination Finals");
return efMatches
.toSorted((a, b) => a.matchNumber - b.matchNumber)
.map((m) => `M${m.matchNumber}: ${nameOf(m.participant1Id)} vs ${nameOf(m.participant2Id)}`)
.join(", ");
};
try {
const before = await pairings();
// The dry run still resolves the pairings — it just reports them instead of writing.
if (DRY) {
const efMatches = await findPlayoffMatchesByEventIdAndRound(event.id, "Elimination Finals");
const wcMatches = await findPlayoffMatchesByEventIdAndRound(event.id, "Wildcard Round");
const decided = wcMatches.filter((m) => m.isComplete && m.winnerId).length;
log(` ${name}: ${before} (${decided}/${wcMatches.length} Wildcard results, ` +
`${efMatches.filter((m) => m.isComplete).length} Elimination Final(s) played)`);
continue;
}
const reseed = await reseedAflEliminationFinals(event.id);
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
alreadyRight += 1;
log(` = ${name}: already correct — ${before}`);
continue;
}
fixed += 1;
log(` ~ ${name}:`);
log(` was: ${before}`);
log(` now: ${await pairings()}`);
} catch (e) {
skipped += 1;
log(` ! ${name}: ${(e as Error).message}`);
}
}
if (DRY) {
log("\nDry run — no writes. Re-run without --dry to apply.");
return;
}
log(`\nDone. re-seeded=${fixed}, already correct=${alreadyRight}, skipped=${skipped}.`);
}
async function main() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("ERROR: DATABASE_URL is required");
process.exit(1);
}
const client = postgres(dbUrl, { max: 1 });
const db = drizzle(client, { schema });
try {
await DatabaseContext.run(db, run);
} finally {
await client.end();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});