Fix review findings in the tie-split ledger and backfill
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m4s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m22s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
This commit is contained in:
Claude 2026-08-08 07:32:14 +00:00
parent 9024391d2f
commit a143df51f6
No known key found for this signature in database
11 changed files with 460 additions and 99 deletions

View file

@ -81,6 +81,19 @@ describe("computeCoronaStates", () => {
expect(states.driver).toMatchObject({ type: "scored", points: 70 }); 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", () => { it("marks picks with no result as pending", () => {
const states = computeCoronaStates( const states = computeCoronaStates(
[pick("rahm", "qualifying_points")], [pick("rahm", "qualifying_points")],

View file

@ -223,6 +223,27 @@ describe("getDraftedParticipantsWithPoints", () => {
currentQP: null, 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 () => { it("does not query scoringEvents when no playoff_bracket picks", async () => {

View file

@ -3,6 +3,8 @@ import {
calculateFantasyPoints, calculateFantasyPoints,
calculateAveragedPoints, calculateAveragedPoints,
calculateSharedPlacementPoints, calculateSharedPlacementPoints,
calculatePickPoints,
usesSharedPlacementSplit,
type ScoringRules, type ScoringRules,
} from "../scoring-rules"; } 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", () => { describe("Playoff Placement Scenarios", () => {
it("should handle 8-team single elimination bracket", () => { it("should handle 8-team single elimination bracket", () => {
// Champion: 1st (100 pts) // Champion: 1st (100 pts)

View file

@ -130,6 +130,34 @@ describe("getTeamScoreBreakdown", () => {
expect(breakdown?.picks[0].points).toBe(18); 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 () => { it("still averages bracket tiers", async () => {
const db = makeDb([makePick("team-a", "Team A", "playoff_bracket", 5)]); const db = makeDb([makePick("team-a", "Team A", "playoff_bracket", 5)]);
@ -158,8 +186,15 @@ describe("getTeamScoreBreakdown", () => {
sportsSeasonId: "ss-2", sportsSeasonId: "ss-2",
pickNumber: 3, 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( const breakdown = await getTeamScoreBreakdown(
"team-1", "team-1",
@ -173,6 +208,7 @@ describe("getTeamScoreBreakdown", () => {
); );
expect(breakdown?.actualPoints).toBe(score.totalPoints); 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);
}); });
}); });

View file

@ -10,7 +10,9 @@ function makeInsertChain() {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
const insert = vi.fn().mockReturnValue({ values }); 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 { interface MakeDbOpts {
@ -56,6 +58,7 @@ function makeDb(opts: MakeDbOpts = {}) {
return { return {
db: { db: {
insert: chain.insert, insert: chain.insert,
delete: chain.delete,
query: { query: {
sportsSeasons: { sportsSeasons: {
findFirst: vi.fn().mockResolvedValue(sportsSeason), findFirst: vi.fn().mockResolvedValue(sportsSeason),
@ -77,6 +80,8 @@ function makeDb(opts: MakeDbOpts = {}) {
}, },
scoringEvents: { scoringEvents: {
findFirst: vi.fn().mockResolvedValue(scoringEvent), 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: { seasonParticipantResults: {
findMany: vi.fn().mockResolvedValue(participantResults), findMany: vi.fn().mockResolvedValue(participantResults),
@ -528,4 +533,89 @@ describe("recordFinalPlacementScoreEvents", () => {
expect.objectContaining({ sportName: "F1", pointsDelta: "50" }) 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",
});
});
}); });

View file

@ -1,7 +1,11 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, inArray, asc } from "drizzle-orm"; import { eq, and, inArray, asc } from "drizzle-orm";
import { getScoringRules, calculatePickPoints } from "./scoring-rules"; import {
getScoringRules,
calculatePickPoints,
usesSharedPlacementSplit,
} from "./scoring-rules";
import { import {
getSharedPlacementCounts, getSharedPlacementCounts,
lookupSharedPlacementCount, lookupSharedPlacementCount,
@ -158,18 +162,23 @@ export async function getDraftedParticipantsWithPoints(
const bracketSeasonIds = new Set<string>(); const bracketSeasonIds = new Set<string>();
const qpSeasonIds = new Set<string>(); const qpSeasonIds = new Set<string>();
const qpParticipantIds = new Set<string>(); const qpParticipantIds = new Set<string>();
const finalizedQPSeasonIds = new Set<string>(); // 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<string>();
for (const pick of picks) { for (const pick of picks) {
const pattern = pick.participant.sportsSeason.scoringPattern; const pattern = pick.participant.sportsSeason.scoringPattern;
const ssId = pick.participant.sportsSeasonId; const ssId = pick.participant.sportsSeasonId;
const finalPosition = pick.participant.results[0]?.finalPosition;
if (pattern === "playoff_bracket") bracketSeasonIds.add(ssId); if (pattern === "playoff_bracket") bracketSeasonIds.add(ssId);
if (pattern === "qualifying_points") { if (pattern === "qualifying_points") {
// Accumulated QP is a qualifying_points-only concept — no F1 equivalent.
qpSeasonIds.add(ssId); qpSeasonIds.add(ssId);
qpParticipantIds.add(pick.participant.id); 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( const sharedPlacementCounts = await getSharedPlacementCounts([...tieSplitSeasonIds], db);
[...finalizedQPSeasonIds],
db
);
// Assemble result grouped by sportsSeasonId // Assemble result grouped by sportsSeasonId
const result = new Map<string, DraftedParticipantWithPoints[]>(); const result = new Map<string, DraftedParticipantWithPoints[]>();
@ -226,7 +232,7 @@ export async function getDraftedParticipantsWithPoints(
earnedPoints = calculatePickPoints(resultRow.finalPosition, pattern, scoringRules, { earnedPoints = calculatePickPoints(resultRow.finalPosition, pattern, scoringRules, {
bracketTemplateId: bracketTemplateMap.get(sportsSeasonId) ?? null, bracketTemplateId: bracketTemplateMap.get(sportsSeasonId) ?? null,
tiedParticipants: lookupSharedPlacementCount( tiedParticipants: lookupSharedPlacementCount(
qpSharedPlacementCounts, sharedPlacementCounts,
sportsSeasonId, sportsSeasonId,
resultRow.finalPosition resultRow.finalPosition
), ),

View file

@ -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 * Pure counterpart to getSharedPlacementCounts, for callers that already hold the
* placement's points across the tied participants (see calculatePickPoints). * 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 * Positions <= 0 (no scoring placement) are excluded.
* golfer tied for 8th with an undrafted player still only earns half the 8th
* place points, so narrowing this query to drafted participants would silently
* over-award. Positions <= 0 (no scoring placement) are excluded.
*
* Callers that look up a position with no entry should treat it as 1 (no tie).
*/ */
export async function getSharedPlacementCounts( export function countSharedPlacements(
sportsSeasonIds: string[], rows: Array<{ sportsSeasonId: string; finalPosition: number | null }>
providedDb?: ReturnType<typeof database> ): Map<string, Map<number, number>> {
): Promise<Map<string, Map<number, number>>> {
const counts = new Map<string, Map<number, number>>(); const counts = new Map<string, Map<number, number>>();
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) { for (const row of rows) {
if (row.finalPosition === null || row.finalPosition <= 0) continue; if (row.finalPosition === null || row.finalPosition <= 0) continue;
@ -115,6 +103,34 @@ export async function getSharedPlacementCounts(
return counts; 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<typeof database>
): Promise<Map<string, Map<number, number>>> {
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 * Convenience lookup over getSharedPlacementCounts' result. Missing entries mean
* no other participant shares the placement, so the tie count is 1. * no other participant shares the placement, so the tie count is 1.

View file

@ -1,7 +1,12 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm"; 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 { getSharedPlacementCounts } from "./participant-result";
import { getSeasonResults } from "./participant-season-result"; import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; 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 — // Ledger the placement points so this season shows up in Recent Scores —
// QP seasons award everything here, with no per-match deltas to record. // QP seasons award everything here, with no per-match deltas to record.
await recordFinalPlacementScoreEvents( // Guarded like the probability refresh below: the season is already marked
{ sportsSeasonId, eventName: "Final Standings" }, // completed at this point, so letting a ledger failure escape would abort
db // 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 // Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" }); 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 // Ledger the placement points — season_standings has the same one-shot award
// shape as qualifying_points and was likewise absent from Recent Scores. // shape as qualifying_points and was likewise absent from Recent Scores.
await recordFinalPlacementScoreEvents( // Guarded so a ledger failure cannot abort finalization (see above).
{ sportsSeasonId, eventName: "Season Complete" }, try {
db 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 // Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" }); await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });
@ -1391,15 +1408,14 @@ export async function calculateTeamScore(
pattern === "playoff_bracket" pattern === "playoff_bracket"
? await getBracketTemplate(pick.participant.sportsSeasonId) ? await getBracketTemplate(pick.participant.sportsSeasonId)
: null, : null,
tiedParticipants: tiedParticipants: usesSharedPlacementSplit(pattern)
pattern === "qualifying_points" ? await getSharedPlacementCount(
? await getSharedPlacementCount( pick.participant.sportsSeasonId,
pick.participant.sportsSeasonId, result.finalPosition,
result.finalPosition, db,
db, sharedPlacementCountCache
sharedPlacementCountCache )
) : 1,
: 1,
}); });
totalPoints += points; totalPoints += points;
@ -1500,15 +1516,14 @@ export async function calculateTeamProjectedScore(
return calculatePickPoints(finalPosition, pattern, rules, { return calculatePickPoints(finalPosition, pattern, rules, {
bracketTemplateId: bracketTemplateId:
pattern === "playoff_bracket" ? await getBracketTemplate(sportsSeasonId) : null, pattern === "playoff_bracket" ? await getBracketTemplate(sportsSeasonId) : null,
tiedParticipants: tiedParticipants: usesSharedPlacementSplit(pattern)
pattern === "qualifying_points" ? await getSharedPlacementCount(
? await getSharedPlacementCount( sportsSeasonId,
sportsSeasonId, finalPosition,
finalPosition, db,
db, sharedPlacementCountCache
sharedPlacementCountCache )
) : 1,
: 1,
}); });
} }

View file

@ -108,9 +108,7 @@ export function calculateAveragedPoints(
return sum + calculateFantasyPoints(placement, rules); return sum + calculateFantasyPoints(placement, rules);
}, 0); }, 0);
// Epsilon guard mirrors roundQualifyingPoints — keeps values that are exactly return Math.round(total / placements.length);
// representable-adjacent (e.g. 18.499999999999996) from rounding the wrong way.
return Math.round(total / placements.length + Number.EPSILON);
} }
/** /**
@ -199,11 +197,41 @@ export function calculateBracketPoints(
return 0; 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. * Fantasy points earned by a single drafted participant, for any scoring pattern.
* *
* This is the ONE place the bracket / qualifying_points / default cascade lives. * This is the ONE place the bracket / tie-split / default cascade lives. It
* It previously existed as a hand-rolled if/else at six call sites, two of which * 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 * 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 * 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. * standings. Every caller must route through here.
@ -213,9 +241,10 @@ export function calculateBracketPoints(
* @param rules - The fantasy season's point values. * @param rules - The fantasy season's point values.
* @param opts.bracketTemplateId - Required for playoff_bracket to pick the right * @param opts.bracketTemplateId - Required for playoff_bracket to pick the right
* tier structure (e.g. AFL/LLWS split 58 into two pairs). * tier structure (e.g. AFL/LLWS split 58 into two pairs).
* @param opts.tiedParticipants - Required for qualifying_points: how many * @param opts.tiedParticipants - Required for any pattern where
* participants share this finalPosition across the WHOLE sports season, not * usesSharedPlacementSplit is true: how many participants share this
* just the ones that were drafted. Defaults to 1 (no tie). * finalPosition across the WHOLE sports season, not just the ones that were
* drafted. Defaults to 1 (no tie).
*/ */
export function calculatePickPoints( export function calculatePickPoints(
finalPosition: number, finalPosition: number,
@ -226,7 +255,7 @@ export function calculatePickPoints(
if (scoringPattern === "playoff_bracket") { if (scoringPattern === "playoff_bracket") {
return calculateBracketPoints(finalPosition, rules, opts?.bracketTemplateId ?? null); return calculateBracketPoints(finalPosition, rules, opts?.bracketTemplateId ?? null);
} }
if (scoringPattern === "qualifying_points") { if (usesSharedPlacementSplit(scoringPattern)) {
return calculateSharedPlacementPoints( return calculateSharedPlacementPoints(
finalPosition, finalPosition,
opts?.tiedParticipants ?? 1, opts?.tiedParticipants ?? 1,

View file

@ -1,13 +1,13 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; 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 { import {
calculateBracketPoints, calculateBracketPoints,
calculatePickPoints, calculatePickPoints,
type ScoringRules, type ScoringRules,
} from "~/models/scoring-rules"; } from "~/models/scoring-rules";
import { import {
getSharedPlacementCounts, countSharedPlacements,
lookupSharedPlacementCount, lookupSharedPlacementCount,
} from "~/models/participant-result"; } from "~/models/participant-result";
import { findParticipantNamesByIds } from "~/models/season-participant"; import { findParticipantNamesByIds } from "~/models/season-participant";
@ -86,6 +86,10 @@ export async function recordTeamScoreEvent(
set: { set: {
participantIds: params.participantIds, participantIds: params.participantIds,
pointsDelta: params.pointsDelta.toString(), 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 * Records ledger rows for a sports season whose final placements have just been
* assigned qualifying_points (golf, tennis, CS2) and season_standings (F1). * 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 * calculatePickPoints, so a tied golfer contributes the same split award the
* standings show. * standings show.
* *
* `eventId` anchors the row and MUST resolve to a real scoring event: the * Every row is anchored to a real scoring event, because the event-level unique
* event-level unique index is (teamId, seasonId, scoringEventId) and Postgres * index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as
* treats NULLs as distinct, so a null anchor would silently duplicate rows on * distinct a null anchor would defeat the upsert entirely. If no anchor can be
* every re-finalization instead of upserting. If no anchor can be found the * found the ledger write is skipped rather than risking duplicates; standings are
* ledger write is skipped rather than risking duplicates standings are
* unaffected either way. * 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( export async function recordFinalPlacementScoreEvents(
params: { params: {
@ -238,29 +256,40 @@ export async function recordFinalPlacementScoreEvents(
// Resolve the anchor event: the caller's, else the most recently completed // Resolve the anchor event: the caller's, else the most recently completed
// event for this sports season. // 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 eventId = params.eventId ?? null;
let eventName = params.eventName ?? null; const eventName = params.eventName ?? defaultEventName(sportsSeason.scoringPattern);
if (!eventId) { if (!eventId) {
const anchor = await db.query.scoringEvents.findFirst({ const anchor = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId), where: and(
columns: { id: true, name: true }, eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId),
orderBy: [desc(schema.scoringEvents.completedAt), desc(schema.scoringEvents.createdAt)], 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) { if (!anchor) {
logger.warn( 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; return;
} }
eventId = anchor.id; eventId = anchor.id;
eventName = eventName ?? anchor.name;
} }
if (!eventId) return;
// Scoring placements for this sports season, plus the tie spans they imply. // 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({ const results = await db.query.seasonParticipantResults.findMany({
where: eq(schema.seasonParticipantResults.sportsSeasonId, params.sportsSeasonId), where: eq(schema.seasonParticipantResults.sportsSeasonId, params.sportsSeasonId),
columns: { participantId: true, finalPosition: true }, columns: { sportsSeasonId: true, participantId: true, finalPosition: true },
}); });
const positionByParticipantId = new Map<string, number>(); const positionByParticipantId = new Map<string, number>();
for (const row of results) { for (const row of results) {
@ -270,7 +299,7 @@ export async function recordFinalPlacementScoreEvents(
} }
if (positionByParticipantId.size === 0) return; if (positionByParticipantId.size === 0) return;
const sharedPlacementCounts = await getSharedPlacementCounts([params.sportsSeasonId], db); const sharedPlacementCounts = countSharedPlacements(results);
const seasonSports = await db.query.seasonSports.findMany({ const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId), where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId),
@ -343,6 +372,30 @@ export async function recordFinalPlacementScoreEvents(
byTeam.set(key, entry); 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()) { for (const entry of byTeam.values()) {
try { try {
await recordTeamScoreEvent( await recordTeamScoreEvent(

View file

@ -15,10 +15,15 @@
* Scores. recordFinalPlacementScoreEvents now writes them at * Scores. recordFinalPlacementScoreEvents now writes them at
* finalization, but only for seasons finalized from here on. * finalization, but only for seasons finalized from here on.
* *
* This re-runs both for every already-finalized sports season. Ledger rows are * The ledger pass covers every already-finalized one-shot sports season and is
* upserted on (team, season, scoring event), so re-running rewrites rather than * safe to repeat: recordFinalPlacementScoreEvents clears that season's existing
* duplicates. Standings recalculation is likewise a pure recompute from * event-level rows before writing.
* participant results. *
* 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. * 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 { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import { inArray } from "drizzle-orm"; import { inArray, eq } from "drizzle-orm";
import * as schema from "../database/schema.js"; import * as schema from "../database/schema.js";
import { DatabaseContext, database } from "../database/context.js"; import { DatabaseContext, database } from "../database/context.js";
import { recalculateStandings } from "../app/models/scoring-calculator.js"; import { recalculateStandings } from "../app/models/scoring-calculator.js";
import { recordFinalPlacementScoreEvents } from "../app/models/team-score-events.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 DRY = process.argv.includes("--dry");
const log = (...a: unknown[]) => console.log(...a); 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 // Standings pass, restricted to leagues that can actually change.
// totals. Cheap enough to run unconditionally and avoids trying to guess //
// which seasons contain a tie. // recalculateStandings is NOT a pure recompute: it sets previousRank =
const seasons = await db.query.seasons.findMany({ columns: { id: true, year: true } }); // currentRank, so every season it touches loses its rank-movement arrows until
log(`\nFantasy seasons to recalculate: ${seasons.length}`); // 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<string>();
for (const [sportsSeasonId, byPosition] of countSharedPlacements(allResults)) {
for (const count of byPosition.values()) {
if (count > 1) {
tiedSportsSeasonIds.add(sportsSeasonId);
break;
}
}
}
const affectedSeasonIds = new Set<string>();
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 recalculated = 0;
let recalcFailed = 0; let recalcFailed = 0;
for (const season of seasons) { for (const seasonId of affectedSeasonIds) {
if (DRY) continue; if (DRY) {
log(` (dry) season ${seasonId} — would recalculate standings`);
continue;
}
try { try {
await recalculateStandings(season.id, db); await recalculateStandings(seasonId, db);
recalculated += 1; recalculated += 1;
} catch (e) { } catch (e) {
recalcFailed += 1; recalcFailed += 1;
log(` ! season ${season.id} (${season.year}): ${(e as Error).message}`); log(` ! season ${seasonId}: ${(e as Error).message}`);
} }
} }