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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-14 22:16:57 -07:00 committed by GitHub
parent 4a875e7628
commit d6a92c44ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 210 additions and 21 deletions

View file

@ -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);
});
});

View file

@ -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.
*

View file

@ -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<typeof database>
): Promise<void> {
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 T5T8 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<string>,
finalizedLoserIds: Set<string>
): 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<string>();
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,
};
});
}
}

View file

@ -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++;