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
283 lines
9.8 KiB
TypeScript
283 lines
9.8 KiB
TypeScript
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 6th–8th 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 5–8 split into two separate pairs.
|
||
*
|
||
* Most brackets (standard single-elimination) have ONE tier covering positions 5–8:
|
||
* 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 5–8 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 5–8
|
||
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 5–8 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,
|
||
];
|
||
}
|