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}`); } }