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:
Chris Parsons 2026-04-15 11:27:00 -07:00 committed by GitHub
parent f356bdce03
commit 0eb486f8d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 47 additions and 23 deletions

View file

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

View file

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

View file

@ -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,