diff --git a/app/services/simulations/ncaam-simulator.ts b/app/services/simulations/ncaam-simulator.ts index ebb57fa..841ee5e 100644 --- a/app/services/simulations/ncaam-simulator.ts +++ b/app/services/simulations/ncaam-simulator.ts @@ -45,7 +45,7 @@ import { database } from "~/database/context"; import { eq, and, inArray } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { BracketRegion } from "~/lib/bracket-templates"; -import { buildNCAA68SlotMap, matchIndexForSeedSlot } from "~/lib/bracket-templates"; +import { buildNCAA68SlotMap, matchIndexForSeedSlot, NCAA_68 } from "~/lib/bracket-templates"; import type { Simulator, SimulationResult } from "./types"; // ─── Simulation parameters ──────────────────────────────────────────────────── @@ -367,6 +367,21 @@ export class NCAAMSimulator implements Simulator { `This simulator only supports the standard 64-team NCAAM format.` ); } + if (r32Matches.length !== 16) { + throw new Error(`Expected 16 Round of 32 matches, found ${r32Matches.length}.`); + } + if (s16Matches.length !== 8) { + throw new Error(`Expected 8 Sweet Sixteen matches, found ${s16Matches.length}.`); + } + if (e8Matches.length !== 4) { + throw new Error(`Expected 4 Elite Eight matches, found ${e8Matches.length}.`); + } + if (ffMatches.length !== 2) { + throw new Error(`Expected 2 Final Four matches, found ${ffMatches.length}.`); + } + if (champMatches.length !== 1) { + throw new Error(`Expected 1 Championship match, found ${champMatches.length}.`); + } // 4. Build the First Four → Round of 64 slot mapping (if First Four games exist). // @@ -387,13 +402,8 @@ export class NCAAMSimulator implements Simulator { if (firstFourMatches.length > 0) { const regions = (bracketEvent.bracketRegionConfig as BracketRegion[] | null) ?? - // Default ncaa_68 regions if no override stored - [ - { name: "East", directSeeds: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16], playIns: [] }, - { name: "South", directSeeds: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15], playIns: [{ seedSlot: 16, teams: 2 }] }, - { name: "West", directSeeds: [1,2,3,4,5,6,7,8,9,10,12,13,14,15,16], playIns: [{ seedSlot: 11, teams: 2 }] }, - { name: "Midwest", directSeeds: [1,2,3,4,5,6,7,8,9,10,12,13,14,15], playIns: [{ seedSlot: 11, teams: 2 }, { seedSlot: 16, teams: 2 }] }, - ]; + // Fall back to the template's built-in regions — single source of truth + NCAA_68.regions!; const slotMap = buildNCAA68SlotMap(regions); for (let i = 0; i < slotMap.playInOffsets.length; i++) { @@ -412,7 +422,9 @@ export class NCAAMSimulator implements Simulator { } } - // Validate First Four → R64 mapping covers exactly the null R64 slots. + // Validate First Four → R64 mapping covers all null R64 slots. + // Note: FF-mapped slots may already be filled if the First Four game was completed + // and the winner was advanced — that's expected and handled in the sim loop. const r64SlotsCoveredByFF = new Set(ffToR64.values()); for (const nullSlot of r64NullSlots) { if (!r64SlotsCoveredByFF.has(nullSlot)) { @@ -422,11 +434,25 @@ export class NCAAMSimulator implements Simulator { ); } } - for (const ffR64Slot of r64SlotsCoveredByFF) { + for (const [ffMatchNum, ffR64Slot] of ffToR64) { if (!r64NullSlots.has(ffR64Slot)) { + // Slot is already filled — only valid if the FF game is complete + const ffMatch = firstFourMatches.find((m) => m.matchNumber === ffMatchNum); + if (!ffMatch?.isComplete) { + throw new Error( + `First Four maps to Round of 64 match ${ffR64Slot}, but that slot is ` + + `already filled. Check the bracket configuration.` + ); + } + } + } + // In the First Four path, participant1Id must always be set; participant2Id + // may be null only for FF-pending slots (already validated above). + for (const m of r64Matches) { + if (!m.participant1Id) { throw new Error( - `First Four maps to Round of 64 match ${ffR64Slot}, but that slot is ` + - `already filled. Check the bracket configuration.` + `Round of 64 match ${m.matchNumber} is missing participant1. ` + + `Check the bracket configuration.` ); } } @@ -462,6 +488,15 @@ export class NCAAMSimulator implements Simulator { .from(schema.participants) .where(inArray(schema.participants.id, participantIds)); + if (participantRows.length < participantIds.length) { + const foundIds = new Set(participantRows.map((r) => r.id)); + const missing = participantIds.filter((id) => !foundIds.has(id)); + console.warn( + `[NCAAMSimulator] ${missing.length} participant ID(s) not found in DB: ${missing.join(", ")}. ` + + `They will be treated as average strength (KenPom 0.0).` + ); + } + const netRatingById = new Map(); for (const { id, name } of participantRows) { netRatingById.set(id, getNetRating(name)); @@ -550,9 +585,13 @@ export class NCAAMSimulator implements Simulator { const dbMatch = e8ByNum.get(i); let winner: string; let loser: string; - if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) { + if (dbMatch?.isComplete && dbMatch.winnerId) { winner = dbMatch.winnerId; - loser = dbMatch.loserId; + // Derive loser from stored participants if loserId is missing + loser = dbMatch.loserId ?? + (dbMatch.participant1Id === dbMatch.winnerId + ? dbMatch.participant2Id! + : dbMatch.participant1Id!); } else { const p1 = s16Winners[(i - 1) * 2]; const p2 = s16Winners[(i - 1) * 2 + 1]; @@ -568,9 +607,12 @@ export class NCAAMSimulator implements Simulator { const dbMatch = ffByNum.get(i); let winner: string; let loser: string; - if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) { + if (dbMatch?.isComplete && dbMatch.winnerId) { winner = dbMatch.winnerId; - loser = dbMatch.loserId; + loser = dbMatch.loserId ?? + (dbMatch.participant1Id === dbMatch.winnerId + ? dbMatch.participant2Id! + : dbMatch.participant1Id!); } else { const p1 = e8Winners[(i - 1) * 2]; const p2 = e8Winners[(i - 1) * 2 + 1]; @@ -583,9 +625,12 @@ export class NCAAMSimulator implements Simulator { // ── Championship ────────────────────────────────────────────────────── let champion: string; let finalist: string; - if (champMatch?.isComplete && champMatch.winnerId && champMatch.loserId) { + if (champMatch?.isComplete && champMatch.winnerId) { champion = champMatch.winnerId; - finalist = champMatch.loserId; + finalist = champMatch.loserId ?? + (champMatch.participant1Id === champMatch.winnerId + ? champMatch.participant2Id! + : champMatch.participant1Id!); } else { ({ winner: champion, loser: finalist } = simMatch(ffWinners[0], ffWinners[1])); } @@ -593,7 +638,7 @@ export class NCAAMSimulator implements Simulator { finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); } - // 9. Convert integer counts to probability distributions. + // 10. Convert integer counts to probability distributions. // Exact denominators guarantee column sums of 1.0 by construction: // probFirst/Second → N total per sim → sum = 1 // probThird/Fourth → ffLoserCounts / (2*N) → 2 losers * 1/(2N) = 1 diff --git a/app/services/simulations/ncaaw-simulator.ts b/app/services/simulations/ncaaw-simulator.ts index 59fb3e9..3c29541 100644 --- a/app/services/simulations/ncaaw-simulator.ts +++ b/app/services/simulations/ncaaw-simulator.ts @@ -43,7 +43,7 @@ import { database } from "~/database/context"; import { eq, and, inArray } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { BracketRegion } from "~/lib/bracket-templates"; -import { buildNCAA68SlotMap, matchIndexForSeedSlot } from "~/lib/bracket-templates"; +import { buildNCAA68SlotMap, matchIndexForSeedSlot, NCAA_68 } from "~/lib/bracket-templates"; import type { Simulator, SimulationResult } from "./types"; // ─── Simulation parameters ──────────────────────────────────────────────────── @@ -344,6 +344,21 @@ export class NCAAWSimulator implements Simulator { `This simulator only supports the standard 64-team NCAAW format.` ); } + if (r32Matches.length !== 16) { + throw new Error(`Expected 16 Round of 32 matches, found ${r32Matches.length}.`); + } + if (s16Matches.length !== 8) { + throw new Error(`Expected 8 Sweet Sixteen matches, found ${s16Matches.length}.`); + } + if (e8Matches.length !== 4) { + throw new Error(`Expected 4 Elite Eight matches, found ${e8Matches.length}.`); + } + if (ffMatches.length !== 2) { + throw new Error(`Expected 2 Final Four matches, found ${ffMatches.length}.`); + } + if (champMatches.length !== 1) { + throw new Error(`Expected 1 Championship match, found ${champMatches.length}.`); + } // 4. Build First Four → Round of 64 slot mapping (if First Four games exist). const ffToR64 = new Map(); @@ -354,12 +369,8 @@ export class NCAAWSimulator implements Simulator { if (firstFourMatches.length > 0) { const regions = (bracketEvent.bracketRegionConfig as BracketRegion[] | null) ?? - [ - { name: "East", directSeeds: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16], playIns: [] }, - { name: "South", directSeeds: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15], playIns: [{ seedSlot: 16, teams: 2 }] }, - { name: "West", directSeeds: [1,2,3,4,5,6,7,8,9,10,12,13,14,15,16], playIns: [{ seedSlot: 11, teams: 2 }] }, - { name: "Midwest", directSeeds: [1,2,3,4,5,6,7,8,9,10,12,13,14,15], playIns: [{ seedSlot: 11, teams: 2 }, { seedSlot: 16, teams: 2 }] }, - ]; + // Fall back to the template's built-in regions — single source of truth + NCAA_68.regions!; const slotMap = buildNCAA68SlotMap(regions); for (let i = 0; i < slotMap.playInOffsets.length; i++) { @@ -377,7 +388,9 @@ export class NCAAWSimulator implements Simulator { } } - // Validate First Four → R64 mapping covers exactly the null R64 slots. + // Validate First Four → R64 mapping covers all null R64 slots. + // Note: FF-mapped slots may already be filled if the First Four game was completed + // and the winner was advanced — that's expected and handled in the sim loop. const r64SlotsCoveredByFF = new Set(ffToR64.values()); for (const nullSlot of r64NullSlots) { if (!r64SlotsCoveredByFF.has(nullSlot)) { @@ -387,11 +400,25 @@ export class NCAAWSimulator implements Simulator { ); } } - for (const ffR64Slot of r64SlotsCoveredByFF) { + for (const [ffMatchNum, ffR64Slot] of ffToR64) { if (!r64NullSlots.has(ffR64Slot)) { + // Slot is already filled — only valid if the FF game is complete + const ffMatch = firstFourMatches.find((m) => m.matchNumber === ffMatchNum); + if (!ffMatch?.isComplete) { + throw new Error( + `First Four maps to Round of 64 match ${ffR64Slot}, but that slot is ` + + `already filled. Check the bracket configuration.` + ); + } + } + } + // In the First Four path, participant1Id must always be set; participant2Id + // may be null only for FF-pending slots (already validated above). + for (const m of r64Matches) { + if (!m.participant1Id) { throw new Error( - `First Four maps to Round of 64 match ${ffR64Slot}, but that slot is ` + - `already filled. Check the bracket configuration.` + `Round of 64 match ${m.matchNumber} is missing participant1. ` + + `Check the bracket configuration.` ); } } @@ -424,6 +451,15 @@ export class NCAAWSimulator implements Simulator { .from(schema.participants) .where(inArray(schema.participants.id, participantIds)); + if (participantRows.length < participantIds.length) { + const foundIds = new Set(participantRows.map((r) => r.id)); + const missing = participantIds.filter((id) => !foundIds.has(id)); + console.warn( + `[NCAAWSimulator] ${missing.length} participant ID(s) not found in DB: ${missing.join(", ")}. ` + + `They will be treated as average strength (Barthag ${BARTHAG_FALLBACK}).` + ); + } + const barthagById = new Map(); for (const { id, name } of participantRows) { barthagById.set(id, getBarthagRating(name)); @@ -509,9 +545,13 @@ export class NCAAWSimulator implements Simulator { const dbMatch = e8ByNum.get(i); let winner: string; let loser: string; - if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) { + if (dbMatch?.isComplete && dbMatch.winnerId) { winner = dbMatch.winnerId; - loser = dbMatch.loserId; + // Derive loser from stored participants if loserId is missing + loser = dbMatch.loserId ?? + (dbMatch.participant1Id === dbMatch.winnerId + ? dbMatch.participant2Id! + : dbMatch.participant1Id!); } else { const p1 = s16Winners[(i - 1) * 2]; const p2 = s16Winners[(i - 1) * 2 + 1]; @@ -527,9 +567,12 @@ export class NCAAWSimulator implements Simulator { const dbMatch = ffByNum.get(i); let winner: string; let loser: string; - if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) { + if (dbMatch?.isComplete && dbMatch.winnerId) { winner = dbMatch.winnerId; - loser = dbMatch.loserId; + loser = dbMatch.loserId ?? + (dbMatch.participant1Id === dbMatch.winnerId + ? dbMatch.participant2Id! + : dbMatch.participant1Id!); } else { const p1 = e8Winners[(i - 1) * 2]; const p2 = e8Winners[(i - 1) * 2 + 1]; @@ -542,9 +585,12 @@ export class NCAAWSimulator implements Simulator { // ── Championship ────────────────────────────────────────────────────── let champion: string; let finalist: string; - if (champMatch?.isComplete && champMatch.winnerId && champMatch.loserId) { + if (champMatch?.isComplete && champMatch.winnerId) { champion = champMatch.winnerId; - finalist = champMatch.loserId; + finalist = champMatch.loserId ?? + (champMatch.participant1Id === champMatch.winnerId + ? champMatch.participant2Id! + : champMatch.participant1Id!); } else { ({ winner: champion, loser: finalist } = simMatch(ffWinners[0], ffWinners[1])); }