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,
|
||||
templateId: string
|
||||
): 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") {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -187,9 +187,12 @@ export async function processPlayoffEvent(
|
|||
// Process based on round
|
||||
const round = event.playoffRound;
|
||||
|
||||
// Check if this is a scoring round
|
||||
// First try to get isScoring from the match (template-based)
|
||||
const isScoring = matches[0]?.isScoring ?? true; // Default to true for legacy brackets
|
||||
// Prefer 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 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
|
||||
// Get all participants in the sports season
|
||||
|
|
|
|||
|
|
@ -279,33 +279,33 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
await setMatchWinner(matchId, winnerId, loserId);
|
||||
|
||||
// Try to advance the winner to the next round using template
|
||||
if (event.bracketTemplateId) {
|
||||
const template = getBracketTemplate(event.bracketTemplateId);
|
||||
if (template) {
|
||||
try {
|
||||
await advanceWinnerTemplate(matchId, winnerId, template);
|
||||
} catch (error) {
|
||||
// Only ignore "already filled" errors, re-throw unexpected errors
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("already filled")
|
||||
) {
|
||||
logger.warn("Match already filled, skipping advancement");
|
||||
} else {
|
||||
throw error; // Re-throw unexpected errors
|
||||
}
|
||||
const setWinnerTemplate = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||
if (setWinnerTemplate) {
|
||||
try {
|
||||
await advanceWinnerTemplate(matchId, winnerId, setWinnerTemplate);
|
||||
} catch (error) {
|
||||
// Only ignore "already filled" errors, re-throw unexpected errors
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("already filled")
|
||||
) {
|
||||
logger.warn("Match already filled, skipping advancement");
|
||||
} else {
|
||||
throw error; // Re-throw unexpected errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Immediately score this match: loser gets their final placement,
|
||||
// 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({
|
||||
round: match.round,
|
||||
winnerId,
|
||||
loserId,
|
||||
isScoring: match.isScoring ?? true,
|
||||
isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true),
|
||||
sportsSeasonId: event.sportsSeasonId,
|
||||
bracketTemplateId: event.bracketTemplateId,
|
||||
eventId: event.id,
|
||||
|
|
@ -356,6 +356,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
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
|
||||
let successCount = 0;
|
||||
|
|
@ -409,12 +414,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
|
||||
// Score this match without triggering standings/probability recalc yet —
|
||||
// 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({
|
||||
round: match.round,
|
||||
winnerId,
|
||||
loserId,
|
||||
isScoring: match.isScoring ?? true,
|
||||
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
|
||||
sportsSeasonId: event.sportsSeasonId,
|
||||
bracketTemplateId: event.bracketTemplateId,
|
||||
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).
|
||||
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||
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 ai = roundOrder.indexOf(a.round);
|
||||
|
|
@ -605,12 +615,17 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
});
|
||||
|
||||
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(
|
||||
{
|
||||
round: match.round,
|
||||
winnerId: match.winnerId ?? "",
|
||||
loserId: match.loserId ?? "",
|
||||
isScoring: match.isScoring ?? true,
|
||||
isScoring,
|
||||
sportsSeasonId: event.sportsSeasonId,
|
||||
bracketTemplateId: event.bracketTemplateId,
|
||||
skipSideEffects: true,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue