diff --git a/app/lib/__tests__/corona-states.test.ts b/app/lib/__tests__/corona-states.test.ts new file mode 100644 index 0000000..59760c6 --- /dev/null +++ b/app/lib/__tests__/corona-states.test.ts @@ -0,0 +1,135 @@ +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("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")], + 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/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..ca04d52 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, }); }); @@ -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 b9a307d..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"; @@ -71,8 +73,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 +111,52 @@ 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); + }); + }); + + 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); }); }); @@ -150,8 +197,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__/standings-breakdown.test.ts b/app/models/__tests__/standings-breakdown.test.ts new file mode 100644 index 0000000..45ab6b6 --- /dev/null +++ b/app/models/__tests__/standings-breakdown.test.ts @@ -0,0 +1,214 @@ +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("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)]); + + 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, + }), + 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 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); + // 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-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/__tests__/team-score-events.test.ts b/app/models/__tests__/team-score-events.test.ts index 7f543e6..8de262e 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 ──────────────────────────────────────────────────────── @@ -10,13 +10,21 @@ 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 { 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 +49,8 @@ function makeDb(opts: MakeDbOpts = {}) { seasons = [], scoreEventRows = [], participantRows = [], + scoringEvent = null, + participantResults = [], } = opts; const chain = makeInsertChain(); @@ -48,6 +58,7 @@ function makeDb(opts: MakeDbOpts = {}) { return { db: { insert: chain.insert, + delete: chain.delete, query: { sportsSeasons: { findFirst: vi.fn().mockResolvedValue(sportsSeason), @@ -67,13 +78,26 @@ function makeDb(opts: MakeDbOpts = {}) { seasonParticipants: { findMany: vi.fn().mockResolvedValue(participantRows), }, + 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), + }, }, } 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 +382,240 @@ 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" }) + ); + }); + + 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 7289046..ba3795a 100644 --- a/app/models/draft-pick.ts +++ b/app/models/draft-pick.ts @@ -3,10 +3,13 @@ import * as schema from "~/database/schema"; import { eq, and, inArray, asc } from "drizzle-orm"; import { getScoringRules, - calculateFantasyPoints, - calculateBracketPoints, - calculateSharedPlacementPoints, + calculatePickPoints, + usesSharedPlacementSplit, } from "./scoring-rules"; +import { + getSharedPlacementCounts, + lookupSharedPlacementCount, +} from "./participant-result"; export async function createDraftPick(data: { seasonId: string; @@ -159,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); } } @@ -203,19 +211,7 @@ 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 sharedPlacementCounts = await getSharedPlacementCounts([...tieSplitSeasonIds], db); // Assemble result grouped by sportsSeasonId const result = new Map(); @@ -233,23 +229,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( + sharedPlacementCounts, + sportsSeasonId, + resultRow.finalPosition + ), + }); } const arr = result.get(sportsSeasonId) ?? []; diff --git a/app/models/participant-result.ts b/app/models/participant-result.ts index 4e36257..ea18d02 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,73 @@ export async function findParticipantResultsBySportsSeasonId( }); } +/** + * Tallies how many participants share each scoring placement, per sports season. + * + * 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. + * + * Positions <= 0 (no scoring placement) are excluded. + */ +export function countSharedPlacements( + rows: Array<{ sportsSeasonId: string; finalPosition: number | null }> +): Map> { + const counts = new Map>(); + + 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; +} + +/** + * 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. + */ +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..9ef7760 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -3,10 +3,11 @@ import * as schema from "~/database/schema"; import { eq, and, inArray } from "drizzle-orm"; import { getScoringRules, - calculateFantasyPoints, - calculateBracketPoints, - calculateSharedPlacementPoints, + calculatePickPoints, + usesSharedPlacementSplit, + 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"; @@ -16,7 +17,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 { @@ -595,6 +599,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 +611,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; @@ -1178,6 +1178,20 @@ 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. + // 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" }); @@ -1281,6 +1295,18 @@ 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. + // 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" }); @@ -1376,27 +1402,21 @@ 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: usesSharedPlacementSplit(pattern) + ? 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 +1503,51 @@ 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: usesSharedPlacementSplit(pattern) + ? 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..d531445 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,7 @@ export function calculateAveragedPoints( return sum + calculateFantasyPoints(placement, rules); }, 0); - return total / placements.length; + return Math.round(total / placements.length); } /** @@ -190,6 +197,74 @@ 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 / tie-split / default cascade lives. It + * previously existed as a hand-rolled if/else at six call sites, two of which + * were missing the qualifying_points arm entirely — that divergence is what made + * a tied golfer worth 15 points on the team page and draft board but 7.5 in the + * standings. Every caller must route through here. + * + * @param finalPosition - Placement in the sports season (1-8 scores, 0 = none). + * @param scoringPattern - The sports season's scoringPattern column. + * @param rules - The fantasy season's point values. + * @param opts.bracketTemplateId - Required for playoff_bracket to pick the right + * tier structure (e.g. AFL/LLWS split 5–8 into two pairs). + * @param opts.tiedParticipants - Required for any pattern where + * usesSharedPlacementSplit is true: how many participants share this + * finalPosition across the WHOLE sports season, not just the ones that were + * drafted. Defaults to 1 (no tie). + */ +export function calculatePickPoints( + finalPosition: number, + scoringPattern: string | null | undefined, + rules: ScoringRules, + opts?: { bracketTemplateId?: string | null; tiedParticipants?: number } +): number { + if (scoringPattern === "playoff_bracket") { + return calculateBracketPoints(finalPosition, rules, opts?.bracketTemplateId ?? null); + } + if (usesSharedPlacementSplit(scoringPattern)) { + return calculateSharedPlacementPoints( + finalPosition, + opts?.tiedParticipants ?? 1, + rules + ); + } + return calculateFantasyPoints(finalPosition, rules); +} + /** * Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th] * Useful for display purposes 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/models/team-score-events.ts b/app/models/team-score-events.ts index 077772c..2f19c13 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 { eq, inArray, desc, sql, and, isNull } from "drizzle-orm"; +import { + calculateBracketPoints, + calculatePickPoints, + type ScoringRules, +} from "~/models/scoring-rules"; +import { + countSharedPlacements, + lookupSharedPlacementCount, +} from "~/models/participant-result"; import { findParticipantNamesByIds } from "~/models/season-participant"; import { logger } from "~/lib/logger"; @@ -78,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, }, }); } @@ -190,6 +202,223 @@ 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). + * + * 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. + * + * 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: { + 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. + // + // 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; + const eventName = params.eventName ?? defaultEventName(sportsSeason.scoringPattern); + if (!eventId) { + const anchor = await db.query.scoringEvents.findFirst({ + 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 completed scoring event to anchor final placements for sports season ${params.sportsSeasonId}; skipping ledger write` + ); + return; + } + eventId = anchor.id; + } + + // 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: { sportsSeasonId: true, 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 = countSharedPlacements(results); + + 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); + } + + // 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( + { + 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; 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, ); } diff --git a/scripts/backfill-rounded-tie-splits.ts b/scripts/backfill-rounded-tie-splits.ts new file mode 100644 index 0000000..029ec85 --- /dev/null +++ b/scripts/backfill-rounded-tie-splits.ts @@ -0,0 +1,176 @@ +/** + * 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. + * + * 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. + * + * 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, 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); + +/** 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, 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 seasonId of affectedSeasonIds) { + if (DRY) { + log(` (dry) season ${seasonId} — would recalculate standings`); + continue; + } + try { + await recalculateStandings(seasonId, db); + recalculated += 1; + } catch (e) { + recalcFailed += 1; + log(` ! season ${seasonId}: ${(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); +});