From d6a92c44ff49387e8b05914e7a433a47498a1ffb Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:16:57 -0700 Subject: [PATCH] fix: NBA play-in 7v8 loser incorrectly marked as eliminated (#295) The loser of NBA Play-In Round 1 matches 1 and 3 (East/West 7v8 games) advances to Play-In Round 2 rather than being eliminated, but was being assigned finalPosition=0 and appearing in Discord as knocked out. - Add `loserAdvances` param to `processMatchResult` to skip recording an elimination result when the loser continues to another match - Extract `doesLoserAdvance()` to `playoff-match.ts` as the single source of truth for which play-in matches have advancing losers (replaces duplicated hardcoded checks at both call sites in bracket.server.ts) - Extract `isLoserNotifiable()` as a pure exported helper so Discord notification eligibility is testable; losers only appear when their team's score changed OR they have a finalized (non-partial) result, correctly suppressing advancing losers while still surfacing 0-pt eliminations that produce no score delta - Add 14 new unit tests covering `doesLoserAdvance`, `isLoserNotifiable`, and the advancing-loser/eliminated-loser `processMatchResult` branches Co-authored-by: Claude Sonnet 4.6 --- .../__tests__/process-match-result.test.ts | 118 +++++++++++++++++- app/models/playoff-match.ts | 18 +++ app/models/scoring-calculator.ts | 92 +++++++++++--- ...sons.$id.events.$eventId.bracket.server.ts | 3 + 4 files changed, 210 insertions(+), 21 deletions(-) diff --git a/app/models/__tests__/process-match-result.test.ts b/app/models/__tests__/process-match-result.test.ts index b45bf85..3afc8fc 100644 --- a/app/models/__tests__/process-match-result.test.ts +++ b/app/models/__tests__/process-match-result.test.ts @@ -59,7 +59,8 @@ function makeDb(existingResult?: { id: string; isPartialScore: boolean }) { }; } -import { processMatchResult } from "../scoring-calculator"; +import { processMatchResult, isLoserNotifiable } from "../scoring-calculator"; +import { doesLoserAdvance } from "../playoff-match"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; const BASE = { @@ -196,6 +197,41 @@ describe("processMatchResult", () => { expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true }); }); + + describe("NBA Play-In loserAdvances=true (7v8 game)", () => { + it("loser is NOT eliminated when loserAdvances=true (advances to Round 2)", async () => { + const { db, insertedRows } = makeDb(); + await processMatchResult( + { + ...BASE, + bracketTemplateId: "nba_20", + round: "Play-In Round 1", + isScoring: false, + loserAdvances: true, + }, + db + ); + // Neither participant gets a result: loser skipped (still playing), and + // winner gets nothing because PIR1 doesn't feed directly into a scoring round. + expect(insertedRows).toHaveLength(0); + }); + + it("loser IS eliminated when loserAdvances=false (9v10 game)", async () => { + const { db, insertedRows } = makeDb(); + await processMatchResult( + { + ...BASE, + bracketTemplateId: "nba_20", + round: "Play-In Round 1", + isScoring: false, + loserAdvances: false, + }, + db + ); + expect(insertedRows).toHaveLength(1); + expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); + }); + }); }); describe("Unrecognized scoring round", () => { @@ -282,3 +318,83 @@ describe("processMatchResult", () => { }); }); }); + +describe("doesLoserAdvance", () => { + it("returns true for NBA Play-In Round 1 match 1 (E7v8)", () => { + expect(doesLoserAdvance("Play-In Round 1", 1, "nba_20")).toBe(true); + }); + + it("returns true for NBA Play-In Round 1 match 3 (W7v8)", () => { + expect(doesLoserAdvance("Play-In Round 1", 3, "nba_20")).toBe(true); + }); + + it("returns false for NBA Play-In Round 1 match 2 (E9v10 — loser eliminated)", () => { + expect(doesLoserAdvance("Play-In Round 1", 2, "nba_20")).toBe(false); + }); + + it("returns false for NBA Play-In Round 1 match 4 (W9v10 — loser eliminated)", () => { + expect(doesLoserAdvance("Play-In Round 1", 4, "nba_20")).toBe(false); + }); + + it("returns false for NBA Play-In Round 2 (all losers eliminated)", () => { + expect(doesLoserAdvance("Play-In Round 2", 1, "nba_20")).toBe(false); + expect(doesLoserAdvance("Play-In Round 2", 2, "nba_20")).toBe(false); + }); + + it("returns false for other templates", () => { + expect(doesLoserAdvance("Play-In Round 1", 1, "nfl_14")).toBe(false); + expect(doesLoserAdvance("Semifinals", 1, "fifa_48")).toBe(false); + }); + + it("returns false for an empty templateId", () => { + expect(doesLoserAdvance("Play-In Round 1", 1, "")).toBe(false); + }); +}); + +describe("isLoserNotifiable", () => { + const LOSER = "loser-1"; + const TEAM = "team-1"; + + it("returns true when the loser's team score changed", () => { + expect( + isLoserNotifiable(LOSER, TEAM, new Set([TEAM]), new Set()) + ).toBe(true); + }); + + it("returns true when the loser is finalized even with no score change (0-pt elimination)", () => { + expect( + isLoserNotifiable(LOSER, TEAM, new Set(), new Set([LOSER])) + ).toBe(true); + }); + + it("returns false when score did not change and loser is not finalized (advances)", () => { + expect( + isLoserNotifiable(LOSER, TEAM, new Set(), new Set()) + ).toBe(false); + }); + + it("returns false when loserId is null", () => { + expect( + isLoserNotifiable(null, TEAM, new Set([TEAM]), new Set()) + ).toBe(false); + }); + + it("returns false when loserId is undefined", () => { + expect( + isLoserNotifiable(undefined, undefined, new Set(), new Set()) + ).toBe(false); + }); + + it("returns false when loser has no team (undrafted) and is not finalized", () => { + expect( + isLoserNotifiable(LOSER, undefined, new Set(), new Set()) + ).toBe(false); + }); + + it("returns true when loser has no team but is finalized (edge case: drafted mid-season?)", () => { + // finalizedLoserIds wins regardless of team presence + expect( + isLoserNotifiable(LOSER, undefined, new Set(), new Set([LOSER])) + ).toBe(true); + }); +}); diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts index f448db4..d6e624e 100644 --- a/app/models/playoff-match.ts +++ b/app/models/playoff-match.ts @@ -1304,6 +1304,24 @@ async function generateNBA20Bracket( return await createManyPlayoffMatches(matches); } +/** + * Returns true if the loser of the given match advances to another round rather + * than being eliminated. Use this to avoid recording a premature elimination result. + * + * Currently only applies to NBA Play-In Round 1 matches 1 and 3 (the 7v8 games), + * whose losers go on to Play-In Round 2. + */ +export function doesLoserAdvance( + round: string, + matchNumber: number, + templateId: string +): boolean { + if (templateId === "nba_20" && round === "Play-In Round 1") { + return matchNumber === 1 || matchNumber === 3; + } + return false; +} + /** * NBA play-in advancement logic. * diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 450c616..11d39d0 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -363,19 +363,28 @@ export async function processMatchResult( /** When set, Discord notification only shows this match (not all completed matches for the event). */ matchId?: string; skipSideEffects?: boolean; + /** + * When true, the loser of this non-scoring round advances to another match + * (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be + * marked as eliminated yet. Has no effect when isScoring=true. + */ + loserAdvances?: boolean; }, providedDb?: ReturnType ): Promise { const db = providedDb || database(); - const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects } = params; + const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params; if (!isScoring) { - // Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts). + // Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts), + // unless loserAdvances=true (e.g. NBA Play-In 7v8 loser goes to Round 2). // Winner only banks provisional T5–T8 floor if they're entering the first // scoring round (guaranteed top-8). For multi-round pre-bracket sequences // like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners // should receive floor points. - await upsertParticipantResult(loserId, sportsSeasonId, 0, db); + if (!loserAdvances) { + await upsertParticipantResult(loserId, sportsSeasonId, 0, db); + } if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) { await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); } @@ -469,6 +478,28 @@ async function upsertParticipantResult( * - The round is non-scoring (winners haven't reached a points-paying zone) * - The round is the Finals (winner already finalized as 1st place inline) */ +/** + * Returns true if a loser should appear in a Discord standings notification. + * + * A loser is shown when: + * - Their team's score changed this round (e.g. they received a floor or position), OR + * - They were definitively eliminated (non-partial result exists), which covers + * 0-pt eliminations that produce no score delta. + * + * Losers who advance to another match (loserAdvances=true) have neither a score + * change nor a finalized result, so they are correctly suppressed. + */ +export function isLoserNotifiable( + loserId: string | null | undefined, + loserTeamId: string | undefined, + changedTeamIds: Set, + finalizedLoserIds: Set +): boolean { + if (!loserId) return false; + const scoreChanged = loserTeamId ? changedTeamIds.has(loserTeamId) : false; + return scoreChanged || finalizedLoserIds.has(loserId); +} + export function getGuaranteedMinimumPosition( round: string, bracketTemplateId: string | null | undefined, @@ -1292,6 +1323,23 @@ export async function recalculateAffectedLeagues( })); } + // Build the set of loser participant IDs that have a finalized (non-partial) result. + // Used in Discord notifications to surface 0-pt eliminations that produce no score delta. + const loserParticipantIds = allCompletedMatches + .map((m) => m.loserId) + .filter((id): id is string => id !== null); + const finalizedLoserIds = new Set(); + if (loserParticipantIds.length > 0) { + const loserResults = await db.query.participantResults.findMany({ + where: and( + inArray(schema.participantResults.participantId, loserParticipantIds), + eq(schema.participantResults.sportsSeasonId, sportsSeasonId), + eq(schema.participantResults.isPartialScore, false) + ), + }); + for (const r of loserResults) finalizedLoserIds.add(r.participantId); + } + // Recalculate each affected season for (const seasonId of seasonIds) { // Capture standings before recalculation for delta calculation @@ -1360,9 +1408,13 @@ export async function recalculateAffectedLeagues( afterStandings.map((s) => [s.teamId, s.team.ownerId]) ); - // Build scored matches for the notification — computed before the gate - // because an elimination (0 points, no score change) still warrants a - // notification when a drafted participant is knocked out. + // Build scored matches for the notification. + // Winners only appear if their team's score changed (they earned points this round). + // Losers appear if their team's score changed OR they were definitively eliminated + // (finalPosition set, non-partial) — the latter catches 0-pt eliminations that + // produce no score delta. Losers who advance to another match (loserAdvances=true, + // e.g. NBA 7v8 → PIR2) have no finalized result and no score change, so they're + // correctly suppressed. let scoredMatches: ScoredMatch[] | undefined; if (allCompletedMatches.length > 0) { const draftPicks = await db.query.draftPicks.findMany({ @@ -1381,17 +1433,10 @@ export async function recalculateAffectedLeagues( draftPicks.map((p) => [p.participantId, p.teamId]) ); - // Look up the fantasy owner's display name for a participant. - // Pass requireScoreChange=true for the winner to avoid surfacing wins - // that didn't award points this round. - const usernameForParticipant = ( - participantId: string | null | undefined, - requireScoreChange = false - ) => { + const lookupUsername = (participantId: string | null | undefined) => { if (!participantId) return undefined; const teamId = teamIdByParticipantId.get(participantId); if (!teamId) return undefined; - if (requireScoreChange && !changedTeamIds.has(teamId)) return undefined; const ownerId = ownerIdByTeamId.get(teamId); if (!ownerId) return undefined; return usernameByClerkId.get(ownerId); @@ -1399,12 +1444,19 @@ export async function recalculateAffectedLeagues( scoredMatches = relevant .filter((m) => m.winnerName && m.loserName) - .map((m) => ({ - winnerName: m.winnerName ?? "", - loserName: m.loserName ?? "", - winnerUsername: usernameForParticipant(m.winnerId, true), - loserUsername: usernameForParticipant(m.loserId), - })); + .map((m) => { + const winnerTeamId = m.winnerId ? teamIdByParticipantId.get(m.winnerId) : undefined; + const loserTeamId = m.loserId ? teamIdByParticipantId.get(m.loserId) : undefined; + const winnerScoreChanged = winnerTeamId ? changedTeamIds.has(winnerTeamId) : false; + return { + winnerName: m.winnerName ?? "", + loserName: m.loserName ?? "", + winnerUsername: winnerScoreChanged ? lookupUsername(m.winnerId) : undefined, + loserUsername: isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds) + ? lookupUsername(m.loserId) + : undefined, + }; + }); } } diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index 21ae56c..ab20924 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -10,6 +10,7 @@ import { advanceWinnerTemplate, findPlayoffMatchById, assignParticipantsToKnockout, + doesLoserAdvance, } from "~/models/playoff-match"; import { createGame, @@ -310,6 +311,7 @@ export async function action({ request, params }: Route.ActionArgs) { eventId: event.id, eventName: event.name ?? undefined, matchId, + loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), }); const db = database(); @@ -417,6 +419,7 @@ export async function action({ request, params }: Route.ActionArgs) { bracketTemplateId: event.bracketTemplateId, eventName: event.name ?? undefined, skipSideEffects: true, + loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), }); successCount++;