diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts index d6e624e..fe436ea 100644 --- a/app/models/playoff-match.ts +++ b/app/models/playoff-match.ts @@ -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; } diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 70c5b7d..48ed86f 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -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 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 763d614..72242e0 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 @@ -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( + 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( + 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,