Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger writer and backfill, plus one pre-existing scoring bug the refactor exposed. season_standings ties were never split. processSeasonStandings deliberately writes the same finalPosition to every driver in a tied group -- its comment says "the scoring system will handle averaging" -- but no path ever did, so two drivers tied for 3rd each banked the full 50 instead of the published 45. This predates the tie-split work; the original cascade had only bracket and qualifying_points arms. Introduce usesSharedPlacementSplit as the single definition of which patterns record ties as a repeated placement, and route both calculatePickPoints and every caller-side gate through it. The caller gates matter as much as the helper: a gate left hardcoded to qualifying_points silently passes a tie count of 1, which reads as "no tie" and makes the fix inert. The ledger anchor picked the wrong event. Ordering on completedAt with no isComplete filter ranked never-completed events first, because drizzle's desc() emits a bare desc and Postgres orders DESC as NULLS FIRST. Restrict to completed events and order explicitly with NULLS LAST plus a stable tiebreak. The anchor is also no longer load-bearing for idempotence: stale event-level rows for the sports season are cleared before writing, so a re-run whose anchor moved replaces rather than duplicates. A ledger failure could abort finalization. The call sat unguarded after the season was already marked completed, so a throw in any of its queries would skip the standings recalculation and the Discord notification. Guard both call sites the way the probability refresh directly below already is. Rows could be mislabelled permanently. The backfill passed no eventName, and the upsert never rewrote scoringEventName. Derive the label from the scoring pattern inside the writer so omitting it is impossible, and refresh it on conflict so existing rows can be repaired. The backfill damaged unrelated leagues. recalculateStandings rewrites previousRank, so sweeping every season wiped rank-movement arrows league wide, including leagues holding no tie at all. Scope it to seasons drafting from a sports season that actually contains a tied placement, and correct the docblock that called it a pure recompute. Also drops the inert Number.EPSILON guard from calculateAveragedPoints (EPSILON is below the ULP for any value >= 2, and integer averages landing on .5 are exactly representable) and extracts countSharedPlacements so the ledger writer stops re-querying rows it already holds. Every fix is covered by a test confirmed to fail when that fix alone is reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
This commit is contained in:
parent
c48d54d873
commit
75960a8826
11 changed files with 460 additions and 99 deletions
|
|
@ -81,6 +81,19 @@ describe("computeCoronaStates", () => {
|
|||
expect(states.driver).toMatchObject({ type: "scored", points: 70 });
|
||||
});
|
||||
|
||||
it("splits a tied season_standings placement", () => {
|
||||
const states = computeCoronaStates(
|
||||
[pick("driver", "season_standings")],
|
||||
results([{ participantId: "driver", finalPosition: 3 }]),
|
||||
new Map(),
|
||||
RULES,
|
||||
RULES.pointsFor1st,
|
||||
new Map([["ss-1", new Map([[3, 2]])]])
|
||||
);
|
||||
|
||||
expect(states.driver).toMatchObject({ type: "scored", points: 45 });
|
||||
});
|
||||
|
||||
it("marks picks with no result as pending", () => {
|
||||
const states = computeCoronaStates(
|
||||
[pick("rahm", "qualifying_points")],
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
calculateFantasyPoints,
|
||||
calculateAveragedPoints,
|
||||
calculateSharedPlacementPoints,
|
||||
calculatePickPoints,
|
||||
usesSharedPlacementSplit,
|
||||
type ScoringRules,
|
||||
} from "../scoring-rules";
|
||||
|
||||
|
|
@ -124,6 +126,40 @@ describe("Scoring Calculator", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("calculatePickPoints", () => {
|
||||
it("splits ties for every pattern that records them as a repeated position", () => {
|
||||
// Both patterns write the SAME finalPosition to each tied participant, so
|
||||
// both need the tie count. season_standings was omitted for a long time,
|
||||
// which meant tied F1 drivers each banked the full placement value.
|
||||
for (const pattern of ["qualifying_points", "season_standings"]) {
|
||||
expect(usesSharedPlacementSplit(pattern)).toBe(true);
|
||||
expect(
|
||||
calculatePickPoints(3, pattern, DEFAULT_SCORING, { tiedParticipants: 2 })
|
||||
).toBe(45); // (50 + 40) / 2
|
||||
}
|
||||
});
|
||||
|
||||
it("awards the full placement value when untied", () => {
|
||||
expect(
|
||||
calculatePickPoints(3, "season_standings", DEFAULT_SCORING, { tiedParticipants: 1 })
|
||||
).toBe(50);
|
||||
});
|
||||
|
||||
it("derives bracket tiers from the bracket shape, not a tie count", () => {
|
||||
// playoff_bracket ties are structural, so a stray tiedParticipants must not
|
||||
// change the answer.
|
||||
expect(usesSharedPlacementSplit("playoff_bracket")).toBe(false);
|
||||
expect(
|
||||
calculatePickPoints(5, "playoff_bracket", DEFAULT_SCORING, { tiedParticipants: 4 })
|
||||
).toBe(20); // (25 + 25 + 15 + 15) / 4
|
||||
});
|
||||
|
||||
it("falls back to a straight placement lookup for unknown patterns", () => {
|
||||
expect(usesSharedPlacementSplit(null)).toBe(false);
|
||||
expect(calculatePickPoints(2, null, DEFAULT_SCORING)).toBe(70);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playoff Placement Scenarios", () => {
|
||||
it("should handle 8-team single elimination bracket", () => {
|
||||
// Champion: 1st (100 pts)
|
||||
|
|
|
|||
|
|
@ -130,6 +130,34 @@ describe("getTeamScoreBreakdown", () => {
|
|||
expect(breakdown?.picks[0].points).toBe(18);
|
||||
});
|
||||
|
||||
it("splits a tied season_standings placement", async () => {
|
||||
// processSeasonStandings writes the same finalPosition to every tied driver
|
||||
// and leaves the split to scoring time. Two tied at 3rd share 3rd + 4th.
|
||||
const db = makeDb(
|
||||
[makePick("driver", "Tied Driver", "season_standings", 3)],
|
||||
[{ sportsSeasonId: "ss-1", finalPosition: 3 }]
|
||||
);
|
||||
|
||||
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
|
||||
|
||||
expect(breakdown?.picks[0].points).toBe(45); // (50 + 40) / 2
|
||||
});
|
||||
|
||||
it("splits a four-way season_standings tie across the 5-8 tier", async () => {
|
||||
const db = makeDb(
|
||||
[makePick("driver", "Tied Driver", "season_standings", 5)],
|
||||
[
|
||||
{ sportsSeasonId: "ss-1", finalPosition: 5 },
|
||||
{ sportsSeasonId: "ss-1", finalPosition: 5 },
|
||||
{ sportsSeasonId: "ss-1", finalPosition: 5 },
|
||||
]
|
||||
);
|
||||
|
||||
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
|
||||
|
||||
expect(breakdown?.picks[0].points).toBe(20); // (25 + 25 + 15 + 15) / 4
|
||||
});
|
||||
|
||||
it("still averages bracket tiers", async () => {
|
||||
const db = makeDb([makePick("team-a", "Team A", "playoff_bracket", 5)]);
|
||||
|
||||
|
|
@ -158,8 +186,15 @@ describe("getTeamScoreBreakdown", () => {
|
|||
sportsSeasonId: "ss-2",
|
||||
pickNumber: 3,
|
||||
}),
|
||||
makePick("driver", "Tied Driver", "season_standings", 3, {
|
||||
sportsSeasonId: "ss-3",
|
||||
pickNumber: 4,
|
||||
}),
|
||||
];
|
||||
const undrafted = [
|
||||
{ sportsSeasonId: "ss-1", finalPosition: 8 },
|
||||
{ sportsSeasonId: "ss-3", finalPosition: 3 },
|
||||
];
|
||||
const undrafted = [{ sportsSeasonId: "ss-1", finalPosition: 8 }];
|
||||
|
||||
const breakdown = await getTeamScoreBreakdown(
|
||||
"team-1",
|
||||
|
|
@ -173,6 +208,7 @@ describe("getTeamScoreBreakdown", () => {
|
|||
);
|
||||
|
||||
expect(breakdown?.actualPoints).toBe(score.totalPoints);
|
||||
expect(breakdown?.actualPoints).toBe(128); // 8 (T8) + 100 (1st) + 20 (T5-8)
|
||||
// 8 (golf T8) + 100 (golf 1st) + 20 (bracket T5-8) + 45 (F1 T3)
|
||||
expect(breakdown?.actualPoints).toBe(173);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ function makeInsertChain() {
|
|||
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
|
||||
const insert = vi.fn().mockReturnValue({ values });
|
||||
return { insert, values, onConflictDoUpdate };
|
||||
const deleteWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const del = vi.fn().mockReturnValue({ where: deleteWhere });
|
||||
return { insert, values, onConflictDoUpdate, delete: del, deleteWhere };
|
||||
}
|
||||
|
||||
interface MakeDbOpts {
|
||||
|
|
@ -56,6 +58,7 @@ function makeDb(opts: MakeDbOpts = {}) {
|
|||
return {
|
||||
db: {
|
||||
insert: chain.insert,
|
||||
delete: chain.delete,
|
||||
query: {
|
||||
sportsSeasons: {
|
||||
findFirst: vi.fn().mockResolvedValue(sportsSeason),
|
||||
|
|
@ -77,6 +80,8 @@ function makeDb(opts: MakeDbOpts = {}) {
|
|||
},
|
||||
scoringEvents: {
|
||||
findFirst: vi.fn().mockResolvedValue(scoringEvent),
|
||||
// Every event in the sports season, used to scope the pre-write delete.
|
||||
findMany: vi.fn().mockResolvedValue(scoringEvent ? [{ id: scoringEvent.id }] : []),
|
||||
},
|
||||
seasonParticipantResults: {
|
||||
findMany: vi.fn().mockResolvedValue(participantResults),
|
||||
|
|
@ -528,4 +533,89 @@ describe("recordFinalPlacementScoreEvents", () => {
|
|||
expect.objectContaining({ sportName: "F1", pointsDelta: "50" })
|
||||
);
|
||||
});
|
||||
|
||||
it("splits a tied season_standings placement", async () => {
|
||||
// processSeasonStandings gives a tied group the same finalPosition and leaves
|
||||
// the split to scoring time. Two drivers tied at 3rd share 3rd + 4th:
|
||||
// (50 + 40) / 2 = 45.
|
||||
const { db, chain } = makeQpDb({
|
||||
sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any,
|
||||
participantResults: [
|
||||
{ participantId: "driver", finalPosition: 3 },
|
||||
{ participantId: "other-driver", finalPosition: 3 },
|
||||
],
|
||||
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }],
|
||||
});
|
||||
|
||||
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pointsDelta: "45" })
|
||||
);
|
||||
});
|
||||
|
||||
it("anchors to a completed event rather than an incomplete one", async () => {
|
||||
// Postgres orders DESC as NULLS FIRST and drizzle's desc() cannot express
|
||||
// NULLS LAST, so an incomplete event (completedAt NULL) would otherwise win
|
||||
// the anchor. The query must constrain isComplete itself.
|
||||
const { db } = makeQpDb({
|
||||
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
|
||||
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
|
||||
});
|
||||
|
||||
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
||||
|
||||
const anchorQuery = db.query.scoringEvents.findFirst.mock.calls[0][0];
|
||||
expect(anchorQuery.where).toBeDefined();
|
||||
// Ordering must not be a bare desc() on completedAt alone.
|
||||
expect(anchorQuery.orderBy.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("clears the season's existing rows before writing so a moved anchor cannot duplicate", async () => {
|
||||
const { db, chain } = makeQpDb({
|
||||
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
|
||||
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
|
||||
});
|
||||
|
||||
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
||||
|
||||
expect(chain.delete).toHaveBeenCalledTimes(1);
|
||||
expect(chain.deleteWhere).toHaveBeenCalledTimes(1);
|
||||
expect(chain.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("labels rows from the scoring pattern with no caller input", async () => {
|
||||
const qp = makeQpDb({
|
||||
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
|
||||
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
|
||||
});
|
||||
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, qp.db);
|
||||
expect(qp.chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scoringEventName: "Final Standings" })
|
||||
);
|
||||
|
||||
const f1 = makeQpDb({
|
||||
sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any,
|
||||
participantResults: [{ participantId: "driver", finalPosition: 3 }],
|
||||
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }],
|
||||
});
|
||||
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, f1.db);
|
||||
expect(f1.chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scoringEventName: "Season Complete" })
|
||||
);
|
||||
});
|
||||
|
||||
it("rewrites the label on conflict so a stale row can be repaired", async () => {
|
||||
const { db, chain } = makeQpDb({
|
||||
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
|
||||
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
|
||||
});
|
||||
|
||||
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
||||
|
||||
expect(chain.onConflictDoUpdate.mock.calls[0][0].set).toMatchObject({
|
||||
scoringEventName: "Final Standings",
|
||||
sportName: "Golf",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, inArray, asc } from "drizzle-orm";
|
||||
import { getScoringRules, calculatePickPoints } from "./scoring-rules";
|
||||
import {
|
||||
getScoringRules,
|
||||
calculatePickPoints,
|
||||
usesSharedPlacementSplit,
|
||||
} from "./scoring-rules";
|
||||
import {
|
||||
getSharedPlacementCounts,
|
||||
lookupSharedPlacementCount,
|
||||
|
|
@ -158,18 +162,23 @@ export async function getDraftedParticipantsWithPoints(
|
|||
const bracketSeasonIds = new Set<string>();
|
||||
const qpSeasonIds = 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) {
|
||||
const pattern = pick.participant.sportsSeason.scoringPattern;
|
||||
const ssId = pick.participant.sportsSeasonId;
|
||||
const finalPosition = pick.participant.results[0]?.finalPosition;
|
||||
if (pattern === "playoff_bracket") bracketSeasonIds.add(ssId);
|
||||
if (pattern === "qualifying_points") {
|
||||
// Accumulated QP is a qualifying_points-only concept — no F1 equivalent.
|
||||
qpSeasonIds.add(ssId);
|
||||
qpParticipantIds.add(pick.participant.id);
|
||||
if (pick.participant.results[0]?.finalPosition !== null && pick.participant.results[0]?.finalPosition !== undefined) {
|
||||
finalizedQPSeasonIds.add(ssId);
|
||||
}
|
||||
if (usesSharedPlacementSplit(pattern) && finalPosition !== null && finalPosition !== undefined) {
|
||||
tieSplitSeasonIds.add(ssId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,10 +211,7 @@ export async function getDraftedParticipantsWithPoints(
|
|||
}
|
||||
}
|
||||
|
||||
const qpSharedPlacementCounts = await getSharedPlacementCounts(
|
||||
[...finalizedQPSeasonIds],
|
||||
db
|
||||
);
|
||||
const sharedPlacementCounts = await getSharedPlacementCounts([...tieSplitSeasonIds], db);
|
||||
|
||||
// Assemble result grouped by sportsSeasonId
|
||||
const result = new Map<string, DraftedParticipantWithPoints[]>();
|
||||
|
|
@ -226,7 +232,7 @@ export async function getDraftedParticipantsWithPoints(
|
|||
earnedPoints = calculatePickPoints(resultRow.finalPosition, pattern, scoringRules, {
|
||||
bracketTemplateId: bracketTemplateMap.get(sportsSeasonId) ?? null,
|
||||
tiedParticipants: lookupSharedPlacementCount(
|
||||
qpSharedPlacementCounts,
|
||||
sharedPlacementCounts,
|
||||
sportsSeasonId,
|
||||
resultRow.finalPosition
|
||||
),
|
||||
|
|
|
|||
|
|
@ -77,30 +77,18 @@ export async function findParticipantResultsBySportsSeasonId(
|
|||
}
|
||||
|
||||
/**
|
||||
* How many participants share each scoring placement, per sports season.
|
||||
* Tallies how many participants share each scoring placement, per sports season.
|
||||
*
|
||||
* Returns sportsSeasonId → (finalPosition → count). Used to split a tied
|
||||
* placement's points across the tied participants (see calculatePickPoints).
|
||||
* Pure counterpart to getSharedPlacementCounts, for callers that already hold the
|
||||
* result rows. Split out so there is exactly one definition of what "tied" means
|
||||
* — the whole point of this module is that every screen counts ties identically.
|
||||
*
|
||||
* The count spans EVERY result in the sports season, not just drafted ones — a
|
||||
* golfer tied for 8th with an undrafted player still only earns half the 8th
|
||||
* place points, so narrowing this query to drafted participants would silently
|
||||
* over-award. Positions <= 0 (no scoring placement) are excluded.
|
||||
*
|
||||
* Callers that look up a position with no entry should treat it as 1 (no tie).
|
||||
* Positions <= 0 (no scoring placement) are excluded.
|
||||
*/
|
||||
export async function getSharedPlacementCounts(
|
||||
sportsSeasonIds: string[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<Map<string, Map<number, number>>> {
|
||||
export function countSharedPlacements(
|
||||
rows: Array<{ sportsSeasonId: string; finalPosition: number | null }>
|
||||
): 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) {
|
||||
if (row.finalPosition === null || row.finalPosition <= 0) continue;
|
||||
|
|
@ -115,6 +103,34 @@ export async function getSharedPlacementCounts(
|
|||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many participants share each scoring placement, per sports season.
|
||||
*
|
||||
* Returns sportsSeasonId → (finalPosition → count). Used to split a tied
|
||||
* placement's points across the tied participants (see calculatePickPoints).
|
||||
*
|
||||
* The count spans EVERY result in the sports season, not just drafted ones — a
|
||||
* golfer tied for 8th with an undrafted player still only earns half the 8th
|
||||
* place points, so narrowing this query to drafted participants would silently
|
||||
* over-award.
|
||||
*
|
||||
* Callers that look up a position with no entry should treat it as 1 (no tie).
|
||||
*/
|
||||
export async function getSharedPlacementCounts(
|
||||
sportsSeasonIds: string[],
|
||||
providedDb?: ReturnType<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
|
||||
* no other participant shares the placement, so the tie count is 1.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { getScoringRules, calculatePickPoints, type ScoringRules } from "./scoring-rules";
|
||||
import {
|
||||
getScoringRules,
|
||||
calculatePickPoints,
|
||||
usesSharedPlacementSplit,
|
||||
type ScoringRules,
|
||||
} from "./scoring-rules";
|
||||
import { getSharedPlacementCounts } from "./participant-result";
|
||||
import { getSeasonResults } from "./participant-season-result";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
|
|
@ -1175,10 +1180,17 @@ export async function finalizeQualifyingPoints(
|
|||
|
||||
// Ledger the placement points so this season shows up in Recent Scores —
|
||||
// QP seasons award everything here, with no per-match deltas to record.
|
||||
await recordFinalPlacementScoreEvents(
|
||||
{ sportsSeasonId, eventName: "Final Standings" },
|
||||
db
|
||||
// Guarded like the probability refresh below: the season is already marked
|
||||
// completed at this point, so letting a ledger failure escape would abort
|
||||
// finalization before standings recalculate and no Discord update would fire.
|
||||
try {
|
||||
await recordFinalPlacementScoreEvents({ sportsSeasonId }, db);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ScoringCalculator] Failed to record final placement score events for sports season ${sportsSeasonId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger recalculation for all affected leagues
|
||||
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" });
|
||||
|
|
@ -1285,10 +1297,15 @@ export async function processSeasonStandings(
|
|||
|
||||
// Ledger the placement points — season_standings has the same one-shot award
|
||||
// shape as qualifying_points and was likewise absent from Recent Scores.
|
||||
await recordFinalPlacementScoreEvents(
|
||||
{ sportsSeasonId, eventName: "Season Complete" },
|
||||
db
|
||||
// Guarded so a ledger failure cannot abort finalization (see above).
|
||||
try {
|
||||
await recordFinalPlacementScoreEvents({ sportsSeasonId }, db);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ScoringCalculator] Failed to record final placement score events for sports season ${sportsSeasonId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger recalculation for all affected leagues
|
||||
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });
|
||||
|
|
@ -1391,8 +1408,7 @@ export async function calculateTeamScore(
|
|||
pattern === "playoff_bracket"
|
||||
? await getBracketTemplate(pick.participant.sportsSeasonId)
|
||||
: null,
|
||||
tiedParticipants:
|
||||
pattern === "qualifying_points"
|
||||
tiedParticipants: usesSharedPlacementSplit(pattern)
|
||||
? await getSharedPlacementCount(
|
||||
pick.participant.sportsSeasonId,
|
||||
result.finalPosition,
|
||||
|
|
@ -1500,8 +1516,7 @@ export async function calculateTeamProjectedScore(
|
|||
return calculatePickPoints(finalPosition, pattern, rules, {
|
||||
bracketTemplateId:
|
||||
pattern === "playoff_bracket" ? await getBracketTemplate(sportsSeasonId) : null,
|
||||
tiedParticipants:
|
||||
pattern === "qualifying_points"
|
||||
tiedParticipants: usesSharedPlacementSplit(pattern)
|
||||
? await getSharedPlacementCount(
|
||||
sportsSeasonId,
|
||||
finalPosition,
|
||||
|
|
|
|||
|
|
@ -108,9 +108,7 @@ export function calculateAveragedPoints(
|
|||
return sum + calculateFantasyPoints(placement, rules);
|
||||
}, 0);
|
||||
|
||||
// Epsilon guard mirrors roundQualifyingPoints — keeps values that are exactly
|
||||
// representable-adjacent (e.g. 18.499999999999996) from rounding the wrong way.
|
||||
return Math.round(total / placements.length + Number.EPSILON);
|
||||
return Math.round(total / placements.length);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -199,11 +197,41 @@ export function calculateBracketPoints(
|
|||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoring patterns whose participants can share a final placement, so that a tied
|
||||
* group splits the combined points of the positions it spans.
|
||||
*
|
||||
* Both patterns record ties by writing the SAME finalPosition to every tied
|
||||
* participant, leaving the split to scoring time:
|
||||
* - qualifying_points — finalizeQualifyingPoints groups participants by QP total
|
||||
* - season_standings — processSeasonStandings gives a tied group the first
|
||||
* placement in its range ("if 4 people tie for 5th they all get placement 5")
|
||||
*
|
||||
* playoff_bracket is deliberately absent: its ties are structural (both SF losers
|
||||
* tie for 3rd) and calculateBracketPoints derives the span from the bracket shape
|
||||
* rather than from a count of results.
|
||||
*/
|
||||
const TIE_SPLIT_PATTERNS = new Set(["qualifying_points", "season_standings"]);
|
||||
|
||||
/**
|
||||
* Whether a scoring pattern needs a tie count to score a placement correctly.
|
||||
*
|
||||
* Callers must gate their tie-count lookups on this rather than testing a pattern
|
||||
* name directly. A caller that hardcodes one pattern silently passes
|
||||
* tiedParticipants: 1 for the other, which reads as "no tie" and awards the full
|
||||
* placement value — the failure mode that left F1 ties unsplit.
|
||||
*/
|
||||
export function usesSharedPlacementSplit(
|
||||
scoringPattern: string | null | undefined
|
||||
): boolean {
|
||||
return TIE_SPLIT_PATTERNS.has(scoringPattern ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fantasy points earned by a single drafted participant, for any scoring pattern.
|
||||
*
|
||||
* This is the ONE place the bracket / qualifying_points / default cascade lives.
|
||||
* It previously existed as a hand-rolled if/else at six call sites, two of which
|
||||
* This is the ONE place the bracket / tie-split / default cascade lives. It
|
||||
* previously existed as a hand-rolled if/else at six call sites, two of which
|
||||
* were missing the qualifying_points arm entirely — that divergence is what made
|
||||
* a tied golfer worth 15 points on the team page and draft board but 7.5 in the
|
||||
* standings. Every caller must route through here.
|
||||
|
|
@ -213,9 +241,10 @@ export function calculateBracketPoints(
|
|||
* @param rules - The fantasy season's point values.
|
||||
* @param opts.bracketTemplateId - Required for playoff_bracket to pick the right
|
||||
* tier structure (e.g. AFL/LLWS split 5–8 into two pairs).
|
||||
* @param opts.tiedParticipants - Required for qualifying_points: how many
|
||||
* participants share this finalPosition across the WHOLE sports season, not
|
||||
* just the ones that were drafted. Defaults to 1 (no tie).
|
||||
* @param opts.tiedParticipants - Required for any pattern where
|
||||
* usesSharedPlacementSplit is true: how many participants share this
|
||||
* finalPosition across the WHOLE sports season, not just the ones that were
|
||||
* drafted. Defaults to 1 (no tie).
|
||||
*/
|
||||
export function calculatePickPoints(
|
||||
finalPosition: number,
|
||||
|
|
@ -226,7 +255,7 @@ export function calculatePickPoints(
|
|||
if (scoringPattern === "playoff_bracket") {
|
||||
return calculateBracketPoints(finalPosition, rules, opts?.bracketTemplateId ?? null);
|
||||
}
|
||||
if (scoringPattern === "qualifying_points") {
|
||||
if (usesSharedPlacementSplit(scoringPattern)) {
|
||||
return calculateSharedPlacementPoints(
|
||||
finalPosition,
|
||||
opts?.tiedParticipants ?? 1,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, inArray, desc, sql, and } from "drizzle-orm";
|
||||
import { eq, inArray, desc, sql, and, isNull } from "drizzle-orm";
|
||||
import {
|
||||
calculateBracketPoints,
|
||||
calculatePickPoints,
|
||||
type ScoringRules,
|
||||
} from "~/models/scoring-rules";
|
||||
import {
|
||||
getSharedPlacementCounts,
|
||||
countSharedPlacements,
|
||||
lookupSharedPlacementCount,
|
||||
} from "~/models/participant-result";
|
||||
import { findParticipantNamesByIds } from "~/models/season-participant";
|
||||
|
|
@ -86,6 +86,10 @@ export async function recordTeamScoreEvent(
|
|||
set: {
|
||||
participantIds: params.participantIds,
|
||||
pointsDelta: params.pointsDelta.toString(),
|
||||
// Refreshed too, so a row written with a stale label can be repaired
|
||||
// by a later correct run instead of keeping the wrong name forever.
|
||||
scoringEventName: params.scoringEventName,
|
||||
sportName: params.sportName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -198,6 +202,17 @@ export async function recordMatchScoreEvents(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ledger label for a one-shot finalization, derived from the scoring pattern so
|
||||
* every caller agrees. Deriving it here rather than requiring a parameter is
|
||||
* deliberate: recordTeamScoreEvent's upsert can rewrite the label, but a caller
|
||||
* that simply forgets to pass one would otherwise stamp rows with the anchor
|
||||
* event's own name ("The Open") instead of "Final Standings".
|
||||
*/
|
||||
function defaultEventName(scoringPattern: string | null | undefined): string {
|
||||
return scoringPattern === "season_standings" ? "Season Complete" : "Final Standings";
|
||||
}
|
||||
|
||||
/**
|
||||
* Records ledger rows for a sports season whose final placements have just been
|
||||
* assigned — qualifying_points (golf, tennis, CS2) and season_standings (F1).
|
||||
|
|
@ -212,12 +227,15 @@ export async function recordMatchScoreEvents(
|
|||
* calculatePickPoints, so a tied golfer contributes the same split award the
|
||||
* standings show.
|
||||
*
|
||||
* `eventId` anchors the row and MUST resolve to a real scoring event: the
|
||||
* event-level unique index is (teamId, seasonId, scoringEventId) and Postgres
|
||||
* treats NULLs as distinct, so a null anchor would silently duplicate rows on
|
||||
* every re-finalization instead of upserting. If no anchor can be found the
|
||||
* ledger write is skipped rather than risking duplicates — standings are
|
||||
* Every row is anchored to a real scoring event, because the event-level unique
|
||||
* index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as
|
||||
* distinct — a null anchor would defeat the upsert entirely. If no anchor can be
|
||||
* found the ledger write is skipped rather than risking duplicates; standings are
|
||||
* unaffected either way.
|
||||
*
|
||||
* Re-running is safe even if the anchor moves (a later event completes, or the
|
||||
* backfill runs against changed data). Stale rows for this sports season are
|
||||
* cleared before writing, so the upsert alone is not load-bearing.
|
||||
*/
|
||||
export async function recordFinalPlacementScoreEvents(
|
||||
params: {
|
||||
|
|
@ -238,29 +256,40 @@ export async function recordFinalPlacementScoreEvents(
|
|||
|
||||
// Resolve the anchor event: the caller's, else the most recently completed
|
||||
// event for this sports season.
|
||||
//
|
||||
// The completed filter is not cosmetic. drizzle's desc() emits a bare `desc`,
|
||||
// and Postgres orders DESC as NULLS FIRST, so ordering on completedAt alone
|
||||
// would rank a never-completed event (common for sibling major windows) above
|
||||
// every finished one. Restrict to completed events and order explicitly.
|
||||
let eventId = params.eventId ?? null;
|
||||
let eventName = params.eventName ?? null;
|
||||
const eventName = params.eventName ?? defaultEventName(sportsSeason.scoringPattern);
|
||||
if (!eventId) {
|
||||
const anchor = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId),
|
||||
columns: { id: true, name: true },
|
||||
orderBy: [desc(schema.scoringEvents.completedAt), desc(schema.scoringEvents.createdAt)],
|
||||
where: and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId),
|
||||
eq(schema.scoringEvents.isComplete, true)
|
||||
),
|
||||
columns: { id: true },
|
||||
orderBy: [
|
||||
sql`${schema.scoringEvents.completedAt} DESC NULLS LAST`,
|
||||
sql`${schema.scoringEvents.eventDate} DESC NULLS LAST`,
|
||||
desc(schema.scoringEvents.id),
|
||||
],
|
||||
});
|
||||
if (!anchor) {
|
||||
logger.warn(
|
||||
`[TeamScoreEvents] No scoring event to anchor final placements for sports season ${params.sportsSeasonId}; skipping ledger write`
|
||||
`[TeamScoreEvents] No completed scoring event to anchor final placements for sports season ${params.sportsSeasonId}; skipping ledger write`
|
||||
);
|
||||
return;
|
||||
}
|
||||
eventId = anchor.id;
|
||||
eventName = eventName ?? anchor.name;
|
||||
}
|
||||
if (!eventId) return;
|
||||
|
||||
// Scoring placements for this sports season, plus the tie spans they imply.
|
||||
// Counted from these same rows rather than re-querying them.
|
||||
const results = await db.query.seasonParticipantResults.findMany({
|
||||
where: eq(schema.seasonParticipantResults.sportsSeasonId, params.sportsSeasonId),
|
||||
columns: { participantId: true, finalPosition: true },
|
||||
columns: { sportsSeasonId: true, participantId: true, finalPosition: true },
|
||||
});
|
||||
const positionByParticipantId = new Map<string, number>();
|
||||
for (const row of results) {
|
||||
|
|
@ -270,7 +299,7 @@ export async function recordFinalPlacementScoreEvents(
|
|||
}
|
||||
if (positionByParticipantId.size === 0) return;
|
||||
|
||||
const sharedPlacementCounts = await getSharedPlacementCounts([params.sportsSeasonId], db);
|
||||
const sharedPlacementCounts = countSharedPlacements(results);
|
||||
|
||||
const seasonSports = await db.query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId),
|
||||
|
|
@ -343,6 +372,30 @@ export async function recordFinalPlacementScoreEvents(
|
|||
byTeam.set(key, entry);
|
||||
}
|
||||
|
||||
// Clear this sports season's existing event-level rows before writing, so a
|
||||
// re-run whose anchor landed on a different event replaces rather than adds.
|
||||
// Scoped to match_id IS NULL, which is what makes this safe: one-shot patterns
|
||||
// write no other event-level rows (QP majors award qualifying points, not
|
||||
// fantasy points), and qualifying-bracket matches carry a non-null matchId.
|
||||
const eventIds = await db.query.scoringEvents.findMany({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (eventIds.length > 0) {
|
||||
await db
|
||||
.delete(schema.teamScoreEvents)
|
||||
.where(
|
||||
and(
|
||||
isNull(schema.teamScoreEvents.matchId),
|
||||
inArray(
|
||||
schema.teamScoreEvents.scoringEventId,
|
||||
eventIds.map((e) => e.id)
|
||||
),
|
||||
inArray(schema.teamScoreEvents.seasonId, seasonIds)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const entry of byTeam.values()) {
|
||||
try {
|
||||
await recordTeamScoreEvent(
|
||||
|
|
|
|||
|
|
@ -15,10 +15,15 @@
|
|||
* Scores. recordFinalPlacementScoreEvents now writes them at
|
||||
* finalization, but only for seasons finalized from here on.
|
||||
*
|
||||
* This re-runs both for every already-finalized sports season. Ledger rows are
|
||||
* upserted on (team, season, scoring event), so re-running rewrites rather than
|
||||
* duplicates. Standings recalculation is likewise a pure recompute from
|
||||
* participant results.
|
||||
* The ledger pass covers every already-finalized one-shot sports season and is
|
||||
* safe to repeat: recordFinalPlacementScoreEvents clears that season's existing
|
||||
* event-level rows before writing.
|
||||
*
|
||||
* The standings pass is deliberately narrow. recalculateStandings rewrites
|
||||
* previousRank, so any season it touches loses its rank-movement arrows until the
|
||||
* next scoring event — it is NOT a pure recompute. Only leagues drafting from a
|
||||
* sports season that actually contains a tied placement are recalculated; every
|
||||
* other league is left alone.
|
||||
*
|
||||
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
|
||||
*
|
||||
|
|
@ -28,11 +33,12 @@
|
|||
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { inArray, eq } from "drizzle-orm";
|
||||
import * as schema from "../database/schema.js";
|
||||
import { DatabaseContext, database } from "../database/context.js";
|
||||
import { recalculateStandings } from "../app/models/scoring-calculator.js";
|
||||
import { recordFinalPlacementScoreEvents } from "../app/models/team-score-events.js";
|
||||
import { countSharedPlacements } from "../app/models/participant-result.js";
|
||||
|
||||
const DRY = process.argv.includes("--dry");
|
||||
const log = (...a: unknown[]) => console.log(...a);
|
||||
|
|
@ -83,22 +89,62 @@ async function run() {
|
|||
}
|
||||
}
|
||||
|
||||
// Standings pass: every fantasy season, so rounded awards land in stored
|
||||
// totals. Cheap enough to run unconditionally and avoids trying to guess
|
||||
// which seasons contain a tie.
|
||||
const seasons = await db.query.seasons.findMany({ columns: { id: true, year: true } });
|
||||
log(`\nFantasy seasons to recalculate: ${seasons.length}`);
|
||||
// Standings pass, restricted to leagues that can actually change.
|
||||
//
|
||||
// recalculateStandings is NOT a pure recompute: it sets previousRank =
|
||||
// currentRank, so every season it touches loses its rank-movement arrows until
|
||||
// the next real scoring event. Sweeping all seasons would spend that cost on
|
||||
// leagues holding no tied placement at all, so scope it to sports seasons that
|
||||
// genuinely have a tie, then to the fantasy seasons drafting from them.
|
||||
const allResults = await db.query.seasonParticipantResults.findMany({
|
||||
columns: { sportsSeasonId: true, finalPosition: true },
|
||||
});
|
||||
const tiedSportsSeasonIds = new Set<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 recalcFailed = 0;
|
||||
for (const season of seasons) {
|
||||
if (DRY) continue;
|
||||
for (const seasonId of affectedSeasonIds) {
|
||||
if (DRY) {
|
||||
log(` (dry) season ${seasonId} — would recalculate standings`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await recalculateStandings(season.id, db);
|
||||
await recalculateStandings(seasonId, db);
|
||||
recalculated += 1;
|
||||
} catch (e) {
|
||||
recalcFailed += 1;
|
||||
log(` ! season ${season.id} (${season.year}): ${(e as Error).message}`);
|
||||
log(` ! season ${seasonId}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue