brackt/app/models/scoring-rules.ts
Claude a143df51f6
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m4s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m22s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.

season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.

The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.

A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.

Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.

The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.

Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.

Every fix is covered by a test confirmed to fail when that fix alone is
reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00

283 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { DEFAULT_SCORING_RULES, type ScoringRules } from "~/lib/scoring-types";
// Re-export for convenience
export { DEFAULT_SCORING_RULES, type ScoringRules };
/**
* Get scoring rules for a season
*/
export async function getScoringRules(
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<ScoringRules | null> {
const db = providedDb || database();
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) {
return null;
}
return {
pointsFor1st: season.pointsFor1st,
pointsFor2nd: season.pointsFor2nd,
pointsFor3rd: season.pointsFor3rd,
pointsFor4th: season.pointsFor4th,
pointsFor5th: season.pointsFor5th,
pointsFor6th: season.pointsFor6th,
pointsFor7th: season.pointsFor7th,
pointsFor8th: season.pointsFor8th,
};
}
/**
* Update scoring rules for a season
*/
export async function updateScoringRules(
seasonId: string,
rules: Partial<ScoringRules>,
providedDb?: ReturnType<typeof database>
): Promise<ScoringRules> {
const db = providedDb || database();
const [updated] = await db
.update(schema.seasons)
.set(rules)
.where(eq(schema.seasons.id, seasonId))
.returning({
pointsFor1st: schema.seasons.pointsFor1st,
pointsFor2nd: schema.seasons.pointsFor2nd,
pointsFor3rd: schema.seasons.pointsFor3rd,
pointsFor4th: schema.seasons.pointsFor4th,
pointsFor5th: schema.seasons.pointsFor5th,
pointsFor6th: schema.seasons.pointsFor6th,
pointsFor7th: schema.seasons.pointsFor7th,
pointsFor8th: schema.seasons.pointsFor8th,
});
return updated;
}
/**
* Calculate fantasy points for a given placement based on season scoring rules
*/
export function calculateFantasyPoints(
placement: number,
rules: ScoringRules
): number {
const pointsMap: Record<number, number> = {
1: rules.pointsFor1st,
2: rules.pointsFor2nd,
3: rules.pointsFor3rd,
4: rules.pointsFor4th,
5: rules.pointsFor5th,
6: rules.pointsFor6th,
7: rules.pointsFor7th,
8: rules.pointsFor8th,
};
return pointsMap[placement] || 0;
}
/**
* Calculate averaged points for shared placements (e.g., playoff ties)
* Used when multiple participants share positions
*
* Example: 4 teams lose in quarterfinals, they share positions 5-8
* Average = (25 + 25 + 15 + 15) / 4 = 20 points each
*
* The result is rounded to the nearest whole point, matching the published rule
* on the /rules page: "the points for all tied positions are combined and split
* equally among them, rounded to the nearest whole point". That page's own
* example rounds down — a three-way tie for 6th8th is (25 + 15 + 15) / 3 =
* 18.33 → 18 — so this is nearest, not ceiling. Season point values are integer
* columns, so this averaging is the only place fractional points can arise.
*/
export function calculateAveragedPoints(
placements: number[],
rules: ScoringRules
): number {
if (placements.length === 0) return 0;
const total = placements.reduce((sum, placement) => {
return sum + calculateFantasyPoints(placement, rules);
}, 0);
return Math.round(total / placements.length);
}
/**
* Calculate points for participants sharing a standings placement.
*
* Example: two participants tied for 2nd share 2nd and 3rd place points.
* If a tie extends beyond the scoring range, the extra slots contribute 0.
*/
export function calculateSharedPlacementPoints(
startPlacement: number,
tiedParticipants: number,
rules: ScoringRules
): number {
if (startPlacement <= 0 || tiedParticipants <= 0) return 0;
const placements = Array.from(
{ length: tiedParticipants },
(_, index) => startPlacement + index
);
return calculateAveragedPoints(placements, rules);
}
/**
* Tier definitions for brackets where positions 58 split into two separate pairs.
*
* Most brackets (standard single-elimination) have ONE tier covering positions 58:
* four QF losers all tie and share the combined prize pool → avg([5,6,7,8]).
*
* AFL is different: it has TWO distinct tiers in the 58 zone:
* - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6])
* - T7-T8: Elimination Finals losers (positions 7 and 8) → avg([7,8])
*
* LLWS has the same shape from its two elimination brackets:
* - T5-T6: Elimination Final losers (one per side) → avg([5,6])
* - T7-T8: Elimination Round 4 losers (one per side) → avg([7,8])
*/
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]);
/**
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
* (not averaged). Standard brackets average them because both SF losers tie.
*
* llws_20's Consolation Third Place game decides 3rd and 4th head-to-head between
* the two side runners-up.
*/
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]);
/**
* Calculate fantasy points for a bracket placement, averaging tied positions.
*
* Standard single-elimination bracket tiers:
* 1st: solo winner
* 2nd: solo finalist
* 3rd-4th: two SF losers share these positions → averaged
* 5th-8th: four QF losers share these positions → averaged
*
* AFL bracket tiers (afl_10):
* 5th-6th: Semi-Finals losers → averaged separately from 7th-8th
* 7th-8th: Elimination Finals losers → averaged separately from 5th-6th
*
* Use this instead of calculateFantasyPoints for playoff_bracket scoring.
*/
export function calculateBracketPoints(
finalPosition: number,
rules: ScoringRules,
bracketTemplateId?: string | null
): number {
if (finalPosition <= 0) return 0;
if (finalPosition === 1) return rules.pointsFor1st;
if (finalPosition === 2) return rules.pointsFor2nd;
if (finalPosition === 3 || finalPosition === 4) {
if (bracketTemplateId && DISTINCT_34_TEMPLATE_IDS.has(bracketTemplateId))
return finalPosition === 3 ? rules.pointsFor3rd : rules.pointsFor4th;
return calculateAveragedPoints([3, 4], rules);
}
if (finalPosition >= 5 && finalPosition <= 8) {
if (bracketTemplateId && SPLIT_5678_TEMPLATE_IDS.has(bracketTemplateId)) {
// AFL-style: two separate 2-team tiers within 58
if (finalPosition <= 6) return calculateAveragedPoints([5, 6], rules);
return calculateAveragedPoints([7, 8], rules);
}
// Standard: all four QF losers share one tier
return calculateAveragedPoints([5, 6, 7, 8], rules);
}
return 0;
}
/**
* Scoring patterns whose participants can share a final placement, so that a tied
* group splits the combined points of the positions it spans.
*
* Both patterns record ties by writing the SAME finalPosition to every tied
* participant, leaving the split to scoring time:
* - qualifying_points — finalizeQualifyingPoints groups participants by QP total
* - season_standings — processSeasonStandings gives a tied group the first
* placement in its range ("if 4 people tie for 5th they all get placement 5")
*
* playoff_bracket is deliberately absent: its ties are structural (both SF losers
* tie for 3rd) and calculateBracketPoints derives the span from the bracket shape
* rather than from a count of results.
*/
const TIE_SPLIT_PATTERNS = new Set(["qualifying_points", "season_standings"]);
/**
* Whether a scoring pattern needs a tie count to score a placement correctly.
*
* Callers must gate their tie-count lookups on this rather than testing a pattern
* name directly. A caller that hardcodes one pattern silently passes
* tiedParticipants: 1 for the other, which reads as "no tie" and awards the full
* placement value — the failure mode that left F1 ties unsplit.
*/
export function usesSharedPlacementSplit(
scoringPattern: string | null | undefined
): boolean {
return TIE_SPLIT_PATTERNS.has(scoringPattern ?? "");
}
/**
* Fantasy points earned by a single drafted participant, for any scoring pattern.
*
* This is the ONE place the bracket / tie-split / default cascade lives. It
* previously existed as a hand-rolled if/else at six call sites, two of which
* were missing the qualifying_points arm entirely — that divergence is what made
* a tied golfer worth 15 points on the team page and draft board but 7.5 in the
* standings. Every caller must route through here.
*
* @param finalPosition - Placement in the sports season (1-8 scores, 0 = none).
* @param scoringPattern - The sports season's scoringPattern column.
* @param rules - The fantasy season's point values.
* @param opts.bracketTemplateId - Required for playoff_bracket to pick the right
* tier structure (e.g. AFL/LLWS split 58 into two pairs).
* @param opts.tiedParticipants - Required for any pattern where
* usesSharedPlacementSplit is true: how many participants share this
* finalPosition across the WHOLE sports season, not just the ones that were
* drafted. Defaults to 1 (no tie).
*/
export function calculatePickPoints(
finalPosition: number,
scoringPattern: string | null | undefined,
rules: ScoringRules,
opts?: { bracketTemplateId?: string | null; tiedParticipants?: number }
): number {
if (scoringPattern === "playoff_bracket") {
return calculateBracketPoints(finalPosition, rules, opts?.bracketTemplateId ?? null);
}
if (usesSharedPlacementSplit(scoringPattern)) {
return calculateSharedPlacementPoints(
finalPosition,
opts?.tiedParticipants ?? 1,
rules
);
}
return calculateFantasyPoints(finalPosition, rules);
}
/**
* Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th]
* Useful for display purposes
*/
export function getScoringRulesArray(rules: ScoringRules): number[] {
return [
rules.pointsFor1st,
rules.pointsFor2nd,
rules.pointsFor3rd,
rules.pointsFor4th,
rules.pointsFor5th,
rules.pointsFor6th,
rules.pointsFor7th,
rules.pointsFor8th,
];
}