From 430526104c1ca5fabc9cf952eb9738bdc9df5266 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:29:37 +0000 Subject: [PATCH 1/4] Unify tie-split point math across every screen A participant tied for a scoring placement splits the combined points of the tied positions. Four code paths computed a pick's points, each re-implementing the same bracket/qualifying_points/default cascade, and two of them omitted the qualifying_points arm entirely. A golfer tied for 8th was therefore worth the full 15 points on the team page and draft board but the split 7.5 in the standings, so a team read 225 on one screen and 218 on another. Collapse the cascade into a single calculatePickPoints helper and route all six call sites through it, backed by one shared getSharedPlacementCounts loader replacing the two separate tie-count queries. Tie counts span every participant in the sports season, not just drafted ones, since an undrafted tie partner still halves the award. Also round split awards to the nearest whole point in calculateAveragedPoints. The /rules page states ties are "combined and split equally among them, rounded to the nearest whole point", and its own worked example rounds 18.33 down to 18, so this is nearest rather than ceiling. Season point values are integer columns, making this averaging the only source of fractional points; rounding here means the standings' 218 is now correct by construction rather than a display artifact, and per-pick values visibly sum to the team total. Existing assertions encoding the unrounded results are updated, and the rules page's two published examples are asserted directly so the code and the published rule cannot drift apart. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz --- app/lib/corona-states.ts | 32 +++- app/models/__tests__/draft-pick.test.ts | 2 +- .../__tests__/scoring-calculator.test.ts | 21 ++- app/models/__tests__/season-standings.test.ts | 12 +- .../__tests__/team-projected-score.test.ts | 2 +- app/models/draft-pick.ts | 51 ++----- app/models/participant-result.ts | 53 ++++++- app/models/scoring-calculator.ts | 142 ++++++++---------- app/models/scoring-rules.ts | 48 +++++- app/models/standings.ts | 34 +++-- .../$leagueId.draft-board.$seasonId.tsx | 8 + 11 files changed, 258 insertions(+), 147 deletions(-) diff --git a/app/lib/corona-states.ts b/app/lib/corona-states.ts index db35e03..fa0b112 100644 --- a/app/lib/corona-states.ts +++ b/app/lib/corona-states.ts @@ -1,4 +1,4 @@ -import { calculateFantasyPoints, calculateBracketPoints } from "~/models/scoring-rules"; +import { calculatePickPoints } from "~/models/scoring-rules"; export type CoronaState = | { type: "eliminated"; points: 0 } @@ -27,12 +27,22 @@ interface ScoringRules { pointsFor8th: number; } +/** + * Corona badge state for every pick on the draft board. + * + * @param sharedPlacementCountsBySportsSeason - sportsSeasonId → (finalPosition → + * how many participants share it), from getSharedPlacementCounts. Required for + * qualifying_points picks: a golfer tied for 8th earns the split award, and + * omitting this would show him at full value here while the standings show the + * split — the exact inconsistency this parameter exists to prevent. + */ export function computeCoronaStates( picks: PickEntry[], resultByParticipant: Map, bracketTemplateBySportsSeason: Map, scoringRules: ScoringRules, maxPoints: number, + sharedPlacementCountsBySportsSeason: Map>, ): Record { const coronaStates: Record = {}; @@ -50,13 +60,19 @@ export function computeCoronaStates( } if (result.finalPosition > 0) { - const isBracket = pick.scoringPattern === "playoff_bracket"; - const templateId = isBracket - ? (bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null) - : null; - const points = isBracket - ? calculateBracketPoints(result.finalPosition, scoringRules, templateId) - : calculateFantasyPoints(result.finalPosition, scoringRules); + const points = calculatePickPoints( + result.finalPosition, + pick.scoringPattern, + scoringRules, + { + bracketTemplateId: + bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null, + tiedParticipants: + sharedPlacementCountsBySportsSeason + .get(pick.participant.sportsSeasonId) + ?.get(result.finalPosition) ?? 1, + } + ); const brightness = maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0; coronaStates[pick.participant.id] = { type: "scored", brightness, points }; continue; diff --git a/app/models/__tests__/draft-pick.test.ts b/app/models/__tests__/draft-pick.test.ts index 781df83..2160e78 100644 --- a/app/models/__tests__/draft-pick.test.ts +++ b/app/models/__tests__/draft-pick.test.ts @@ -204,7 +204,7 @@ describe("getDraftedParticipantsWithPoints", () => { const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db); expect(result.get("ss-1")?.[0]).toMatchObject({ - earnedPoints: 62.5, // (75 + 50) / 2 + earnedPoints: 63, // (75 + 50) / 2 = 62.5 → 63 currentQP: null, }); }); diff --git a/app/models/__tests__/scoring-calculator.test.ts b/app/models/__tests__/scoring-calculator.test.ts index b9a307d..7d9525d 100644 --- a/app/models/__tests__/scoring-calculator.test.ts +++ b/app/models/__tests__/scoring-calculator.test.ts @@ -71,8 +71,8 @@ describe("Scoring Calculator", () => { it("should average points for three-way tie", () => { const points = calculateAveragedPoints([1, 2, 3], DEFAULT_SCORING); - // (100 + 70 + 50) / 3 = 73.33... - expect(points).toBeCloseTo(73.33, 2); + // (100 + 70 + 50) / 3 = 73.33... → 73 (nearest whole point) + expect(points).toBe(73); }); it("should handle single placement (no tie)", () => { @@ -109,7 +109,18 @@ describe("Scoring Calculator", () => { }); it("treats positions beyond 8th as zero at the scoring cutoff", () => { - expect(calculateSharedPlacementPoints(8, 2, DEFAULT_SCORING)).toBe(7.5); + // Two tied for 8th share 8th + 9th; 9th is outside the scoring range and + // contributes 0, so (15 + 0) / 2 = 7.5 → 8. + expect(calculateSharedPlacementPoints(8, 2, DEFAULT_SCORING)).toBe(8); + }); + + // The /rules page (app/routes/rules.tsx) publishes these two examples to + // players. They are asserted here so the code and the published rule cannot + // drift apart — note the second rounds DOWN, confirming "nearest whole + // point" rather than always rounding up. + it("matches the tie examples published on the rules page", () => { + expect(calculateSharedPlacementPoints(2, 2, DEFAULT_SCORING)).toBe(60); + expect(calculateSharedPlacementPoints(6, 3, DEFAULT_SCORING)).toBe(18); }); }); @@ -150,8 +161,8 @@ describe("Scoring Calculator", () => { describe("Edge Cases", () => { it("should handle all participants tying for 1st-8th", () => { const points = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING); - // (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 - expect(points).toBe(42.5); + // (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43 + expect(points).toBe(43); }); it("should handle placements with same point values", () => { diff --git a/app/models/__tests__/season-standings.test.ts b/app/models/__tests__/season-standings.test.ts index 45c1928..4d525d0 100644 --- a/app/models/__tests__/season-standings.test.ts +++ b/app/models/__tests__/season-standings.test.ts @@ -41,8 +41,8 @@ describe("Season Standings (F1 Pattern)", () => { it("should handle 3-way tie for 5th place", () => { // Three drivers tied for 5th share 5th, 6th, and 7th place points const points = calculateAveragedPoints([5, 6, 7], DEFAULT_SCORING); - // (25 + 25 + 15) / 3 = 21.67 - expect(points).toBeCloseTo(21.67, 2); + // (25 + 25 + 15) / 3 = 21.67 → 22 (nearest whole point) + expect(points).toBe(22); }); it("should handle 4-way tie for 1st place", () => { @@ -100,7 +100,7 @@ describe("Season Standings (F1 Pattern)", () => { // Verify tied positions get averaged const tiedPoints = calculateAveragedPoints([3, 4, 5], DEFAULT_SCORING); - expect(tiedPoints).toBeCloseTo(38.33, 2); // (50 + 40 + 25) / 3 + expect(tiedPoints).toBe(38); // (50 + 40 + 25) / 3 = 38.33 → 38 // Verify remaining positions expect(calculateFantasyPoints(6, DEFAULT_SCORING)).toBe(25); @@ -143,7 +143,7 @@ describe("Season Standings (F1 Pattern)", () => { // Tied positions const tied3rd = calculateAveragedPoints([3, 4], customScoring); - expect(tied3rd).toBe(67.5); // (75 + 60) / 2 + expect(tied3rd).toBe(68); // (75 + 60) / 2 = 67.5 → 68 (halves round up) }); }); @@ -151,8 +151,8 @@ describe("Season Standings (F1 Pattern)", () => { it("should handle all 8 positions tied", () => { // Extremely unlikely but mathematically possible const allTied = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING); - // (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 - expect(allTied).toBe(42.5); + // (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43 + expect(allTied).toBe(43); }); it("should handle only top 4 finishing (others DNF/DQ)", () => { diff --git a/app/models/__tests__/team-projected-score.test.ts b/app/models/__tests__/team-projected-score.test.ts index bbce292..a456704 100644 --- a/app/models/__tests__/team-projected-score.test.ts +++ b/app/models/__tests__/team-projected-score.test.ts @@ -344,7 +344,7 @@ describe("calculateTeamScore", () => { const result = await calculateTeamScore("team1", "season1", db); - expect(result.totalPoints).toBe(7.5); // (15 + 0) / 2 + expect(result.totalPoints).toBe(8); // (15 + 0) / 2 = 7.5 → 8 expect(result.participantsCompleted).toBe(1); expect(result.placementCounts[8]).toBe(1); }); diff --git a/app/models/draft-pick.ts b/app/models/draft-pick.ts index 7289046..59e8f3c 100644 --- a/app/models/draft-pick.ts +++ b/app/models/draft-pick.ts @@ -1,12 +1,11 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, inArray, asc } from "drizzle-orm"; +import { getScoringRules, calculatePickPoints } from "./scoring-rules"; import { - getScoringRules, - calculateFantasyPoints, - calculateBracketPoints, - calculateSharedPlacementPoints, -} from "./scoring-rules"; + getSharedPlacementCounts, + lookupSharedPlacementCount, +} from "./participant-result"; export async function createDraftPick(data: { seasonId: string; @@ -203,19 +202,10 @@ export async function getDraftedParticipantsWithPoints( } } - const qpSharedPlacementCounts = new Map>(); - if (finalizedQPSeasonIds.size > 0) { - const results = await db.query.seasonParticipantResults.findMany({ - where: inArray(schema.seasonParticipantResults.sportsSeasonId, [...finalizedQPSeasonIds]), - columns: { sportsSeasonId: true, finalPosition: true }, - }); - for (const row of results) { - if (row.finalPosition === null || row.finalPosition <= 0) continue; - const counts = qpSharedPlacementCounts.get(row.sportsSeasonId) ?? new Map(); - qpSharedPlacementCounts.set(row.sportsSeasonId, counts); - counts.set(row.finalPosition, (counts.get(row.finalPosition) ?? 0) + 1); - } - } + const qpSharedPlacementCounts = await getSharedPlacementCounts( + [...finalizedQPSeasonIds], + db + ); // Assemble result grouped by sportsSeasonId const result = new Map(); @@ -233,23 +223,14 @@ export async function getDraftedParticipantsWithPoints( currentQP = qpMap.get(id) ?? null; } else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) { // Finalized result for any pattern (including finalized QP seasons) - if (pattern === "playoff_bracket") { - earnedPoints = calculateBracketPoints( - resultRow.finalPosition, - scoringRules, - bracketTemplateMap.get(sportsSeasonId) ?? null - ); - } else if (pattern === "qualifying_points") { - const tiedParticipants = - qpSharedPlacementCounts.get(sportsSeasonId)?.get(resultRow.finalPosition) ?? 1; - earnedPoints = calculateSharedPlacementPoints( - resultRow.finalPosition, - tiedParticipants, - scoringRules - ); - } else { - earnedPoints = calculateFantasyPoints(resultRow.finalPosition, scoringRules); - } + earnedPoints = calculatePickPoints(resultRow.finalPosition, pattern, scoringRules, { + bracketTemplateId: bracketTemplateMap.get(sportsSeasonId) ?? null, + tiedParticipants: lookupSharedPlacementCount( + qpSharedPlacementCounts, + sportsSeasonId, + resultRow.finalPosition + ), + }); } const arr = result.get(sportsSeasonId) ?? []; diff --git a/app/models/participant-result.ts b/app/models/participant-result.ts index 4e36257..30fbca5 100644 --- a/app/models/participant-result.ts +++ b/app/models/participant-result.ts @@ -1,4 +1,4 @@ -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; @@ -76,6 +76,57 @@ export async function findParticipantResultsBySportsSeasonId( }); } +/** + * How many participants share each scoring placement, per sports season. + * + * Returns sportsSeasonId → (finalPosition → count). Used to split a tied + * placement's points across the tied participants (see calculatePickPoints). + * + * The count spans EVERY result in the sports season, not just drafted ones — a + * golfer tied for 8th with an undrafted player still only earns half the 8th + * place points, so narrowing this query to drafted participants would silently + * over-award. Positions <= 0 (no scoring placement) are excluded. + * + * Callers that look up a position with no entry should treat it as 1 (no tie). + */ +export async function getSharedPlacementCounts( + sportsSeasonIds: string[], + providedDb?: ReturnType +): Promise>> { + const counts = new Map>(); + if (sportsSeasonIds.length === 0) return counts; + + const db = providedDb || database(); + const rows = await db.query.seasonParticipantResults.findMany({ + where: inArray(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonIds), + columns: { sportsSeasonId: true, finalPosition: true }, + }); + + for (const row of rows) { + if (row.finalPosition === null || row.finalPosition <= 0) continue; + let bySeason = counts.get(row.sportsSeasonId); + if (!bySeason) { + bySeason = new Map(); + counts.set(row.sportsSeasonId, bySeason); + } + bySeason.set(row.finalPosition, (bySeason.get(row.finalPosition) ?? 0) + 1); + } + + return counts; +} + +/** + * Convenience lookup over getSharedPlacementCounts' result. Missing entries mean + * no other participant shares the placement, so the tie count is 1. + */ +export function lookupSharedPlacementCount( + counts: Map>, + sportsSeasonId: string, + finalPosition: number +): number { + return counts.get(sportsSeasonId)?.get(finalPosition) ?? 1; +} + export async function updateParticipantResult( id: string, data: Partial diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index cf6ef02..7b3ef84 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -1,12 +1,8 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, inArray } from "drizzle-orm"; -import { - getScoringRules, - calculateFantasyPoints, - calculateBracketPoints, - calculateSharedPlacementPoints, -} from "./scoring-rules"; +import { getScoringRules, calculatePickPoints, type ScoringRules } from "./scoring-rules"; +import { getSharedPlacementCounts } from "./participant-result"; import { getSeasonResults } from "./participant-season-result"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord"; @@ -595,6 +591,11 @@ async function upsertParticipantResult( } } +/** + * Lazily populates `cache` (sportsSeasonId → placement → count) one sports + * season at a time, then reads the tie count for `finalPosition`. The counting + * itself lives in getSharedPlacementCounts so every screen shares one definition. + */ async function getSharedPlacementCount( sportsSeasonId: string, finalPosition: number, @@ -602,17 +603,8 @@ async function getSharedPlacementCount( cache: Map> ): Promise { if (!cache.has(sportsSeasonId)) { - const results = await db.query.seasonParticipantResults.findMany({ - where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId), - columns: { finalPosition: true }, - }); - const counts = new Map(); - for (const result of results) { - if (result.finalPosition !== null && result.finalPosition > 0) { - counts.set(result.finalPosition, (counts.get(result.finalPosition) ?? 0) + 1); - } - } - cache.set(sportsSeasonId, counts); + const counts = await getSharedPlacementCounts([sportsSeasonId], db); + cache.set(sportsSeasonId, counts.get(sportsSeasonId) ?? new Map()); } return cache.get(sportsSeasonId)?.get(finalPosition) ?? 1; @@ -1376,27 +1368,22 @@ export async function calculateTeamScore( const result = pick.participant.results[0]; if (result && result.finalPosition !== null && result.finalPosition > 0) { - const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket"; - const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points"; - let points: number; - if (isBracket) { - const templateId = await getBracketTemplate(pick.participant.sportsSeasonId); - points = calculateBracketPoints(result.finalPosition, scoringRules, templateId); - } else if (isQualifyingPoints) { - const tiedParticipants = await getSharedPlacementCount( - pick.participant.sportsSeasonId, - result.finalPosition, - db, - sharedPlacementCountCache - ); - points = calculateSharedPlacementPoints( - result.finalPosition, - tiedParticipants, - scoringRules - ); - } else { - points = calculateFantasyPoints(result.finalPosition, scoringRules); - } + const pattern = pick.participant.sportsSeason?.scoringPattern; + const points = calculatePickPoints(result.finalPosition, pattern, scoringRules, { + bracketTemplateId: + pattern === "playoff_bracket" + ? await getBracketTemplate(pick.participant.sportsSeasonId) + : null, + tiedParticipants: + pattern === "qualifying_points" + ? await getSharedPlacementCount( + pick.participant.sportsSeasonId, + result.finalPosition, + db, + sharedPlacementCountCache + ) + : 1, + }); totalPoints += points; // All participants with a valid position count toward the placement tiebreaker, @@ -1483,57 +1470,52 @@ export async function calculateTeamProjectedScore( return templateId; } + // Points for one pick's placement. Identical for finalized results and for the + // provisional floor of a still-alive participant — only what we do with the + // number afterwards differs. `rules` re-binds the null-checked scoringRules + // because the hoisted declaration below cannot see that narrowing. + const rules: ScoringRules = scoringRules; + async function pointsForPick( + sportsSeasonId: string, + pattern: string | null | undefined, + finalPosition: number + ): Promise { + return calculatePickPoints(finalPosition, pattern, rules, { + bracketTemplateId: + pattern === "playoff_bracket" ? await getBracketTemplate(sportsSeasonId) : null, + tiedParticipants: + pattern === "qualifying_points" + ? await getSharedPlacementCount( + sportsSeasonId, + finalPosition, + db, + sharedPlacementCountCache + ) + : 1, + }); + } + // Separate finished vs unfinished participants for (const pick of picks) { const result = pick.participant.results[0]; - const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket"; - const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points"; + const pattern = pick.participant.sportsSeason?.scoringPattern; if (result && result.finalPosition !== null && !result.isPartialScore) { - // Participant is fully finalized — use bracket-averaged points - let points: number; - if (isBracket) { - const templateId = await getBracketTemplate(pick.participant.sportsSeasonId); - points = calculateBracketPoints(result.finalPosition, scoringRules, templateId); - } else if (isQualifyingPoints) { - const tiedParticipants = await getSharedPlacementCount( - pick.participant.sportsSeasonId, - result.finalPosition, - db, - sharedPlacementCountCache - ); - points = calculateSharedPlacementPoints( - result.finalPosition, - tiedParticipants, - scoringRules - ); - } else { - points = calculateFantasyPoints(result.finalPosition, scoringRules); - } - actualPoints += points; + // Participant is fully finalized + actualPoints += await pointsForPick( + pick.participant.sportsSeasonId, + pattern, + result.finalPosition + ); participantsFinished++; } else if (result && result.finalPosition !== null && result.isPartialScore) { // Still alive with a provisional floor — count floor as actual, EV for projection. // Note: NOT incremented in participantsFinished; these participants are still competing. - const templateId = isBracket ? await getBracketTemplate(pick.participant.sportsSeasonId) : null; - let floorPoints: number; - if (isBracket) { - floorPoints = calculateBracketPoints(result.finalPosition, scoringRules, templateId); - } else if (isQualifyingPoints) { - const tiedParticipants = await getSharedPlacementCount( - pick.participant.sportsSeasonId, - result.finalPosition, - db, - sharedPlacementCountCache - ); - floorPoints = calculateSharedPlacementPoints( - result.finalPosition, - tiedParticipants, - scoringRules - ); - } else { - floorPoints = calculateFantasyPoints(result.finalPosition, scoringRules); - } + const floorPoints = await pointsForPick( + pick.participant.sportsSeasonId, + pattern, + result.finalPosition + ); actualPoints += floorPoints; // EV already accounts for their full projected value, so subtract floor to avoid // double-counting when we do actualPoints + evSum below diff --git a/app/models/scoring-rules.ts b/app/models/scoring-rules.ts index a05ed8f..26d4816 100644 --- a/app/models/scoring-rules.ts +++ b/app/models/scoring-rules.ts @@ -90,6 +90,13 @@ export function calculateFantasyPoints( * * 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[], @@ -101,7 +108,9 @@ export function calculateAveragedPoints( return sum + calculateFantasyPoints(placement, rules); }, 0); - return total / placements.length; + // Epsilon guard mirrors roundQualifyingPoints — keeps values that are exactly + // representable-adjacent (e.g. 18.499999999999996) from rounding the wrong way. + return Math.round(total / placements.length + Number.EPSILON); } /** @@ -190,6 +199,43 @@ export function calculateBracketPoints( return 0; } +/** + * Fantasy points earned by a single drafted participant, for any scoring pattern. + * + * This is the ONE place the bracket / qualifying_points / 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 qualifying_points: 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 (scoringPattern === "qualifying_points") { + 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 diff --git a/app/models/standings.ts b/app/models/standings.ts index 97acc5f..6416601 100644 --- a/app/models/standings.ts +++ b/app/models/standings.ts @@ -2,7 +2,11 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and } from "drizzle-orm"; import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings"; -import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules"; +import { calculatePickPoints } from "~/models/scoring-rules"; +import { + getSharedPlacementCounts, + lookupSharedPlacementCount, +} from "~/models/participant-result"; import { logger } from "~/lib/logger"; import { getParticipantEV } from "./participant-expected-value"; import { calculateEV } from "~/services/ev-calculator"; @@ -157,6 +161,14 @@ export async function getTeamScoreBreakdown( pointsFor8th: season.pointsFor8th, }; + // Tie counts for qualifying_points picks, so a golfer tied for 8th is worth the + // split award here exactly as it is in calculateTeamScore. Counted across every + // participant in the sports season — an undrafted tie partner still halves it. + const sharedPlacementCounts = await getSharedPlacementCounts( + [...new Set(picks.map((p) => p.participant.sportsSeasonId))], + db + ); + // Cache bracket template IDs per sports season (same approach as calculateTeamScore) const bracketTemplateCache = new Map(); async function getBracketTemplate(sportsSeasonId: string): Promise { @@ -176,7 +188,7 @@ export async function getTeamScoreBreakdown( const pickBreakdown = await Promise.all( picks.map(async (pick) => { const result = pick.participant.results[0]; - const isBracket = pick.participant.sportsSeason.scoringPattern === "playoff_bracket"; + const pattern = pick.participant.sportsSeason.scoringPattern; let points = 0; let projectedPoints: number | null = null; @@ -199,13 +211,17 @@ export async function getTeamScoreBreakdown( }; if (result && result.finalPosition !== null && result.finalPosition > 0) { - // Calculate points using bracket-averaged scoring for bracket sports - if (isBracket) { - const templateId = await getBracketTemplate(pick.participant.sportsSeasonId); - points = calculateBracketPoints(result.finalPosition, scoringRules, templateId); - } else { - points = calculateFantasyPoints(result.finalPosition, scoringRules); - } + points = calculatePickPoints(result.finalPosition, pattern, scoringRules, { + bracketTemplateId: + pattern === "playoff_bracket" + ? await getBracketTemplate(pick.participant.sportsSeasonId) + : null, + tiedParticipants: lookupSharedPlacementCount( + sharedPlacementCounts, + pick.participant.sportsSeasonId, + result.finalPosition + ), + }); if (result.isPartialScore) { // Still alive with a floor position — use EV for projected since they can advance diff --git a/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx b/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx index d1ed2ff..9379560 100644 --- a/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx @@ -12,6 +12,7 @@ import { Button } from "~/components/ui/button"; import { ArrowLeft } from "lucide-react"; import logomarkUrl from "../../../public/logomark.svg?url"; import { computeCoronaStates } from "~/lib/corona-states"; +import { getSharedPlacementCounts } from "~/models/participant-result"; import type { CoronaState } from "~/components/draft/DraftPickCell"; import type { Route } from "./+types/$leagueId.draft-board.$seasonId"; @@ -157,12 +158,19 @@ export async function loader(args: Route.LoaderArgs) { pointsFor8th: season.pointsFor8th, }; + // Counted across every participant in each sports season, not just the + // drafted ones above — an undrafted player tied for the same placement still + // halves the award. + const sharedPlacementCountsBySportsSeason = + await getSharedPlacementCounts(sportsSeasonIds, db); + coronaStates = computeCoronaStates( draftPicks, resultByParticipant, bracketTemplateBySportsSeason, scoringRules, season.pointsFor1st, + sharedPlacementCountsBySportsSeason, ); } -- 2.45.3 From 75798d971126199a7778fce9c1388d489ba8f94a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:38:26 +0000 Subject: [PATCH 2/4] Record final-placement points in the score-events ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qualifying_points (golf, tennis, CS2) and season_standings (F1) award all of their points in one step at finalization, so they produce no per-match deltas. recordMatchScoreEvents is bracket-only and fires from match processing, which meant these sports were never written to team_score_events at all and silently never appeared in Recent Scores. Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints and processSeasonStandings. It writes one row per team at the ledger's event-level grain, carrying that team's summed award and every contributing participant, with points from calculatePickPoints so a tied golfer contributes the same split award the standings show. The row is anchored to a real scoring event rather than a null one: the event-level unique index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as distinct, so a null anchor would duplicate rows on every re-finalization instead of upserting. When no anchor can be resolved the ledger write is skipped, which leaves standings unaffected. Adds regression coverage for the two screens that had diverged — getTeamScoreBreakdown and computeCoronaStates — including an assertion that the team page's actualPoints equals calculateTeamScore's totalPoints for the same roster, and cases proving undrafted participants still count toward a tie span. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz --- app/lib/__tests__/corona-states.test.ts | 122 ++++++++++++ .../__tests__/standings-breakdown.test.ts | 178 ++++++++++++++++++ .../__tests__/team-score-events.test.ts | 177 ++++++++++++++++- app/models/scoring-calculator.ts | 19 +- app/models/team-score-events.ts | 178 +++++++++++++++++- 5 files changed, 669 insertions(+), 5 deletions(-) create mode 100644 app/lib/__tests__/corona-states.test.ts create mode 100644 app/models/__tests__/standings-breakdown.test.ts diff --git a/app/lib/__tests__/corona-states.test.ts b/app/lib/__tests__/corona-states.test.ts new file mode 100644 index 0000000..44d71ed --- /dev/null +++ b/app/lib/__tests__/corona-states.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { computeCoronaStates } from "../corona-states"; + +const RULES = { + pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40, + pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15, +}; + +function pick(id: string, scoringPattern: string | null, sportsSeasonId = "ss-1") { + return { participant: { id, sportsSeasonId }, scoringPattern }; +} + +function results( + rows: Array<{ participantId: string; finalPosition: number | null; isPartialScore?: boolean }> +) { + return new Map( + rows.map((r) => [r.participantId, { isPartialScore: false, ...r }]) + ); +} + +describe("computeCoronaStates", () => { + const noTies = new Map>(); + + it("splits points for a tied qualifying_points pick", () => { + // Rahm tied for 8th. 9th is outside the scoring range and contributes 0, + // so (15 + 0) / 2 = 7.5 → 8. Before the shared helper this path awarded the + // full 15 while the standings awarded the split — the reported bug. + const states = computeCoronaStates( + [pick("rahm", "qualifying_points")], + results([{ participantId: "rahm", finalPosition: 8 }]), + new Map(), + RULES, + RULES.pointsFor1st, + new Map([["ss-1", new Map([[8, 2]])]]) + ); + + expect(states.rahm).toEqual({ + type: "scored", + points: 8, + brightness: 8 / 100, + }); + }); + + it("awards the full value when the placement is untied", () => { + const states = computeCoronaStates( + [pick("rahm", "qualifying_points")], + results([{ participantId: "rahm", finalPosition: 8 }]), + new Map(), + RULES, + RULES.pointsFor1st, + noTies + ); + + expect(states.rahm).toMatchObject({ type: "scored", points: 15 }); + }); + + it("still averages bracket tiers via the bracket template", () => { + const states = computeCoronaStates( + [pick("team-a", "playoff_bracket")], + results([{ participantId: "team-a", finalPosition: 5 }]), + new Map([["ss-1", null]]), + RULES, + RULES.pointsFor1st, + noTies + ); + + // Four QF losers share 5th-8th: (25 + 25 + 15 + 15) / 4 = 20 + expect(states["team-a"]).toMatchObject({ type: "scored", points: 20 }); + }); + + it("scores other patterns straight off the placement", () => { + const states = computeCoronaStates( + [pick("driver", "season_standings")], + results([{ participantId: "driver", finalPosition: 2 }]), + new Map(), + RULES, + RULES.pointsFor1st, + noTies + ); + + expect(states.driver).toMatchObject({ type: "scored", points: 70 }); + }); + + it("marks picks with no result as pending", () => { + const states = computeCoronaStates( + [pick("rahm", "qualifying_points")], + results([]), + new Map(), + RULES, + RULES.pointsFor1st, + noTies + ); + + expect(states.rahm).toEqual({ type: "pending" }); + }); + + it("marks a finalized zero placement as eliminated", () => { + const states = computeCoronaStates( + [pick("rahm", "qualifying_points")], + results([{ participantId: "rahm", finalPosition: 0 }]), + new Map(), + RULES, + RULES.pointsFor1st, + noTies + ); + + expect(states.rahm).toEqual({ type: "eliminated", points: 0 }); + }); + + it("keeps brightness within range when a tier exceeds maxPoints", () => { + const states = computeCoronaStates( + [pick("champ", "season_standings")], + results([{ participantId: "champ", finalPosition: 1 }]), + new Map(), + RULES, + 10, // deliberately below the 1st-place award + noTies + ); + + expect(states.champ).toMatchObject({ brightness: 1 }); + }); +}); diff --git a/app/models/__tests__/standings-breakdown.test.ts b/app/models/__tests__/standings-breakdown.test.ts new file mode 100644 index 0000000..5b178a3 --- /dev/null +++ b/app/models/__tests__/standings-breakdown.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * Regression tests for getTeamScoreBreakdown — the team detail page's points. + * + * This path used to branch only on `isBracket`, so a qualifying_points + * participant tied for a placement was awarded the FULL placement value here + * while calculateTeamScore (which writes team_standings) awarded the split. A + * team therefore read 225 on its team page and 218 in the standings. + * + * The load-bearing assertion is the last one: the breakdown's actualPoints must + * equal calculateTeamScore's totalPoints for the same fixture. Anything that + * makes these two disagree reintroduces the bug. + */ + +vi.mock("~/services/ev-calculator", () => ({ + calculateEV: vi.fn(), +})); + +vi.mock("../participant-expected-value", () => ({ + getParticipantEV: vi.fn(), +})); + +import { getTeamScoreBreakdown } from "../standings"; +import { calculateTeamScore } from "../scoring-calculator"; +import { getParticipantEV } from "../participant-expected-value"; + +const SCORING = { + pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40, + pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15, +}; + +function makePick( + participantId: string, + name: string, + scoringPattern: string, + finalPosition: number | null, + { sportsSeasonId = "ss-1", isPartialScore = false, pickNumber = 1 } = {} +) { + return { + pickNumber, + round: 1, + participant: { + id: participantId, + name, + sportsSeasonId, + results: finalPosition === null ? [] : [{ finalPosition, isPartialScore }], + sportsSeason: { scoringPattern, sport: { name: "Golf" } }, + }, + }; +} + +/** + * @param extraResults results for participants NOBODY drafted. They still count + * toward a tie span, which is exactly why the tie query cannot be narrowed to + * drafted participants. + */ +function makeDb( + picks: ReturnType[], + extraResults: { sportsSeasonId: string; finalPosition: number }[] = [] +) { + const seasonResults = [ + ...picks.flatMap((pick) => + pick.participant.results + .filter((r) => r.finalPosition !== null) + .map((r) => ({ + sportsSeasonId: pick.participant.sportsSeasonId, + finalPosition: r.finalPosition, + })) + ), + ...extraResults, + ]; + + return { + query: { + seasons: { findFirst: vi.fn().mockResolvedValue(SCORING) }, + draftPicks: { findMany: vi.fn().mockResolvedValue(picks) }, + scoringEvents: { findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }) }, + seasonParticipantResults: { findMany: vi.fn().mockResolvedValue(seasonResults) }, + teams: { findFirst: vi.fn().mockResolvedValue({ id: "team-1", name: "Mike" }) }, + }, + } as any; +} + +describe("getTeamScoreBreakdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getParticipantEV).mockResolvedValue(null as any); + }); + + it("splits a tied qualifying_points placement", async () => { + // Rahm tied for 8th with an undrafted player: (15 + 0) / 2 = 7.5 → 8. + const db = makeDb( + [makePick("rahm", "Jon Rahm", "qualifying_points", 8)], + [{ sportsSeasonId: "ss-1", finalPosition: 8 }] + ); + + const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db); + + expect(breakdown?.picks[0]).toMatchObject({ + participant: expect.objectContaining({ name: "Jon Rahm" }), + finalPosition: 8, + points: 8, + }); + expect(breakdown?.actualPoints).toBe(8); + }); + + it("awards the full placement value when untied", async () => { + const db = makeDb([makePick("rahm", "Jon Rahm", "qualifying_points", 8)]); + + const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db); + + expect(breakdown?.picks[0].points).toBe(15); + expect(breakdown?.actualPoints).toBe(15); + }); + + it("counts undrafted participants in the tie span", async () => { + // Three-way tie for 6th where two of the three went undrafted: + // (25 + 15 + 15) / 3 = 18.33 → 18, the rules page's published example. + const db = makeDb( + [makePick("golfer", "Tied Golfer", "qualifying_points", 6)], + [ + { sportsSeasonId: "ss-1", finalPosition: 6 }, + { sportsSeasonId: "ss-1", finalPosition: 6 }, + ] + ); + + const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db); + + expect(breakdown?.picks[0].points).toBe(18); + }); + + it("still averages bracket tiers", async () => { + const db = makeDb([makePick("team-a", "Team A", "playoff_bracket", 5)]); + + const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db); + + // Four QF losers share 5th-8th: (25 + 25 + 15 + 15) / 4 = 20 + expect(breakdown?.picks[0].points).toBe(20); + }); + + it("leaves picks without a result at zero points", async () => { + const db = makeDb([makePick("pending", "Pending Golfer", "qualifying_points", null)]); + + const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db); + + expect(breakdown?.picks[0]).toMatchObject({ points: 0, isComplete: false }); + expect(breakdown?.actualPoints).toBe(0); + }); + + it("agrees with calculateTeamScore on the same roster", async () => { + // The reported bug in miniature: a tied golfer plus untied picks. Both + // functions read the same fixture, so any divergence is a real regression. + const roster = () => [ + makePick("rahm", "Jon Rahm", "qualifying_points", 8, { pickNumber: 1 }), + makePick("scheffler", "Scottie Scheffler", "qualifying_points", 1, { pickNumber: 2 }), + makePick("team-a", "Team A", "playoff_bracket", 5, { + sportsSeasonId: "ss-2", + pickNumber: 3, + }), + ]; + const undrafted = [{ sportsSeasonId: "ss-1", finalPosition: 8 }]; + + const breakdown = await getTeamScoreBreakdown( + "team-1", + "season-1", + makeDb(roster(), undrafted) + ); + const score = await calculateTeamScore( + "team-1", + "season-1", + makeDb(roster(), undrafted) + ); + + expect(breakdown?.actualPoints).toBe(score.totalPoints); + expect(breakdown?.actualPoints).toBe(128); // 8 (T8) + 100 (1st) + 20 (T5-8) + }); +}); diff --git a/app/models/__tests__/team-score-events.test.ts b/app/models/__tests__/team-score-events.test.ts index 7f543e6..0f4f438 100644 --- a/app/models/__tests__/team-score-events.test.ts +++ b/app/models/__tests__/team-score-events.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("~/lib/logger", () => ({ - logger: { error: vi.fn() }, + logger: { error: vi.fn(), warn: vi.fn() }, })); // ── DB mock helpers ──────────────────────────────────────────────────────── @@ -16,7 +16,13 @@ function makeInsertChain() { interface MakeDbOpts { sportsSeason?: { sport: { name: string } } | null; seasonSports?: { seasonId: string }[]; - picks?: { teamId: string; seasonId: string }[]; + picks?: { teamId: string; seasonId: string; participantId?: string }[]; + scoringEvent?: { id: string; name: string } | null; + participantResults?: { + participantId: string; + finalPosition: number | null; + sportsSeasonId?: string; + }[]; seasons?: { id: string; pointsFor1st: number; pointsFor2nd: number; pointsFor3rd: number; @@ -41,6 +47,8 @@ function makeDb(opts: MakeDbOpts = {}) { seasons = [], scoreEventRows = [], participantRows = [], + scoringEvent = null, + participantResults = [], } = opts; const chain = makeInsertChain(); @@ -67,13 +75,24 @@ function makeDb(opts: MakeDbOpts = {}) { seasonParticipants: { findMany: vi.fn().mockResolvedValue(participantRows), }, + scoringEvents: { + findFirst: vi.fn().mockResolvedValue(scoringEvent), + }, + seasonParticipantResults: { + findMany: vi.fn().mockResolvedValue(participantResults), + }, }, } as any, chain, }; } -import { recordTeamScoreEvent, recordMatchScoreEvents, getRecentTeamScoreEvents } from "../team-score-events"; +import { + recordTeamScoreEvent, + recordMatchScoreEvents, + recordFinalPlacementScoreEvents, + getRecentTeamScoreEvents, +} from "../team-score-events"; const BASE_PARAMS = { teamId: "team-1", @@ -358,3 +377,155 @@ describe("getRecentTeamScoreEvents", () => { expect(result[0].participants).toEqual([]); }); }); + +describe("recordFinalPlacementScoreEvents", () => { + beforeEach(() => vi.clearAllMocks()); + + const RULES = { + id: "season-1", + pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40, + pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15, + }; + + function makeQpDb(overrides: Partial = {}) { + return makeDb({ + sportsSeason: { scoringPattern: "qualifying_points", sport: { name: "Golf" } } as any, + scoringEvent: { id: "event-9", name: "The Open" }, + seasonSports: [{ seasonId: "season-1" }], + seasons: [RULES], + ...overrides, + // Tie counts are grouped by sportsSeasonId, so result rows must carry it. + participantResults: (overrides.participantResults ?? []).map((r) => ({ + sportsSeasonId: "ss-1", + ...r, + })), + }); + } + + it("writes one row per team with the summed award and its participants", async () => { + const { db, chain } = makeQpDb({ + participantResults: [ + { participantId: "rahm", finalPosition: 8 }, + { participantId: "scheffler", finalPosition: 1 }, + ], + picks: [ + { teamId: "team-1", seasonId: "season-1", participantId: "rahm" }, + { teamId: "team-1", seasonId: "season-1", participantId: "scheffler" }, + ], + }); + + await recordFinalPlacementScoreEvents( + { sportsSeasonId: "ss-1", eventName: "Final Standings" }, + db + ); + + expect(chain.insert).toHaveBeenCalledTimes(1); + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: "team-1", + seasonId: "season-1", + scoringEventId: "event-9", + scoringEventName: "Final Standings", + sportName: "Golf", + matchId: null, + pointsDelta: "115", // 100 (1st) + 15 (8th, untied) + participantIds: ["rahm", "scheffler"], + }) + ); + }); + + it("awards the split value when the placement is tied", async () => { + const { db, chain } = makeQpDb({ + // Rahm ties for 8th with an UNDRAFTED player — the tie still halves it. + participantResults: [ + { participantId: "rahm", finalPosition: 8 }, + { participantId: "undrafted-guy", finalPosition: 8 }, + ], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + // (15 + 0) / 2 = 7.5 → 8, matching what the standings show. + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ pointsDelta: "8", participantIds: ["rahm"] }) + ); + }); + + it("keeps each team's award separate", async () => { + const { db, chain } = makeQpDb({ + participantResults: [ + { participantId: "rahm", finalPosition: 8 }, + { participantId: "scheffler", finalPosition: 1 }, + ], + picks: [ + { teamId: "team-1", seasonId: "season-1", participantId: "rahm" }, + { teamId: "team-2", seasonId: "season-1", participantId: "scheffler" }, + ], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + expect(chain.insert).toHaveBeenCalledTimes(2); + const deltasByTeam = Object.fromEntries( + chain.values.mock.calls.map(([v]: any[]) => [v.teamId, v.pointsDelta]) + ); + expect(deltasByTeam).toEqual({ "team-1": "15", "team-2": "100" }); + }); + + it("upserts on the event-level target so re-finalizing does not duplicate", async () => { + const { db, chain } = makeQpDb({ + participantResults: [{ participantId: "rahm", finalPosition: 8 }], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + const conflictArg = chain.onConflictDoUpdate.mock.calls[0][0]; + expect(conflictArg.target).toHaveLength(3); + expect(conflictArg.targetWhere).toBeDefined(); + expect(conflictArg.set).toMatchObject({ pointsDelta: "15" }); + }); + + it("skips the write when no scoring event can anchor the row", async () => { + // A null scoringEventId would defeat the partial unique index, since + // Postgres treats NULLs as distinct — better to skip than duplicate. + const { db, chain } = makeQpDb({ + scoringEvent: null, + participantResults: [{ participantId: "rahm", finalPosition: 8 }], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + expect(chain.insert).not.toHaveBeenCalled(); + }); + + it("ignores non-scoring placements", async () => { + const { db, chain } = makeQpDb({ + participantResults: [ + { participantId: "rahm", finalPosition: 0 }, + { participantId: "other", finalPosition: null }, + ], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + expect(chain.insert).not.toHaveBeenCalled(); + }); + + it("scores season_standings placements with the shared tier averaging", async () => { + const { db, chain } = makeQpDb({ + sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any, + participantResults: [{ participantId: "driver", finalPosition: 3 }], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ sportName: "F1", pointsDelta: "50" }) + ); + }); +}); diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 7b3ef84..a350e8f 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -12,7 +12,10 @@ import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff- import { getUserDisplayName } from "~/models/user"; import { findDiscordIdsByUserIds } from "~/models/account"; import { createDailySnapshot } from "~/models/standings"; -import { recordMatchScoreEvents } from "~/models/team-score-events"; +import { + recordMatchScoreEvents, + recordFinalPlacementScoreEvents, +} from "~/models/team-score-events"; import { logger } from "~/lib/logger"; import { getEventResults } from "./event-result"; import { @@ -1170,6 +1173,13 @@ export async function finalizeQualifyingPoints( }) .where(eq(schema.sportsSeasons.id, sportsSeasonId)); + // Ledger the placement points so this season shows up in Recent Scores — + // QP seasons award everything here, with no per-match deltas to record. + await recordFinalPlacementScoreEvents( + { sportsSeasonId, eventName: "Final Standings" }, + db + ); + // Trigger recalculation for all affected leagues await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" }); @@ -1273,6 +1283,13 @@ export async function processSeasonStandings( } } + // Ledger the placement points — season_standings has the same one-shot award + // shape as qualifying_points and was likewise absent from Recent Scores. + await recordFinalPlacementScoreEvents( + { sportsSeasonId, eventName: "Season Complete" }, + db + ); + // Trigger recalculation for all affected leagues await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" }); diff --git a/app/models/team-score-events.ts b/app/models/team-score-events.ts index 077772c..6bdcb6d 100644 --- a/app/models/team-score-events.ts +++ b/app/models/team-score-events.ts @@ -1,7 +1,15 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, inArray, desc, sql, and } from "drizzle-orm"; -import { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules"; +import { + calculateBracketPoints, + calculatePickPoints, + type ScoringRules, +} from "~/models/scoring-rules"; +import { + getSharedPlacementCounts, + lookupSharedPlacementCount, +} from "~/models/participant-result"; import { findParticipantNamesByIds } from "~/models/season-participant"; import { logger } from "~/lib/logger"; @@ -190,6 +198,174 @@ export async function recordMatchScoreEvents( } } +/** + * Records ledger rows for a sports season whose final placements have just been + * assigned — qualifying_points (golf, tennis, CS2) and season_standings (F1). + * + * These patterns award all of their points in one step at finalization, so they + * have no per-match deltas and were never written to team_score_events at all: + * recordMatchScoreEvents is bracket-only and fires from match processing. The + * effect was that golf results silently never appeared in Recent Scores. + * + * One row per team (matching the event-level unique index), carrying that team's + * summed award and every participant that contributed. Points come from + * calculatePickPoints, so a tied golfer contributes the same split award the + * standings show. + * + * `eventId` anchors the row and MUST resolve to a real scoring event: the + * event-level unique index is (teamId, seasonId, scoringEventId) and Postgres + * treats NULLs as distinct, so a null anchor would silently duplicate rows on + * every re-finalization instead of upserting. If no anchor can be found the + * ledger write is skipped rather than risking duplicates — standings are + * unaffected either way. + */ +export async function recordFinalPlacementScoreEvents( + params: { + sportsSeasonId: string; + eventId?: string | null; + eventName?: string | null; + }, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + const sportsSeason = await db.query.sportsSeasons.findFirst({ + where: eq(schema.sportsSeasons.id, params.sportsSeasonId), + columns: { id: true, scoringPattern: true }, + with: { sport: { columns: { name: true } } }, + }); + if (!sportsSeason) return; + + // Resolve the anchor event: the caller's, else the most recently completed + // event for this sports season. + let eventId = params.eventId ?? null; + let eventName = params.eventName ?? null; + if (!eventId) { + const anchor = await db.query.scoringEvents.findFirst({ + where: eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId), + columns: { id: true, name: true }, + orderBy: [desc(schema.scoringEvents.completedAt), desc(schema.scoringEvents.createdAt)], + }); + if (!anchor) { + logger.warn( + `[TeamScoreEvents] No scoring event to anchor final placements for sports season ${params.sportsSeasonId}; skipping ledger write` + ); + return; + } + eventId = anchor.id; + eventName = eventName ?? anchor.name; + } + if (!eventId) return; + + // Scoring placements for this sports season, plus the tie spans they imply. + const results = await db.query.seasonParticipantResults.findMany({ + where: eq(schema.seasonParticipantResults.sportsSeasonId, params.sportsSeasonId), + columns: { participantId: true, finalPosition: true }, + }); + const positionByParticipantId = new Map(); + for (const row of results) { + if (row.finalPosition !== null && row.finalPosition > 0) { + positionByParticipantId.set(row.participantId, row.finalPosition); + } + } + if (positionByParticipantId.size === 0) return; + + const sharedPlacementCounts = await getSharedPlacementCounts([params.sportsSeasonId], db); + + const seasonSports = await db.query.seasonSports.findMany({ + where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId), + columns: { seasonId: true }, + }); + if (seasonSports.length === 0) return; + const seasonIds = seasonSports.map((ss) => ss.seasonId); + + const picks = await db.query.draftPicks.findMany({ + where: and( + inArray(schema.draftPicks.seasonId, seasonIds), + inArray(schema.draftPicks.participantId, [...positionByParticipantId.keys()]) + ), + columns: { teamId: true, seasonId: true, participantId: true }, + }); + if (picks.length === 0) return; + + const seasonRows = await db.query.seasons.findMany({ + where: inArray(schema.seasons.id, seasonIds), + columns: { + id: true, + pointsFor1st: true, pointsFor2nd: true, pointsFor3rd: true, + pointsFor4th: true, pointsFor5th: true, pointsFor6th: true, + pointsFor7th: true, pointsFor8th: true, + }, + }); + const rulesBySeasonId = new Map( + seasonRows.map((s) => [s.id, { + pointsFor1st: s.pointsFor1st, pointsFor2nd: s.pointsFor2nd, + pointsFor3rd: s.pointsFor3rd, pointsFor4th: s.pointsFor4th, + pointsFor5th: s.pointsFor5th, pointsFor6th: s.pointsFor6th, + pointsFor7th: s.pointsFor7th, pointsFor8th: s.pointsFor8th, + }]) + ); + + // Accumulate per (season, team) — the ledger's event-level grain. + const byTeam = new Map< + string, + { teamId: string; seasonId: string; points: number; participantIds: string[] } + >(); + + for (const pick of picks) { + const rules = rulesBySeasonId.get(pick.seasonId); + const finalPosition = positionByParticipantId.get(pick.participantId); + if (!rules || finalPosition === undefined) continue; + + const points = calculatePickPoints( + finalPosition, + sportsSeason.scoringPattern, + rules, + { + tiedParticipants: lookupSharedPlacementCount( + sharedPlacementCounts, + params.sportsSeasonId, + finalPosition + ), + } + ); + if (points <= 0) continue; + + const key = `${pick.seasonId}:${pick.teamId}`; + const entry = byTeam.get(key) ?? { + teamId: pick.teamId, + seasonId: pick.seasonId, + points: 0, + participantIds: [], + }; + entry.points += points; + entry.participantIds.push(pick.participantId); + byTeam.set(key, entry); + } + + for (const entry of byTeam.values()) { + try { + await recordTeamScoreEvent( + { + teamId: entry.teamId, + seasonId: entry.seasonId, + scoringEventId: eventId, + scoringEventName: eventName, + sportName: sportsSeason.sport?.name ?? null, + participantIds: entry.participantIds, + pointsDelta: entry.points, + }, + db + ); + } catch (err) { + logger.error( + `[TeamScoreEvents] Failed to record final placement score event for team ${entry.teamId} sports season ${params.sportsSeasonId}:`, + err + ); + } + } +} + export interface TeamScoreEventEntry { id: string; teamId: string; -- 2.45.3 From c48d54d873a5bdcb4e1b5d8f2e7958ec194732fc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:43:22 +0000 Subject: [PATCH 3/4] Add backfill for rounded tie splits and missing ledger rows Two stored artifacts went stale when the tie-split math was unified. team_standings totals hold pre-rounding values, and standings are only rewritten when something re-triggers a recalculation, so already-finished leagues would keep 217.50-style figures indefinitely. Separately, qualifying_points and season_standings seasons finalized before this change have no team_score_events rows, so their results stay missing from Recent Scores. The script re-ledgers every already-finalized one-shot sports season and recalculates standings for every fantasy season. Both operations are pure recomputes and ledger rows upsert on (team, season, scoring event), so it is safe to re-run. Supports --dry, following backfill-qp-resplit.ts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz --- scripts/backfill-rounded-tie-splits.ts | 130 +++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 scripts/backfill-rounded-tie-splits.ts diff --git a/scripts/backfill-rounded-tie-splits.ts b/scripts/backfill-rounded-tie-splits.ts new file mode 100644 index 0000000..618676a --- /dev/null +++ b/scripts/backfill-rounded-tie-splits.ts @@ -0,0 +1,130 @@ +/** + * Backfill: recalculate standings after tie splits became whole points, and + * ledger the final placements that were never recorded. + * + * Two stored artifacts went stale when the tie-split math was unified: + * + * 1. team_standings.total_points / actual_points / projected_points hold + * pre-rounding values (a team carrying a golfer tied for 8th sits at + * 217.50 rather than 218). Standings are only rewritten when something + * re-triggers a recalculation, so leagues whose seasons already finished + * would keep the old figures indefinitely. + * + * 2. qualifying_points and season_standings sports were never written to + * team_score_events at all, so their results are missing from Recent + * Scores. recordFinalPlacementScoreEvents now writes them at + * finalization, but only for seasons finalized from here on. + * + * This re-runs both for every already-finalized sports season. Ledger rows are + * upserted on (team, season, scoring event), so re-running rewrites rather than + * duplicates. Standings recalculation is likewise a pure recompute from + * participant results. + * + * Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL. + * + * npx tsx scripts/backfill-rounded-tie-splits.ts # apply + * npx tsx scripts/backfill-rounded-tie-splits.ts --dry # report only + */ + +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { inArray } from "drizzle-orm"; +import * as schema from "../database/schema.js"; +import { DatabaseContext, database } from "../database/context.js"; +import { recalculateStandings } from "../app/models/scoring-calculator.js"; +import { recordFinalPlacementScoreEvents } from "../app/models/team-score-events.js"; + +const DRY = process.argv.includes("--dry"); +const log = (...a: unknown[]) => console.log(...a); + +/** Patterns that award all of their points at once, with no per-match deltas. */ +type ScoringPattern = NonNullable< + (typeof schema.sportsSeasons.$inferSelect)["scoringPattern"] +>; +const ONE_SHOT_PATTERNS: ScoringPattern[] = ["qualifying_points", "season_standings"]; + +async function run() { + const db = database(); + + // Ledger pass: one-shot sports seasons that already have final placements. + const oneShotSeasons = await db.query.sportsSeasons.findMany({ + where: inArray(schema.sportsSeasons.scoringPattern, ONE_SHOT_PATTERNS), + columns: { id: true, name: true, scoringPattern: true }, + }); + + const finalized: typeof oneShotSeasons = []; + for (const ss of oneShotSeasons) { + const anyPlacement = await db.query.seasonParticipantResults.findFirst({ + where: (r, { eq, and, gt }) => + and(eq(r.sportsSeasonId, ss.id), gt(r.finalPosition, 0)), + columns: { id: true }, + }); + if (anyPlacement) finalized.push(ss); + } + + log( + `One-shot sports seasons with final placements: ${finalized.length} of ${oneShotSeasons.length}` + ); + + let ledgered = 0; + let ledgerFailed = 0; + for (const ss of finalized) { + if (DRY) { + log(` (dry) ${ss.name} [${ss.scoringPattern}] — would write ledger rows`); + continue; + } + try { + await recordFinalPlacementScoreEvents({ sportsSeasonId: ss.id }, db); + ledgered += 1; + log(` ${ss.name} [${ss.scoringPattern}]: ledgered`); + } catch (e) { + ledgerFailed += 1; + log(` ! ${ss.name}: ${(e as Error).message}`); + } + } + + // Standings pass: every fantasy season, so rounded awards land in stored + // totals. Cheap enough to run unconditionally and avoids trying to guess + // which seasons contain a tie. + const seasons = await db.query.seasons.findMany({ columns: { id: true, year: true } }); + log(`\nFantasy seasons to recalculate: ${seasons.length}`); + + let recalculated = 0; + let recalcFailed = 0; + for (const season of seasons) { + if (DRY) continue; + try { + await recalculateStandings(season.id, db); + recalculated += 1; + } catch (e) { + recalcFailed += 1; + log(` ! season ${season.id} (${season.year}): ${(e as Error).message}`); + } + } + + log( + `\nDone${DRY ? " (dry run — no writes)" : ""}. ` + + `ledgered=${ledgered} (failed ${ledgerFailed}), ` + + `standings recalculated=${recalculated} (failed ${recalcFailed}).` + ); +} + +async function main() { + const dbUrl = process.env.DATABASE_URL; + if (!dbUrl) { + console.error("ERROR: DATABASE_URL is required"); + process.exit(1); + } + const client = postgres(dbUrl, { max: 1 }); + const db = drizzle(client, { schema }); + try { + await DatabaseContext.run(db, run); + } finally { + await client.end(); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); -- 2.45.3 From 75960a8826971396b7ac90ee8bc289cfa2af62a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 07:32:14 +0000 Subject: [PATCH 4/4] 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 Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz --- app/lib/__tests__/corona-states.test.ts | 13 +++ app/models/__tests__/draft-pick.test.ts | 21 +++++ .../__tests__/scoring-calculator.test.ts | 36 ++++++++ .../__tests__/standings-breakdown.test.ts | 40 +++++++- .../__tests__/team-score-events.test.ts | 92 ++++++++++++++++++- app/models/draft-pick.ts | 26 ++++-- app/models/participant-result.ts | 56 +++++++---- app/models/scoring-calculator.ts | 69 ++++++++------ app/models/scoring-rules.ts | 47 ++++++++-- app/models/team-score-events.ts | 85 +++++++++++++---- scripts/backfill-rounded-tie-splits.ts | 74 ++++++++++++--- 11 files changed, 460 insertions(+), 99 deletions(-) diff --git a/app/lib/__tests__/corona-states.test.ts b/app/lib/__tests__/corona-states.test.ts index 44d71ed..59760c6 100644 --- a/app/lib/__tests__/corona-states.test.ts +++ b/app/lib/__tests__/corona-states.test.ts @@ -81,6 +81,19 @@ describe("computeCoronaStates", () => { expect(states.driver).toMatchObject({ type: "scored", points: 70 }); }); + it("splits a tied season_standings placement", () => { + const states = computeCoronaStates( + [pick("driver", "season_standings")], + results([{ participantId: "driver", finalPosition: 3 }]), + new Map(), + RULES, + RULES.pointsFor1st, + new Map([["ss-1", new Map([[3, 2]])]]) + ); + + expect(states.driver).toMatchObject({ type: "scored", points: 45 }); + }); + it("marks picks with no result as pending", () => { const states = computeCoronaStates( [pick("rahm", "qualifying_points")], diff --git a/app/models/__tests__/draft-pick.test.ts b/app/models/__tests__/draft-pick.test.ts index 2160e78..ca04d52 100644 --- a/app/models/__tests__/draft-pick.test.ts +++ b/app/models/__tests__/draft-pick.test.ts @@ -223,6 +223,27 @@ describe("getDraftedParticipantsWithPoints", () => { currentQP: null, }); }); + + it("splits earnedPoints for tied season_standings placements", async () => { + // Guards the caller-side gate: this collection loop decides which sports + // seasons get a tie count. Gating it on qualifying_points alone silently + // passes tiedParticipants: 1 here, which reads as "no tie" and awards the + // full 50 instead of the split 45. + const db = makeDb({ + picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "season_standings", finalPosition: 3 })], + seasonParticipantResults: [ + { sportsSeasonId: "ss-1", finalPosition: 3 }, + { sportsSeasonId: "ss-1", finalPosition: 3 }, + ], + }); + + const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db); + + expect(result.get("ss-1")?.[0]).toMatchObject({ + earnedPoints: 45, // (50 + 40) / 2 + currentQP: null, + }); + }); }); it("does not query scoringEvents when no playoff_bracket picks", async () => { diff --git a/app/models/__tests__/scoring-calculator.test.ts b/app/models/__tests__/scoring-calculator.test.ts index 7d9525d..a00362f 100644 --- a/app/models/__tests__/scoring-calculator.test.ts +++ b/app/models/__tests__/scoring-calculator.test.ts @@ -3,6 +3,8 @@ import { calculateFantasyPoints, calculateAveragedPoints, calculateSharedPlacementPoints, + calculatePickPoints, + usesSharedPlacementSplit, type ScoringRules, } from "../scoring-rules"; @@ -124,6 +126,40 @@ describe("Scoring Calculator", () => { }); }); + describe("calculatePickPoints", () => { + it("splits ties for every pattern that records them as a repeated position", () => { + // Both patterns write the SAME finalPosition to each tied participant, so + // both need the tie count. season_standings was omitted for a long time, + // which meant tied F1 drivers each banked the full placement value. + for (const pattern of ["qualifying_points", "season_standings"]) { + expect(usesSharedPlacementSplit(pattern)).toBe(true); + expect( + calculatePickPoints(3, pattern, DEFAULT_SCORING, { tiedParticipants: 2 }) + ).toBe(45); // (50 + 40) / 2 + } + }); + + it("awards the full placement value when untied", () => { + expect( + calculatePickPoints(3, "season_standings", DEFAULT_SCORING, { tiedParticipants: 1 }) + ).toBe(50); + }); + + it("derives bracket tiers from the bracket shape, not a tie count", () => { + // playoff_bracket ties are structural, so a stray tiedParticipants must not + // change the answer. + expect(usesSharedPlacementSplit("playoff_bracket")).toBe(false); + expect( + calculatePickPoints(5, "playoff_bracket", DEFAULT_SCORING, { tiedParticipants: 4 }) + ).toBe(20); // (25 + 25 + 15 + 15) / 4 + }); + + it("falls back to a straight placement lookup for unknown patterns", () => { + expect(usesSharedPlacementSplit(null)).toBe(false); + expect(calculatePickPoints(2, null, DEFAULT_SCORING)).toBe(70); + }); + }); + describe("Playoff Placement Scenarios", () => { it("should handle 8-team single elimination bracket", () => { // Champion: 1st (100 pts) diff --git a/app/models/__tests__/standings-breakdown.test.ts b/app/models/__tests__/standings-breakdown.test.ts index 5b178a3..45ab6b6 100644 --- a/app/models/__tests__/standings-breakdown.test.ts +++ b/app/models/__tests__/standings-breakdown.test.ts @@ -130,6 +130,34 @@ describe("getTeamScoreBreakdown", () => { expect(breakdown?.picks[0].points).toBe(18); }); + it("splits a tied season_standings placement", async () => { + // processSeasonStandings writes the same finalPosition to every tied driver + // and leaves the split to scoring time. Two tied at 3rd share 3rd + 4th. + const db = makeDb( + [makePick("driver", "Tied Driver", "season_standings", 3)], + [{ sportsSeasonId: "ss-1", finalPosition: 3 }] + ); + + const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db); + + expect(breakdown?.picks[0].points).toBe(45); // (50 + 40) / 2 + }); + + it("splits a four-way season_standings tie across the 5-8 tier", async () => { + const db = makeDb( + [makePick("driver", "Tied Driver", "season_standings", 5)], + [ + { sportsSeasonId: "ss-1", finalPosition: 5 }, + { sportsSeasonId: "ss-1", finalPosition: 5 }, + { sportsSeasonId: "ss-1", finalPosition: 5 }, + ] + ); + + const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db); + + expect(breakdown?.picks[0].points).toBe(20); // (25 + 25 + 15 + 15) / 4 + }); + it("still averages bracket tiers", async () => { const db = makeDb([makePick("team-a", "Team A", "playoff_bracket", 5)]); @@ -158,8 +186,15 @@ describe("getTeamScoreBreakdown", () => { sportsSeasonId: "ss-2", pickNumber: 3, }), + makePick("driver", "Tied Driver", "season_standings", 3, { + sportsSeasonId: "ss-3", + pickNumber: 4, + }), + ]; + const undrafted = [ + { sportsSeasonId: "ss-1", finalPosition: 8 }, + { sportsSeasonId: "ss-3", finalPosition: 3 }, ]; - const undrafted = [{ sportsSeasonId: "ss-1", finalPosition: 8 }]; const breakdown = await getTeamScoreBreakdown( "team-1", @@ -173,6 +208,7 @@ describe("getTeamScoreBreakdown", () => { ); expect(breakdown?.actualPoints).toBe(score.totalPoints); - expect(breakdown?.actualPoints).toBe(128); // 8 (T8) + 100 (1st) + 20 (T5-8) + // 8 (golf T8) + 100 (golf 1st) + 20 (bracket T5-8) + 45 (F1 T3) + expect(breakdown?.actualPoints).toBe(173); }); }); diff --git a/app/models/__tests__/team-score-events.test.ts b/app/models/__tests__/team-score-events.test.ts index 0f4f438..8de262e 100644 --- a/app/models/__tests__/team-score-events.test.ts +++ b/app/models/__tests__/team-score-events.test.ts @@ -10,7 +10,9 @@ function makeInsertChain() { const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); const insert = vi.fn().mockReturnValue({ values }); - return { insert, values, onConflictDoUpdate }; + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const del = vi.fn().mockReturnValue({ where: deleteWhere }); + return { insert, values, onConflictDoUpdate, delete: del, deleteWhere }; } interface MakeDbOpts { @@ -56,6 +58,7 @@ function makeDb(opts: MakeDbOpts = {}) { return { db: { insert: chain.insert, + delete: chain.delete, query: { sportsSeasons: { findFirst: vi.fn().mockResolvedValue(sportsSeason), @@ -77,6 +80,8 @@ function makeDb(opts: MakeDbOpts = {}) { }, scoringEvents: { findFirst: vi.fn().mockResolvedValue(scoringEvent), + // Every event in the sports season, used to scope the pre-write delete. + findMany: vi.fn().mockResolvedValue(scoringEvent ? [{ id: scoringEvent.id }] : []), }, seasonParticipantResults: { findMany: vi.fn().mockResolvedValue(participantResults), @@ -528,4 +533,89 @@ describe("recordFinalPlacementScoreEvents", () => { expect.objectContaining({ sportName: "F1", pointsDelta: "50" }) ); }); + + it("splits a tied season_standings placement", async () => { + // processSeasonStandings gives a tied group the same finalPosition and leaves + // the split to scoring time. Two drivers tied at 3rd share 3rd + 4th: + // (50 + 40) / 2 = 45. + const { db, chain } = makeQpDb({ + sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any, + participantResults: [ + { participantId: "driver", finalPosition: 3 }, + { participantId: "other-driver", finalPosition: 3 }, + ], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ pointsDelta: "45" }) + ); + }); + + it("anchors to a completed event rather than an incomplete one", async () => { + // Postgres orders DESC as NULLS FIRST and drizzle's desc() cannot express + // NULLS LAST, so an incomplete event (completedAt NULL) would otherwise win + // the anchor. The query must constrain isComplete itself. + const { db } = makeQpDb({ + participantResults: [{ participantId: "rahm", finalPosition: 8 }], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + const anchorQuery = db.query.scoringEvents.findFirst.mock.calls[0][0]; + expect(anchorQuery.where).toBeDefined(); + // Ordering must not be a bare desc() on completedAt alone. + expect(anchorQuery.orderBy.length).toBeGreaterThan(1); + }); + + it("clears the season's existing rows before writing so a moved anchor cannot duplicate", async () => { + const { db, chain } = makeQpDb({ + participantResults: [{ participantId: "rahm", finalPosition: 8 }], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + expect(chain.delete).toHaveBeenCalledTimes(1); + expect(chain.deleteWhere).toHaveBeenCalledTimes(1); + expect(chain.insert).toHaveBeenCalledTimes(1); + }); + + it("labels rows from the scoring pattern with no caller input", async () => { + const qp = makeQpDb({ + participantResults: [{ participantId: "rahm", finalPosition: 8 }], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }], + }); + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, qp.db); + expect(qp.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ scoringEventName: "Final Standings" }) + ); + + const f1 = makeQpDb({ + sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any, + participantResults: [{ participantId: "driver", finalPosition: 3 }], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }], + }); + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, f1.db); + expect(f1.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ scoringEventName: "Season Complete" }) + ); + }); + + it("rewrites the label on conflict so a stale row can be repaired", async () => { + const { db, chain } = makeQpDb({ + participantResults: [{ participantId: "rahm", finalPosition: 8 }], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + expect(chain.onConflictDoUpdate.mock.calls[0][0].set).toMatchObject({ + scoringEventName: "Final Standings", + sportName: "Golf", + }); + }); }); diff --git a/app/models/draft-pick.ts b/app/models/draft-pick.ts index 59e8f3c..ba3795a 100644 --- a/app/models/draft-pick.ts +++ b/app/models/draft-pick.ts @@ -1,7 +1,11 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, inArray, asc } from "drizzle-orm"; -import { getScoringRules, calculatePickPoints } from "./scoring-rules"; +import { + getScoringRules, + calculatePickPoints, + usesSharedPlacementSplit, +} from "./scoring-rules"; import { getSharedPlacementCounts, lookupSharedPlacementCount, @@ -158,18 +162,23 @@ export async function getDraftedParticipantsWithPoints( const bracketSeasonIds = new Set(); const qpSeasonIds = new Set(); const qpParticipantIds = new Set(); - const finalizedQPSeasonIds = new Set(); + // Seasons needing a tie count. Broader than qpSeasonIds: season_standings also + // records ties as a repeated finalPosition, and gating this on qualifying_points + // alone would silently score tied F1 drivers at the full placement value. + const tieSplitSeasonIds = new Set(); for (const pick of picks) { const pattern = pick.participant.sportsSeason.scoringPattern; const ssId = pick.participant.sportsSeasonId; + const finalPosition = pick.participant.results[0]?.finalPosition; if (pattern === "playoff_bracket") bracketSeasonIds.add(ssId); if (pattern === "qualifying_points") { + // Accumulated QP is a qualifying_points-only concept — no F1 equivalent. qpSeasonIds.add(ssId); qpParticipantIds.add(pick.participant.id); - if (pick.participant.results[0]?.finalPosition !== null && pick.participant.results[0]?.finalPosition !== undefined) { - finalizedQPSeasonIds.add(ssId); - } + } + if (usesSharedPlacementSplit(pattern) && finalPosition !== null && finalPosition !== undefined) { + tieSplitSeasonIds.add(ssId); } } @@ -202,10 +211,7 @@ export async function getDraftedParticipantsWithPoints( } } - const qpSharedPlacementCounts = await getSharedPlacementCounts( - [...finalizedQPSeasonIds], - db - ); + const sharedPlacementCounts = await getSharedPlacementCounts([...tieSplitSeasonIds], db); // Assemble result grouped by sportsSeasonId const result = new Map(); @@ -226,7 +232,7 @@ export async function getDraftedParticipantsWithPoints( earnedPoints = calculatePickPoints(resultRow.finalPosition, pattern, scoringRules, { bracketTemplateId: bracketTemplateMap.get(sportsSeasonId) ?? null, tiedParticipants: lookupSharedPlacementCount( - qpSharedPlacementCounts, + sharedPlacementCounts, sportsSeasonId, resultRow.finalPosition ), diff --git a/app/models/participant-result.ts b/app/models/participant-result.ts index 30fbca5..ea18d02 100644 --- a/app/models/participant-result.ts +++ b/app/models/participant-result.ts @@ -77,30 +77,18 @@ export async function findParticipantResultsBySportsSeasonId( } /** - * How many participants share each scoring placement, per sports season. + * Tallies how many participants share each scoring placement, per sports season. * - * Returns sportsSeasonId → (finalPosition → count). Used to split a tied - * placement's points across the tied participants (see calculatePickPoints). + * Pure counterpart to getSharedPlacementCounts, for callers that already hold the + * result rows. Split out so there is exactly one definition of what "tied" means + * — the whole point of this module is that every screen counts ties identically. * - * The count spans EVERY result in the sports season, not just drafted ones — a - * golfer tied for 8th with an undrafted player still only earns half the 8th - * place points, so narrowing this query to drafted participants would silently - * over-award. Positions <= 0 (no scoring placement) are excluded. - * - * Callers that look up a position with no entry should treat it as 1 (no tie). + * Positions <= 0 (no scoring placement) are excluded. */ -export async function getSharedPlacementCounts( - sportsSeasonIds: string[], - providedDb?: ReturnType -): Promise>> { +export function countSharedPlacements( + rows: Array<{ sportsSeasonId: string; finalPosition: number | null }> +): Map> { const counts = new Map>(); - if (sportsSeasonIds.length === 0) return counts; - - const db = providedDb || database(); - const rows = await db.query.seasonParticipantResults.findMany({ - where: inArray(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonIds), - columns: { sportsSeasonId: true, finalPosition: true }, - }); for (const row of rows) { if (row.finalPosition === null || row.finalPosition <= 0) continue; @@ -115,6 +103,34 @@ export async function getSharedPlacementCounts( return counts; } +/** + * How many participants share each scoring placement, per sports season. + * + * Returns sportsSeasonId → (finalPosition → count). Used to split a tied + * placement's points across the tied participants (see calculatePickPoints). + * + * The count spans EVERY result in the sports season, not just drafted ones — a + * golfer tied for 8th with an undrafted player still only earns half the 8th + * place points, so narrowing this query to drafted participants would silently + * over-award. + * + * Callers that look up a position with no entry should treat it as 1 (no tie). + */ +export async function getSharedPlacementCounts( + sportsSeasonIds: string[], + providedDb?: ReturnType +): Promise>> { + if (sportsSeasonIds.length === 0) return new Map(); + + const db = providedDb || database(); + const rows = await db.query.seasonParticipantResults.findMany({ + where: inArray(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonIds), + columns: { sportsSeasonId: true, finalPosition: true }, + }); + + return countSharedPlacements(rows); +} + /** * Convenience lookup over getSharedPlacementCounts' result. Missing entries mean * no other participant shares the placement, so the tie count is 1. diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index a350e8f..9ef7760 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -1,7 +1,12 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, inArray } from "drizzle-orm"; -import { getScoringRules, calculatePickPoints, type ScoringRules } from "./scoring-rules"; +import { + getScoringRules, + calculatePickPoints, + usesSharedPlacementSplit, + type ScoringRules, +} from "./scoring-rules"; import { getSharedPlacementCounts } from "./participant-result"; import { getSeasonResults } from "./participant-season-result"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; @@ -1175,10 +1180,17 @@ export async function finalizeQualifyingPoints( // Ledger the placement points so this season shows up in Recent Scores — // QP seasons award everything here, with no per-match deltas to record. - await recordFinalPlacementScoreEvents( - { sportsSeasonId, eventName: "Final Standings" }, - db - ); + // Guarded like the probability refresh below: the season is already marked + // completed at this point, so letting a ledger failure escape would abort + // finalization before standings recalculate and no Discord update would fire. + try { + await recordFinalPlacementScoreEvents({ sportsSeasonId }, db); + } catch (error) { + logger.error( + `[ScoringCalculator] Failed to record final placement score events for sports season ${sportsSeasonId}:`, + error + ); + } // Trigger recalculation for all affected leagues await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" }); @@ -1285,10 +1297,15 @@ export async function processSeasonStandings( // Ledger the placement points — season_standings has the same one-shot award // shape as qualifying_points and was likewise absent from Recent Scores. - await recordFinalPlacementScoreEvents( - { sportsSeasonId, eventName: "Season Complete" }, - db - ); + // Guarded so a ledger failure cannot abort finalization (see above). + try { + await recordFinalPlacementScoreEvents({ sportsSeasonId }, db); + } catch (error) { + logger.error( + `[ScoringCalculator] Failed to record final placement score events for sports season ${sportsSeasonId}:`, + error + ); + } // Trigger recalculation for all affected leagues await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" }); @@ -1391,15 +1408,14 @@ export async function calculateTeamScore( pattern === "playoff_bracket" ? await getBracketTemplate(pick.participant.sportsSeasonId) : null, - tiedParticipants: - pattern === "qualifying_points" - ? await getSharedPlacementCount( - pick.participant.sportsSeasonId, - result.finalPosition, - db, - sharedPlacementCountCache - ) - : 1, + tiedParticipants: usesSharedPlacementSplit(pattern) + ? await getSharedPlacementCount( + pick.participant.sportsSeasonId, + result.finalPosition, + db, + sharedPlacementCountCache + ) + : 1, }); totalPoints += points; @@ -1500,15 +1516,14 @@ export async function calculateTeamProjectedScore( return calculatePickPoints(finalPosition, pattern, rules, { bracketTemplateId: pattern === "playoff_bracket" ? await getBracketTemplate(sportsSeasonId) : null, - tiedParticipants: - pattern === "qualifying_points" - ? await getSharedPlacementCount( - sportsSeasonId, - finalPosition, - db, - sharedPlacementCountCache - ) - : 1, + tiedParticipants: usesSharedPlacementSplit(pattern) + ? await getSharedPlacementCount( + sportsSeasonId, + finalPosition, + db, + sharedPlacementCountCache + ) + : 1, }); } diff --git a/app/models/scoring-rules.ts b/app/models/scoring-rules.ts index 26d4816..d531445 100644 --- a/app/models/scoring-rules.ts +++ b/app/models/scoring-rules.ts @@ -108,9 +108,7 @@ export function calculateAveragedPoints( return sum + calculateFantasyPoints(placement, rules); }, 0); - // Epsilon guard mirrors roundQualifyingPoints — keeps values that are exactly - // representable-adjacent (e.g. 18.499999999999996) from rounding the wrong way. - return Math.round(total / placements.length + Number.EPSILON); + return Math.round(total / placements.length); } /** @@ -199,11 +197,41 @@ export function calculateBracketPoints( 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 / qualifying_points / default cascade lives. - * It previously existed as a hand-rolled if/else at six call sites, two of which + * 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. @@ -213,9 +241,10 @@ export function calculateBracketPoints( * @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 qualifying_points: how many - * participants share this finalPosition across the WHOLE sports season, not - * just the ones that were drafted. Defaults to 1 (no tie). + * @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, @@ -226,7 +255,7 @@ export function calculatePickPoints( if (scoringPattern === "playoff_bracket") { return calculateBracketPoints(finalPosition, rules, opts?.bracketTemplateId ?? null); } - if (scoringPattern === "qualifying_points") { + if (usesSharedPlacementSplit(scoringPattern)) { return calculateSharedPlacementPoints( finalPosition, opts?.tiedParticipants ?? 1, diff --git a/app/models/team-score-events.ts b/app/models/team-score-events.ts index 6bdcb6d..2f19c13 100644 --- a/app/models/team-score-events.ts +++ b/app/models/team-score-events.ts @@ -1,13 +1,13 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; -import { eq, inArray, desc, sql, and } from "drizzle-orm"; +import { eq, inArray, desc, sql, and, isNull } from "drizzle-orm"; import { calculateBracketPoints, calculatePickPoints, type ScoringRules, } from "~/models/scoring-rules"; import { - getSharedPlacementCounts, + countSharedPlacements, lookupSharedPlacementCount, } from "~/models/participant-result"; import { findParticipantNamesByIds } from "~/models/season-participant"; @@ -86,6 +86,10 @@ export async function recordTeamScoreEvent( set: { participantIds: params.participantIds, pointsDelta: params.pointsDelta.toString(), + // Refreshed too, so a row written with a stale label can be repaired + // by a later correct run instead of keeping the wrong name forever. + scoringEventName: params.scoringEventName, + sportName: params.sportName, }, }); } @@ -198,6 +202,17 @@ export async function recordMatchScoreEvents( } } +/** + * Ledger label for a one-shot finalization, derived from the scoring pattern so + * every caller agrees. Deriving it here rather than requiring a parameter is + * deliberate: recordTeamScoreEvent's upsert can rewrite the label, but a caller + * that simply forgets to pass one would otherwise stamp rows with the anchor + * event's own name ("The Open") instead of "Final Standings". + */ +function defaultEventName(scoringPattern: string | null | undefined): string { + return scoringPattern === "season_standings" ? "Season Complete" : "Final Standings"; +} + /** * Records ledger rows for a sports season whose final placements have just been * assigned — qualifying_points (golf, tennis, CS2) and season_standings (F1). @@ -212,12 +227,15 @@ export async function recordMatchScoreEvents( * calculatePickPoints, so a tied golfer contributes the same split award the * standings show. * - * `eventId` anchors the row and MUST resolve to a real scoring event: the - * event-level unique index is (teamId, seasonId, scoringEventId) and Postgres - * treats NULLs as distinct, so a null anchor would silently duplicate rows on - * every re-finalization instead of upserting. If no anchor can be found the - * ledger write is skipped rather than risking duplicates — standings are + * Every row is anchored to a real scoring event, because the event-level unique + * index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as + * distinct — a null anchor would defeat the upsert entirely. If no anchor can be + * found the ledger write is skipped rather than risking duplicates; standings are * unaffected either way. + * + * Re-running is safe even if the anchor moves (a later event completes, or the + * backfill runs against changed data). Stale rows for this sports season are + * cleared before writing, so the upsert alone is not load-bearing. */ export async function recordFinalPlacementScoreEvents( params: { @@ -238,29 +256,40 @@ export async function recordFinalPlacementScoreEvents( // Resolve the anchor event: the caller's, else the most recently completed // event for this sports season. + // + // The completed filter is not cosmetic. drizzle's desc() emits a bare `desc`, + // and Postgres orders DESC as NULLS FIRST, so ordering on completedAt alone + // would rank a never-completed event (common for sibling major windows) above + // every finished one. Restrict to completed events and order explicitly. let eventId = params.eventId ?? null; - let eventName = params.eventName ?? null; + const eventName = params.eventName ?? defaultEventName(sportsSeason.scoringPattern); if (!eventId) { const anchor = await db.query.scoringEvents.findFirst({ - where: eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId), - columns: { id: true, name: true }, - orderBy: [desc(schema.scoringEvents.completedAt), desc(schema.scoringEvents.createdAt)], + where: and( + eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId), + eq(schema.scoringEvents.isComplete, true) + ), + columns: { id: true }, + orderBy: [ + sql`${schema.scoringEvents.completedAt} DESC NULLS LAST`, + sql`${schema.scoringEvents.eventDate} DESC NULLS LAST`, + desc(schema.scoringEvents.id), + ], }); if (!anchor) { logger.warn( - `[TeamScoreEvents] No scoring event to anchor final placements for sports season ${params.sportsSeasonId}; skipping ledger write` + `[TeamScoreEvents] No completed scoring event to anchor final placements for sports season ${params.sportsSeasonId}; skipping ledger write` ); return; } eventId = anchor.id; - eventName = eventName ?? anchor.name; } - if (!eventId) return; // Scoring placements for this sports season, plus the tie spans they imply. + // Counted from these same rows rather than re-querying them. const results = await db.query.seasonParticipantResults.findMany({ where: eq(schema.seasonParticipantResults.sportsSeasonId, params.sportsSeasonId), - columns: { participantId: true, finalPosition: true }, + columns: { sportsSeasonId: true, participantId: true, finalPosition: true }, }); const positionByParticipantId = new Map(); for (const row of results) { @@ -270,7 +299,7 @@ export async function recordFinalPlacementScoreEvents( } if (positionByParticipantId.size === 0) return; - const sharedPlacementCounts = await getSharedPlacementCounts([params.sportsSeasonId], db); + const sharedPlacementCounts = countSharedPlacements(results); const seasonSports = await db.query.seasonSports.findMany({ where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId), @@ -343,6 +372,30 @@ export async function recordFinalPlacementScoreEvents( byTeam.set(key, entry); } + // Clear this sports season's existing event-level rows before writing, so a + // re-run whose anchor landed on a different event replaces rather than adds. + // Scoped to match_id IS NULL, which is what makes this safe: one-shot patterns + // write no other event-level rows (QP majors award qualifying points, not + // fantasy points), and qualifying-bracket matches carry a non-null matchId. + const eventIds = await db.query.scoringEvents.findMany({ + where: eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId), + columns: { id: true }, + }); + if (eventIds.length > 0) { + await db + .delete(schema.teamScoreEvents) + .where( + and( + isNull(schema.teamScoreEvents.matchId), + inArray( + schema.teamScoreEvents.scoringEventId, + eventIds.map((e) => e.id) + ), + inArray(schema.teamScoreEvents.seasonId, seasonIds) + ) + ); + } + for (const entry of byTeam.values()) { try { await recordTeamScoreEvent( diff --git a/scripts/backfill-rounded-tie-splits.ts b/scripts/backfill-rounded-tie-splits.ts index 618676a..029ec85 100644 --- a/scripts/backfill-rounded-tie-splits.ts +++ b/scripts/backfill-rounded-tie-splits.ts @@ -15,10 +15,15 @@ * Scores. recordFinalPlacementScoreEvents now writes them at * finalization, but only for seasons finalized from here on. * - * This re-runs both for every already-finalized sports season. Ledger rows are - * upserted on (team, season, scoring event), so re-running rewrites rather than - * duplicates. Standings recalculation is likewise a pure recompute from - * participant results. + * The ledger pass covers every already-finalized one-shot sports season and is + * safe to repeat: recordFinalPlacementScoreEvents clears that season's existing + * event-level rows before writing. + * + * The standings pass is deliberately narrow. recalculateStandings rewrites + * previousRank, so any season it touches loses its rank-movement arrows until the + * next scoring event — it is NOT a pure recompute. Only leagues drafting from a + * sports season that actually contains a tied placement are recalculated; every + * other league is left alone. * * Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL. * @@ -28,11 +33,12 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; -import { inArray } from "drizzle-orm"; +import { inArray, eq } from "drizzle-orm"; import * as schema from "../database/schema.js"; import { DatabaseContext, database } from "../database/context.js"; import { recalculateStandings } from "../app/models/scoring-calculator.js"; import { recordFinalPlacementScoreEvents } from "../app/models/team-score-events.js"; +import { countSharedPlacements } from "../app/models/participant-result.js"; const DRY = process.argv.includes("--dry"); const log = (...a: unknown[]) => console.log(...a); @@ -83,22 +89,62 @@ async function run() { } } - // Standings pass: every fantasy season, so rounded awards land in stored - // totals. Cheap enough to run unconditionally and avoids trying to guess - // which seasons contain a tie. - const seasons = await db.query.seasons.findMany({ columns: { id: true, year: true } }); - log(`\nFantasy seasons to recalculate: ${seasons.length}`); + // Standings pass, restricted to leagues that can actually change. + // + // recalculateStandings is NOT a pure recompute: it sets previousRank = + // currentRank, so every season it touches loses its rank-movement arrows until + // the next real scoring event. Sweeping all seasons would spend that cost on + // leagues holding no tied placement at all, so scope it to sports seasons that + // genuinely have a tie, then to the fantasy seasons drafting from them. + const allResults = await db.query.seasonParticipantResults.findMany({ + columns: { sportsSeasonId: true, finalPosition: true }, + }); + const tiedSportsSeasonIds = new Set(); + for (const [sportsSeasonId, byPosition] of countSharedPlacements(allResults)) { + for (const count of byPosition.values()) { + if (count > 1) { + tiedSportsSeasonIds.add(sportsSeasonId); + break; + } + } + } + + const affectedSeasonIds = new Set(); + if (tiedSportsSeasonIds.size > 0) { + const picks = await db + .select({ + seasonId: schema.draftPicks.seasonId, + sportsSeasonId: schema.seasonParticipants.sportsSeasonId, + }) + .from(schema.draftPicks) + .innerJoin( + schema.seasonParticipants, + eq(schema.draftPicks.participantId, schema.seasonParticipants.id) + ) + .where( + inArray(schema.seasonParticipants.sportsSeasonId, [...tiedSportsSeasonIds]) + ); + for (const pick of picks) affectedSeasonIds.add(pick.seasonId); + } + + log( + `\nSports seasons with a tied placement: ${tiedSportsSeasonIds.size}` + + `\nFantasy seasons to recalculate: ${affectedSeasonIds.size}` + ); let recalculated = 0; let recalcFailed = 0; - for (const season of seasons) { - if (DRY) continue; + for (const seasonId of affectedSeasonIds) { + if (DRY) { + log(` (dry) season ${seasonId} — would recalculate standings`); + continue; + } try { - await recalculateStandings(season.id, db); + await recalculateStandings(seasonId, db); recalculated += 1; } catch (e) { recalcFailed += 1; - log(` ! season ${season.id} (${season.year}): ${(e as Error).message}`); + log(` ! season ${seasonId}: ${(e as Error).message}`); } } -- 2.45.3