From 143698cb96d6d78b50fd9fab83874eddd4eb975c Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:37:55 -0700 Subject: [PATCH] Fix NCAAM/NCAAW simulators: FF completion handling and validation hardening (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the "slot is already filled" simulation error when First Four games have already been played and their winners advanced to the Round of 64. Changes: - Fix FF→R64 validation to allow a filled R64 slot when the corresponding First Four match is already complete (the original bug) - Fix isComplete guard for E8/FF/Championship: derive loser from stored participant IDs when loserId is null rather than re-simulating a real game - Add round count validation for all rounds (R32 16, S16 8, E8 4, FF 2, Champ 1) to catch malformed brackets before the hot sim loop instead of producing silent garbage output - Add participant1Id null check in the First Four path (mirrors the no-FF path) - Add console.warn when participant IDs are missing from DB (fall-back to average strength instead of crashing) - Import NCAA_68.regions from bracket-templates.ts instead of duplicating the hardcoded fallback in each simulator - Fix duplicate step-9 comment number in ncaam-simulator.ts Co-authored-by: Claude Sonnet 4.6 --- app/services/simulations/ncaam-simulator.ts | 83 ++++++++++++++++----- app/services/simulations/ncaaw-simulator.ts | 80 +++++++++++++++----- 2 files changed, 127 insertions(+), 36 deletions(-) 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])); }