Fix NBA Play-In Round 1 loser advancement logic (#296)
* Fix NBA Play-In 7/8 loser incorrectly marked as eliminated processPlayoffEvent (called by autoCompleteRoundIfDone when all round matches finish) was marking every non-scoring round loser as eliminated without checking doesLoserAdvance. This caused the 7v8 loser, who should advance to Play-In Round 2, to get finalPosition=0 as soon as the full round completed. Fixes: - processPlayoffEvent now calls doesLoserAdvance per match before writing a 0-pt elimination result, matching the guard already in processMatchResult - recalculate-floors handler now passes loserAdvances to processMatchResult so a full reprocess also respects the loser-advances rule - recalculateAffectedLeagues gains a skipDiscord option; recalculate-floors uses it so clicking the admin "Recalculate Floors" button corrects the bad data without re-announcing results on Discord - Add two tests confirming 7v8 loser is not eliminated and 9v10 loser is https://claude.ai/code/session_01QmvezscLYY38gN4GbvXZbA * Address code review feedback on Play-In loserAdvances fix - Use outer `round` variable instead of match.round in processPlayoffEvent (they're identical, but consistent with surrounding code) - Add comment at recalculate-floors call site explaining skipDiscord intent - Combine two redundant test cases into one covering all four assertions - Add West conference matches (M3/M4) to test fixture — East-only was insufficient given doesLoserAdvance checks matchNumber 1 & 3 - Add guard test: when bracketTemplateId is null all losers are eliminated, catching any future refactor that drops the field from the DB query https://claude.ai/code/session_01QmvezscLYY38gN4GbvXZbA --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d6a92c44ff
commit
f356bdce03
3 changed files with 133 additions and 5 deletions
|
|
@ -59,7 +59,7 @@ function makeDb(existingResult?: { id: string; isPartialScore: boolean }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
import { processMatchResult, isLoserNotifiable } from "../scoring-calculator";
|
import { processMatchResult, isLoserNotifiable, processPlayoffEvent } from "../scoring-calculator";
|
||||||
import { doesLoserAdvance } from "../playoff-match";
|
import { doesLoserAdvance } from "../playoff-match";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
|
|
||||||
|
|
@ -398,3 +398,127 @@ describe("isLoserNotifiable", () => {
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => {
|
||||||
|
function makePlayInDb(matches: object[]) {
|
||||||
|
const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = [];
|
||||||
|
const insertValues = vi.fn().mockImplementation((values: object) => {
|
||||||
|
insertedRows.push(values as typeof insertedRows[0]);
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
insert: vi.fn().mockReturnValue({ values: insertValues }),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
||||||
|
}),
|
||||||
|
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
||||||
|
query: {
|
||||||
|
scoringEvents: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({
|
||||||
|
id: "event-1",
|
||||||
|
playoffRound: "Play-In Round 1",
|
||||||
|
bracketTemplateId: "nba_20",
|
||||||
|
sportsSeasonId: "ss-1",
|
||||||
|
name: "NBA Playoffs",
|
||||||
|
sportsSeason: {
|
||||||
|
seasonSports: [{ seasonId: "season-1", season: {} }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
playoffMatches: {
|
||||||
|
findMany: vi.fn().mockResolvedValue(matches),
|
||||||
|
},
|
||||||
|
participants: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
participantResults: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
seasons: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({
|
||||||
|
id: "season-1",
|
||||||
|
pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50,
|
||||||
|
pointsFor4th: 40, pointsFor5th: 25, pointsFor6th: 25,
|
||||||
|
pointsFor7th: 15, pointsFor8th: 15,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
seasonSports: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
sportsSeasons: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
insertedRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// All four Play-In Round 1 matches: East 7v8 (M1), East 9v10 (M2), West 7v8 (M3), West 9v10 (M4)
|
||||||
|
const PLAY_IN_MATCHES = [
|
||||||
|
{
|
||||||
|
id: "m1", round: "Play-In Round 1", matchNumber: 1, isScoring: false,
|
||||||
|
isComplete: true, scoringEventId: "event-1",
|
||||||
|
participant1Id: "winner-78e", participant2Id: "loser-78e",
|
||||||
|
winnerId: "winner-78e", loserId: "loser-78e",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "m2", round: "Play-In Round 1", matchNumber: 2, isScoring: false,
|
||||||
|
isComplete: true, scoringEventId: "event-1",
|
||||||
|
participant1Id: "winner-910e", participant2Id: "loser-910e",
|
||||||
|
winnerId: "winner-910e", loserId: "loser-910e",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "m3", round: "Play-In Round 1", matchNumber: 3, isScoring: false,
|
||||||
|
isComplete: true, scoringEventId: "event-1",
|
||||||
|
participant1Id: "winner-78w", participant2Id: "loser-78w",
|
||||||
|
winnerId: "winner-78w", loserId: "loser-78w",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "m4", round: "Play-In Round 1", matchNumber: 4, isScoring: false,
|
||||||
|
isComplete: true, scoringEventId: "event-1",
|
||||||
|
participant1Id: "winner-910w", participant2Id: "loser-910w",
|
||||||
|
winnerId: "winner-910w", loserId: "loser-910w",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("eliminates 9v10 losers (East & West) but NOT 7v8 losers", async () => {
|
||||||
|
const { db, insertedRows } = makePlayInDb(PLAY_IN_MATCHES);
|
||||||
|
|
||||||
|
await processPlayoffEvent("event-1", db, { skipRecalculate: true });
|
||||||
|
|
||||||
|
const eliminatedIds = insertedRows
|
||||||
|
.filter((r) => r.finalPosition === 0 && !r.isPartialScore)
|
||||||
|
.map((r) => r.participantId);
|
||||||
|
|
||||||
|
// 7v8 losers advance to Play-In Round 2 — must not be eliminated yet
|
||||||
|
expect(eliminatedIds).not.toContain("loser-78e");
|
||||||
|
expect(eliminatedIds).not.toContain("loser-78w");
|
||||||
|
// 9v10 losers are out immediately
|
||||||
|
expect(eliminatedIds).toContain("loser-910e");
|
||||||
|
expect(eliminatedIds).toContain("loser-910w");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("eliminates ALL losers when bracketTemplateId is missing (falls back to safe default)", async () => {
|
||||||
|
// Guards against a future refactor that drops bracketTemplateId from the DB query.
|
||||||
|
// doesLoserAdvance receives "" → returns false for all matches → all losers eliminated.
|
||||||
|
const { db, insertedRows } = makePlayInDb(PLAY_IN_MATCHES);
|
||||||
|
(db.query.scoringEvents.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
id: "event-1",
|
||||||
|
playoffRound: "Play-In Round 1",
|
||||||
|
bracketTemplateId: null, // simulates missing templateId
|
||||||
|
sportsSeasonId: "ss-1",
|
||||||
|
name: "NBA Playoffs",
|
||||||
|
sportsSeason: {
|
||||||
|
seasonSports: [{ seasonId: "season-1", season: {} }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await processPlayoffEvent("event-1", db, { skipRecalculate: true });
|
||||||
|
|
||||||
|
const eliminatedIds = insertedRows
|
||||||
|
.filter((r) => r.finalPosition === 0 && !r.isPartialScore)
|
||||||
|
.map((r) => r.participantId);
|
||||||
|
|
||||||
|
expect(eliminatedIds).toContain("loser-78e");
|
||||||
|
expect(eliminatedIds).toContain("loser-78w");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { getSeasonResults } from "./participant-season-result";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
||||||
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||||
|
import { doesLoserAdvance } from "~/models/playoff-match";
|
||||||
import { getUserDisplayName } from "~/models/user";
|
import { getUserDisplayName } from "~/models/user";
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
@ -251,7 +252,8 @@ export async function processPlayoffEvent(
|
||||||
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
|
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
|
||||||
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
|
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
if (match.loserId) {
|
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
|
||||||
|
if (match.loserId && !loserAdvances) {
|
||||||
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
||||||
}
|
}
|
||||||
if (match.winnerId && awardFloor) {
|
if (match.winnerId && awardFloor) {
|
||||||
|
|
@ -1271,7 +1273,7 @@ export async function recalculateStandings(
|
||||||
export async function recalculateAffectedLeagues(
|
export async function recalculateAffectedLeagues(
|
||||||
sportsSeasonId: string,
|
sportsSeasonId: string,
|
||||||
providedDb?: ReturnType<typeof database>,
|
providedDb?: ReturnType<typeof database>,
|
||||||
options?: { eventName?: string; eventId?: string; matchIds?: string[] }
|
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
|
@ -1386,7 +1388,7 @@ export async function recalculateAffectedLeagues(
|
||||||
});
|
});
|
||||||
|
|
||||||
const webhookUrl = season?.league?.discordWebhookUrl;
|
const webhookUrl = season?.league?.discordWebhookUrl;
|
||||||
if (!webhookUrl) continue;
|
if (!webhookUrl || options?.skipDiscord) continue;
|
||||||
|
|
||||||
// Build clerkId → username map for all team owners in this season
|
// Build clerkId → username map for all team owners in this season
|
||||||
const ownerClerkIds = afterStandings
|
const ownerClerkIds = afterStandings
|
||||||
|
|
|
||||||
|
|
@ -614,11 +614,13 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
sportsSeasonId: event.sportsSeasonId,
|
sportsSeasonId: event.sportsSeasonId,
|
||||||
bracketTemplateId: event.bracketTemplateId,
|
bracketTemplateId: event.bracketTemplateId,
|
||||||
skipSideEffects: true,
|
skipSideEffects: true,
|
||||||
|
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId);
|
// skipDiscord: recalculate-floors is a data-correction tool, not a result announcement.
|
||||||
|
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
|
||||||
|
|
||||||
return { success: `Recalculated floors from ${sortedMatches.length} completed match(es)` };
|
return { success: `Recalculated floors from ${sortedMatches.length} completed match(es)` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue