claude/points-split-rounding-inconsistency-7yn7xp #141
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,15 +1408,14 @@ export async function calculateTeamScore(
|
|||
pattern === "playoff_bracket"
|
||||
? await getBracketTemplate(pick.participant.sportsSeasonId)
|
||||
: null,
|
||||
tiedParticipants:
|
||||
pattern === "qualifying_points"
|
||||
? await getSharedPlacementCount(
|
||||
pick.participant.sportsSeasonId,
|
||||
result.finalPosition,
|
||||
db,
|
||||
sharedPlacementCountCache
|
||||
)
|
||||
: 1,
|
||||
tiedParticipants: usesSharedPlacementSplit(pattern)
|
||||
? await getSharedPlacementCount(
|
||||
pick.participant.sportsSeasonId,
|
||||
result.finalPosition,
|
||||
db,
|
||||
sharedPlacementCountCache
|
||||
)
|
||||
: 1,
|
||||
});
|
||||
totalPoints += points;
|
||||
|
||||
|
|
@ -1500,15 +1516,14 @@ export async function calculateTeamProjectedScore(
|
|||
return calculatePickPoints(finalPosition, pattern, rules, {
|
||||
bracketTemplateId:
|
||||
pattern === "playoff_bracket" ? await getBracketTemplate(sportsSeasonId) : null,
|
||||
tiedParticipants:
|
||||
pattern === "qualifying_points"
|
||||
? await getSharedPlacementCount(
|
||||
sportsSeasonId,
|
||||
finalPosition,
|
||||
db,
|
||||
sharedPlacementCountCache
|
||||
)
|
||||
: 1,
|
||||
tiedParticipants: usesSharedPlacementSplit(pattern)
|
||||
? await getSharedPlacementCount(
|
||||
sportsSeasonId,
|
||||
finalPosition,
|
||||
db,
|
||||
sharedPlacementCountCache
|
||||
)
|
||||
: 1,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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