Fix bracket scoring logic to prefer template over DB defaults (#298)
* Emit standings-updated socket event after recalculate-floors so league pages refresh automatically When the admin runs "Recalculate Floors" on the bracket page, the league sports-season homepage was showing stale elimination data because there was no mechanism to notify it of the change. Fix: after recalculate-floors updates participant_results, emit a standings-updated socket event to all fantasy-season draft rooms linked to the sports season. The sports-season page now joins its draft room and revalidates its loader whenever it receives that event for the matching sports season. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix recalculate-floors incorrectly eliminating play-in losers who still advance The playoff_matches.isScoring column defaults to true in the database. Brackets created before this column was added (or before the migration set correct values) have isScoring=true on play-in rounds that should be false. When recalculate-floors replayed those matches, it took the "scoring round" path; since "Play-In Round 1" isn't in ROUND_CONFIG, config===null, and the loser was assigned finalPosition=0 regardless of loserAdvances — permanently eliminating teams like the Suns who had a second play-in game remaining. Fix: build a round→isScoring lookup from the bracket template before replaying matches and use it as the source of truth, falling back to the DB field only when the template doesn't define the round. This ensures non-scoring play-in rounds are always processed with isScoring=false so the loserAdvances guard fires correctly. Also revert unrelated socket changes from the previous (wrong) commit. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix isScoring fallback and loserAdvances gaps across all match-processing paths Three issues found in code review around the recalculate-floors fix: 1. set-winner and set-round-winners (bracket.server.ts) used `match.isScoring ?? true` just like recalculate-floors did. Both now derive isScoring from the bracket template as the source of truth, falling back to the DB field only when the round isn't defined in the template. 2. processPlayoffEvent (scoring-calculator.ts) used `matches[0]?.isScoring ?? true` with the same DB-default problem. Now uses BRACKET_TEMPLATES[bracketTemplateId] to look up the round's isScoring before falling back to the DB field. 3. doesLoserAdvance (playoff-match.ts) was missing the AFL afl_10 Qualifying Finals case: both losers advance to Semi-Finals. Without this, AFL QF losers would incorrectly receive finalPosition=0 when processed through the non-scoring path. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix no-non-null-assertion lint errors in isScoring Map lookups Replace Map.has(key) ? Map.get(key)! : fallback pattern with Map.get(key) ?? fallback to satisfy oxlint no-non-null-assertion rule. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f356bdce03
commit
0eb486f8d3
3 changed files with 47 additions and 23 deletions
|
|
@ -1316,9 +1316,15 @@ export function doesLoserAdvance(
|
||||||
matchNumber: number,
|
matchNumber: number,
|
||||||
templateId: string
|
templateId: string
|
||||||
): boolean {
|
): boolean {
|
||||||
|
// NBA Play-In Round 1: 7v8 games (M1, M3) — loser gets a second chance in Round 2.
|
||||||
|
// 9v10 games (M2, M4) — loser is immediately eliminated.
|
||||||
if (templateId === "nba_20" && round === "Play-In Round 1") {
|
if (templateId === "nba_20" && round === "Play-In Round 1") {
|
||||||
return matchNumber === 1 || matchNumber === 3;
|
return matchNumber === 1 || matchNumber === 3;
|
||||||
}
|
}
|
||||||
|
// AFL Qualifying Finals: both losers (1v4 and 2v3) advance to Semi-Finals.
|
||||||
|
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,12 @@ export async function processPlayoffEvent(
|
||||||
// Process based on round
|
// Process based on round
|
||||||
const round = event.playoffRound;
|
const round = event.playoffRound;
|
||||||
|
|
||||||
// Check if this is a scoring round
|
// Prefer template-defined isScoring over the DB field: the DB column defaults to
|
||||||
// First try to get isScoring from the match (template-based)
|
// true, so legacy play-in rows that predate the column are incorrectly marked as
|
||||||
const isScoring = matches[0]?.isScoring ?? true; // Default to true for legacy brackets
|
// scoring and would assign wrong placements to losers who still have a second game.
|
||||||
|
const bracketTemplate = event.bracketTemplateId ? BRACKET_TEMPLATES[event.bracketTemplateId] : undefined;
|
||||||
|
const templateRoundDef = bracketTemplate?.rounds.find((r) => r.name === round);
|
||||||
|
const isScoring = templateRoundDef !== undefined ? templateRoundDef.isScoring : (matches[0]?.isScoring ?? true);
|
||||||
|
|
||||||
// PHASE 5.3: Handle teams that didn't make the playoffs
|
// PHASE 5.3: Handle teams that didn't make the playoffs
|
||||||
// Get all participants in the sports season
|
// Get all participants in the sports season
|
||||||
|
|
|
||||||
|
|
@ -279,33 +279,33 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
await setMatchWinner(matchId, winnerId, loserId);
|
await setMatchWinner(matchId, winnerId, loserId);
|
||||||
|
|
||||||
// Try to advance the winner to the next round using template
|
// Try to advance the winner to the next round using template
|
||||||
if (event.bracketTemplateId) {
|
const setWinnerTemplate = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||||
const template = getBracketTemplate(event.bracketTemplateId);
|
if (setWinnerTemplate) {
|
||||||
if (template) {
|
try {
|
||||||
try {
|
await advanceWinnerTemplate(matchId, winnerId, setWinnerTemplate);
|
||||||
await advanceWinnerTemplate(matchId, winnerId, template);
|
} catch (error) {
|
||||||
} catch (error) {
|
// Only ignore "already filled" errors, re-throw unexpected errors
|
||||||
// Only ignore "already filled" errors, re-throw unexpected errors
|
if (
|
||||||
if (
|
error instanceof Error &&
|
||||||
error instanceof Error &&
|
error.message.includes("already filled")
|
||||||
error.message.includes("already filled")
|
) {
|
||||||
) {
|
logger.warn("Match already filled, skipping advancement");
|
||||||
logger.warn("Match already filled, skipping advancement");
|
} else {
|
||||||
} else {
|
throw error; // Re-throw unexpected errors
|
||||||
throw error; // Re-throw unexpected errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Immediately score this match: loser gets their final placement,
|
// Immediately score this match: loser gets their final placement,
|
||||||
// winner gets provisional floor points (isPartialScore=true).
|
// winner gets provisional floor points (isPartialScore=true).
|
||||||
// isScoring ?? true: default to true for legacy rows pre-dating the isScoring column.
|
// Prefer the template-defined isScoring over the DB field: the DB column
|
||||||
|
// defaults to true, so legacy play-in rows may be incorrectly marked as scoring.
|
||||||
|
const setWinnerRoundIsScoring = setWinnerTemplate?.rounds.find((r) => r.name === match.round)?.isScoring;
|
||||||
await processMatchResult({
|
await processMatchResult({
|
||||||
round: match.round,
|
round: match.round,
|
||||||
winnerId,
|
winnerId,
|
||||||
loserId,
|
loserId,
|
||||||
isScoring: match.isScoring ?? true,
|
isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true),
|
||||||
sportsSeasonId: event.sportsSeasonId,
|
sportsSeasonId: event.sportsSeasonId,
|
||||||
bracketTemplateId: event.bracketTemplateId,
|
bracketTemplateId: event.bracketTemplateId,
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
|
|
@ -356,6 +356,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||||
|
// Prefer template-defined isScoring over the DB field (DB defaults to true,
|
||||||
|
// so legacy play-in rows may be incorrectly marked as scoring).
|
||||||
|
const roundIsScoring = new Map<string, boolean>(
|
||||||
|
template?.rounds.map((r) => [r.name, r.isScoring]) ?? []
|
||||||
|
);
|
||||||
|
|
||||||
// Process each winner assignment
|
// Process each winner assignment
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
|
|
@ -409,12 +414,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
|
||||||
// Score this match without triggering standings/probability recalc yet —
|
// Score this match without triggering standings/probability recalc yet —
|
||||||
// we batch those side effects into a single call after the loop.
|
// we batch those side effects into a single call after the loop.
|
||||||
// isScoring ?? true: default to true for legacy rows pre-dating the isScoring column.
|
|
||||||
await processMatchResult({
|
await processMatchResult({
|
||||||
round: match.round,
|
round: match.round,
|
||||||
winnerId,
|
winnerId,
|
||||||
loserId,
|
loserId,
|
||||||
isScoring: match.isScoring ?? true,
|
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
|
||||||
sportsSeasonId: event.sportsSeasonId,
|
sportsSeasonId: event.sportsSeasonId,
|
||||||
bracketTemplateId: event.bracketTemplateId,
|
bracketTemplateId: event.bracketTemplateId,
|
||||||
eventName: event.name ?? undefined,
|
eventName: event.name ?? undefined,
|
||||||
|
|
@ -597,6 +601,12 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// Replay each completed match in bracket order (earlier rounds first).
|
// Replay each completed match in bracket order (earlier rounds first).
|
||||||
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||||
const roundOrder = template ? template.rounds.map((r) => r.name) : [];
|
const roundOrder = template ? template.rounds.map((r) => r.name) : [];
|
||||||
|
// Build a round→isScoring map from the template so we use the template as the
|
||||||
|
// source of truth rather than the DB column (which defaults to true and may be
|
||||||
|
// wrong for brackets created before the isScoring column was added).
|
||||||
|
const templateRoundIsScoring = new Map<string, boolean>(
|
||||||
|
template?.rounds.map((r) => [r.name, r.isScoring]) ?? []
|
||||||
|
);
|
||||||
|
|
||||||
const sortedMatches = completed.toSorted((a, b) => {
|
const sortedMatches = completed.toSorted((a, b) => {
|
||||||
const ai = roundOrder.indexOf(a.round);
|
const ai = roundOrder.indexOf(a.round);
|
||||||
|
|
@ -605,12 +615,17 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const match of sortedMatches) {
|
for (const match of sortedMatches) {
|
||||||
|
// Prefer the template-defined isScoring over the DB field: the DB column
|
||||||
|
// defaults to true, so legacy play-in rows that predate the column are
|
||||||
|
// incorrectly marked as scoring and would assign finalPosition=0 to losers
|
||||||
|
// who still have a second game (e.g. NBA 7v8 play-in loser → Round 2).
|
||||||
|
const isScoring = templateRoundIsScoring.get(match.round) ?? (match.isScoring ?? true);
|
||||||
await processMatchResult(
|
await processMatchResult(
|
||||||
{
|
{
|
||||||
round: match.round,
|
round: match.round,
|
||||||
winnerId: match.winnerId ?? "",
|
winnerId: match.winnerId ?? "",
|
||||||
loserId: match.loserId ?? "",
|
loserId: match.loserId ?? "",
|
||||||
isScoring: match.isScoring ?? true,
|
isScoring,
|
||||||
sportsSeasonId: event.sportsSeasonId,
|
sportsSeasonId: event.sportsSeasonId,
|
||||||
bracketTemplateId: event.bracketTemplateId,
|
bracketTemplateId: event.bracketTemplateId,
|
||||||
skipSideEffects: true,
|
skipSideEffects: true,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue