fix: NBA play-in bracket advancement bugs (#292)

* fix: NBA play-in advancement writes E7/W7 to wrong participant slot

advanceNBAPlayInWinner was writing the PIR1 7v8 winner to participant1Id
of First Round M3/M7, but those slots already hold E2/W2 (seeded at
generation time). The "already filled" guard fired, silently swallowing
both the winner advancement and the loser's placement into Play-In
Round 2 — so the 7/8 loser never appeared in the second play-in game.

Fix: write the 7v8 winner to participant2Id (the correct null slot) and
update the guard, doc comment, and load-bearing comment accordingly.
Also swap the p2Override arguments in the simulator and test fixture to
match the corrected slot layout (FR M3: E2=p1, E7=p2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: hasGroupStage check for templates without groupStage property

undefined !== null is true, so templates that omit groupStage (like
nba_20) were incorrectly treated as group-stage templates, triggering
the generate-groups action and the "Invalid template or template has
no group stage" error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-13 01:25:32 -04:00 committed by GitHub
parent 040c97137b
commit 66b0235678
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 16 additions and 14 deletions

View file

@ -1255,7 +1255,7 @@ async function generateNBA20Bracket(
// ── First Round (8 series) ────────────────────────────────────────────────────
// IMPORTANT: This match ordering is load-bearing.
// NBASimulator.simulateBracketAware() hard-codes match numbers to derive play-in
// seed slots: FR M1 p2 = E8, FR M3 p1 = E7, FR M5 p2 = W8, FR M7 p1 = W7.
// seed slots: FR M1 p2 = E8, FR M3 p2 = E7, FR M5 p2 = W8, FR M7 p2 = W7.
// Do not change the match order without updating the simulator's resolveGame/
// resolveSeries calls and the corresponding test fixtures.
const firstRoundSeeding: [number | null, number | null, string][] = [
@ -1308,9 +1308,9 @@ async function generateNBA20Bracket(
* NBA play-in advancement logic.
*
* Play-In Round 1:
* M1 (E7v8): winner First Round M3 p1; loser PIR2 M1 p1
* M1 (E7v8): winner First Round M3 p2 (E7 seed); loser PIR2 M1 p1
* M2 (E9v10): winner PIR2 M1 p2; loser eliminated
* M3 (W7v8): winner First Round M7 p1; loser PIR2 M2 p1
* M3 (W7v8): winner First Round M7 p2 (W7 seed); loser PIR2 M2 p1
* M4 (W9v10): winner PIR2 M2 p2; loser eliminated
*
* Play-In Round 2:
@ -1326,7 +1326,8 @@ async function advanceNBAPlayInWinner(
if (match.round === "Play-In Round 1") {
if (match.matchNumber === 1) {
// E7v8: winner → First Round M3 p1; loser → PIR2 M1 p1
// E7v8: winner → First Round M3 p2 (E7 seed); loser → PIR2 M1 p1
// FR M3 is "E2 vs E7": E2 is already at p1, E7 fills p2.
const [frMatches, pir2Matches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "First Round"),
findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2"),
@ -1335,10 +1336,10 @@ async function advanceNBAPlayInWinner(
const pir2M1 = pir2Matches.find((m) => m.matchNumber === 1);
if (!frM3) throw new Error("First Round match 3 not found");
if (!pir2M1) throw new Error("Play-In Round 2 match 1 not found");
if (frM3.participant1Id) throw new Error("First Round M3 participant1 already filled");
if (frM3.participant2Id) throw new Error("First Round M3 participant2 already filled");
if (pir2M1.participant1Id) throw new Error("Play-In Round 2 M1 participant1 already filled");
await Promise.all([
updatePlayoffMatch(frM3.id, { participant1Id: winnerId }),
updatePlayoffMatch(frM3.id, { participant2Id: winnerId }),
updatePlayoffMatch(pir2M1.id, { participant1Id: loserId }),
]);
return;
@ -1355,7 +1356,8 @@ async function advanceNBAPlayInWinner(
}
if (match.matchNumber === 3) {
// W7v8: winner → First Round M7 p1; loser → PIR2 M2 p1
// W7v8: winner → First Round M7 p2 (W7 seed); loser → PIR2 M2 p1
// FR M7 is "W2 vs W7": W2 is already at p1, W7 fills p2.
const [frMatches, pir2Matches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "First Round"),
findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2"),
@ -1364,10 +1366,10 @@ async function advanceNBAPlayInWinner(
const pir2M2 = pir2Matches.find((m) => m.matchNumber === 2);
if (!frM7) throw new Error("First Round match 7 not found");
if (!pir2M2) throw new Error("Play-In Round 2 match 2 not found");
if (frM7.participant1Id) throw new Error("First Round M7 participant1 already filled");
if (frM7.participant2Id) throw new Error("First Round M7 participant2 already filled");
if (pir2M2.participant1Id) throw new Error("Play-In Round 2 M2 participant1 already filled");
await Promise.all([
updatePlayoffMatch(frM7.id, { participant1Id: winnerId }),
updatePlayoffMatch(frM7.id, { participant2Id: winnerId }),
updatePlayoffMatch(pir2M2.id, { participant1Id: loserId }),
]);
return;

View file

@ -67,7 +67,7 @@ export default function EventBracket({
const template = templates.find((t) => t.id === selectedTemplate);
// Determine if this is a group-stage event
const hasGroupStage = template?.groupStage !== null;
const hasGroupStage = template?.groupStage !== null && template?.groupStage !== undefined;
// Determine if this template uses named regions (e.g. NCAA 68)
const hasRegions = (template?.regions?.length ?? 0) > 0;

View file

@ -232,11 +232,11 @@ function buildFullBracket(
// First Round (play-in slots filled)
makeMatch("First Round", 1, { p1: t[0], p2: e8Seed, isScoring: false }),
makeMatch("First Round", 2, { p1: t[3], p2: t[4], isScoring: false }),
makeMatch("First Round", 3, { p1: e7, p2: t[1], isScoring: false }),
makeMatch("First Round", 3, { p1: t[1], p2: e7, isScoring: false }),
makeMatch("First Round", 4, { p1: t[2], p2: t[5], isScoring: false }),
makeMatch("First Round", 5, { p1: t[10], p2: w8Seed, isScoring: false }),
makeMatch("First Round", 6, { p1: t[13], p2: t[14], isScoring: false }),
makeMatch("First Round", 7, { p1: w7, p2: t[11], isScoring: false }),
makeMatch("First Round", 7, { p1: t[11], p2: w7, isScoring: false }),
makeMatch("First Round", 8, { p1: t[12], p2: t[15], isScoring: false }),
// Conference Semifinals (TBD)
makeMatch("Conference Semifinals", 1, { isScoring: true }),

View file

@ -379,11 +379,11 @@ export class NBASimulator implements Simulator {
// coalescence (DB ?? simmed) always produces the correct team.
const frM1 = resolveSeries(eloMap, frByNum.get(1), undefined, e8Seed); // E1(DB) vs E8
const frM2 = resolveSeries(eloMap, frByNum.get(2)); // E4(DB) vs E5(DB)
const frM3 = resolveSeries(eloMap, frByNum.get(3), e7Seed, undefined); // E7 vs E2(DB)
const frM3 = resolveSeries(eloMap, frByNum.get(3), undefined, e7Seed); // E2(DB) vs E7
const frM4 = resolveSeries(eloMap, frByNum.get(4)); // E3(DB) vs E6(DB)
const frM5 = resolveSeries(eloMap, frByNum.get(5), undefined, w8Seed); // W1(DB) vs W8
const frM6 = resolveSeries(eloMap, frByNum.get(6)); // W4(DB) vs W5(DB)
const frM7 = resolveSeries(eloMap, frByNum.get(7), w7Seed, undefined); // W7 vs W2(DB)
const frM7 = resolveSeries(eloMap, frByNum.get(7), undefined, w7Seed); // W2(DB) vs W7
const frM8 = resolveSeries(eloMap, frByNum.get(8)); // W3(DB) vs W6(DB)
// ── Conference Semifinals (4 best-of-7 series) ────────────────────────