claude/points-split-rounding-inconsistency-7yn7xp #141

Open
chrisp wants to merge 4 commits from claude/points-split-rounding-inconsistency-7yn7xp into main
16 changed files with 1419 additions and 153 deletions

View file

@ -0,0 +1,135 @@
import { describe, it, expect } from "vitest";
import { computeCoronaStates } from "../corona-states";
const RULES = {
pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40,
pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15,
};
function pick(id: string, scoringPattern: string | null, sportsSeasonId = "ss-1") {
return { participant: { id, sportsSeasonId }, scoringPattern };
}
function results(
rows: Array<{ participantId: string; finalPosition: number | null; isPartialScore?: boolean }>
) {
return new Map(
rows.map((r) => [r.participantId, { isPartialScore: false, ...r }])
);
}
describe("computeCoronaStates", () => {
const noTies = new Map<string, Map<number, number>>();
it("splits points for a tied qualifying_points pick", () => {
// Rahm tied for 8th. 9th is outside the scoring range and contributes 0,
// so (15 + 0) / 2 = 7.5 → 8. Before the shared helper this path awarded the
// full 15 while the standings awarded the split — the reported bug.
const states = computeCoronaStates(
[pick("rahm", "qualifying_points")],
results([{ participantId: "rahm", finalPosition: 8 }]),
new Map(),
RULES,
RULES.pointsFor1st,
new Map([["ss-1", new Map([[8, 2]])]])
);
expect(states.rahm).toEqual({
type: "scored",
points: 8,
brightness: 8 / 100,
});
});
it("awards the full value when the placement is untied", () => {
const states = computeCoronaStates(
[pick("rahm", "qualifying_points")],
results([{ participantId: "rahm", finalPosition: 8 }]),
new Map(),
RULES,
RULES.pointsFor1st,
noTies
);
expect(states.rahm).toMatchObject({ type: "scored", points: 15 });
});
it("still averages bracket tiers via the bracket template", () => {
const states = computeCoronaStates(
[pick("team-a", "playoff_bracket")],
results([{ participantId: "team-a", finalPosition: 5 }]),
new Map([["ss-1", null]]),
RULES,
RULES.pointsFor1st,
noTies
);
// Four QF losers share 5th-8th: (25 + 25 + 15 + 15) / 4 = 20
expect(states["team-a"]).toMatchObject({ type: "scored", points: 20 });
});
it("scores other patterns straight off the placement", () => {
const states = computeCoronaStates(
[pick("driver", "season_standings")],
results([{ participantId: "driver", finalPosition: 2 }]),
new Map(),
RULES,
RULES.pointsFor1st,
noTies
);
expect(states.driver).toMatchObject({ type: "scored", points: 70 });
});
it("splits a tied season_standings placement", () => {
const states = computeCoronaStates(
[pick("driver", "season_standings")],
results([{ participantId: "driver", finalPosition: 3 }]),
new Map(),
RULES,
RULES.pointsFor1st,
new Map([["ss-1", new Map([[3, 2]])]])
);
expect(states.driver).toMatchObject({ type: "scored", points: 45 });
});
it("marks picks with no result as pending", () => {
const states = computeCoronaStates(
[pick("rahm", "qualifying_points")],
results([]),
new Map(),
RULES,
RULES.pointsFor1st,
noTies
);
expect(states.rahm).toEqual({ type: "pending" });
});
it("marks a finalized zero placement as eliminated", () => {
const states = computeCoronaStates(
[pick("rahm", "qualifying_points")],
results([{ participantId: "rahm", finalPosition: 0 }]),
new Map(),
RULES,
RULES.pointsFor1st,
noTies
);
expect(states.rahm).toEqual({ type: "eliminated", points: 0 });
});
it("keeps brightness within range when a tier exceeds maxPoints", () => {
const states = computeCoronaStates(
[pick("champ", "season_standings")],
results([{ participantId: "champ", finalPosition: 1 }]),
new Map(),
RULES,
10, // deliberately below the 1st-place award
noTies
);
expect(states.champ).toMatchObject({ brightness: 1 });
});
});

View file

@ -1,4 +1,4 @@
import { calculateFantasyPoints, calculateBracketPoints } from "~/models/scoring-rules";
import { calculatePickPoints } from "~/models/scoring-rules";
export type CoronaState =
| { type: "eliminated"; points: 0 }
@ -27,12 +27,22 @@ interface ScoringRules {
pointsFor8th: number;
}
/**
* Corona badge state for every pick on the draft board.
*
* @param sharedPlacementCountsBySportsSeason - sportsSeasonId (finalPosition
* how many participants share it), from getSharedPlacementCounts. Required for
* qualifying_points picks: a golfer tied for 8th earns the split award, and
* omitting this would show him at full value here while the standings show the
* split the exact inconsistency this parameter exists to prevent.
*/
export function computeCoronaStates(
picks: PickEntry[],
resultByParticipant: Map<string, ResultEntry>,
bracketTemplateBySportsSeason: Map<string, string | null>,
scoringRules: ScoringRules,
maxPoints: number,
sharedPlacementCountsBySportsSeason: Map<string, Map<number, number>>,
): Record<string, CoronaState> {
const coronaStates: Record<string, CoronaState> = {};
@ -50,13 +60,19 @@ export function computeCoronaStates(
}
if (result.finalPosition > 0) {
const isBracket = pick.scoringPattern === "playoff_bracket";
const templateId = isBracket
? (bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null)
: null;
const points = isBracket
? calculateBracketPoints(result.finalPosition, scoringRules, templateId)
: calculateFantasyPoints(result.finalPosition, scoringRules);
const points = calculatePickPoints(
result.finalPosition,
pick.scoringPattern,
scoringRules,
{
bracketTemplateId:
bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null,
tiedParticipants:
sharedPlacementCountsBySportsSeason
.get(pick.participant.sportsSeasonId)
?.get(result.finalPosition) ?? 1,
}
);
const brightness = maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0;
coronaStates[pick.participant.id] = { type: "scored", brightness, points };
continue;

View file

@ -204,7 +204,7 @@ describe("getDraftedParticipantsWithPoints", () => {
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(result.get("ss-1")?.[0]).toMatchObject({
earnedPoints: 62.5, // (75 + 50) / 2
earnedPoints: 63, // (75 + 50) / 2 = 62.5 → 63
currentQP: null,
});
});
@ -223,6 +223,27 @@ describe("getDraftedParticipantsWithPoints", () => {
currentQP: null,
});
});
it("splits earnedPoints for tied season_standings placements", async () => {
// Guards the caller-side gate: this collection loop decides which sports
// seasons get a tie count. Gating it on qualifying_points alone silently
// passes tiedParticipants: 1 here, which reads as "no tie" and awards the
// full 50 instead of the split 45.
const db = makeDb({
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "season_standings", finalPosition: 3 })],
seasonParticipantResults: [
{ sportsSeasonId: "ss-1", finalPosition: 3 },
{ sportsSeasonId: "ss-1", finalPosition: 3 },
],
});
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(result.get("ss-1")?.[0]).toMatchObject({
earnedPoints: 45, // (50 + 40) / 2
currentQP: null,
});
});
});
it("does not query scoringEvents when no playoff_bracket picks", async () => {

View file

@ -3,6 +3,8 @@ import {
calculateFantasyPoints,
calculateAveragedPoints,
calculateSharedPlacementPoints,
calculatePickPoints,
usesSharedPlacementSplit,
type ScoringRules,
} from "../scoring-rules";
@ -71,8 +73,8 @@ describe("Scoring Calculator", () => {
it("should average points for three-way tie", () => {
const points = calculateAveragedPoints([1, 2, 3], DEFAULT_SCORING);
// (100 + 70 + 50) / 3 = 73.33...
expect(points).toBeCloseTo(73.33, 2);
// (100 + 70 + 50) / 3 = 73.33... → 73 (nearest whole point)
expect(points).toBe(73);
});
it("should handle single placement (no tie)", () => {
@ -109,7 +111,52 @@ describe("Scoring Calculator", () => {
});
it("treats positions beyond 8th as zero at the scoring cutoff", () => {
expect(calculateSharedPlacementPoints(8, 2, DEFAULT_SCORING)).toBe(7.5);
// Two tied for 8th share 8th + 9th; 9th is outside the scoring range and
// contributes 0, so (15 + 0) / 2 = 7.5 → 8.
expect(calculateSharedPlacementPoints(8, 2, DEFAULT_SCORING)).toBe(8);
});
// The /rules page (app/routes/rules.tsx) publishes these two examples to
// players. They are asserted here so the code and the published rule cannot
// drift apart — note the second rounds DOWN, confirming "nearest whole
// point" rather than always rounding up.
it("matches the tie examples published on the rules page", () => {
expect(calculateSharedPlacementPoints(2, 2, DEFAULT_SCORING)).toBe(60);
expect(calculateSharedPlacementPoints(6, 3, DEFAULT_SCORING)).toBe(18);
});
});
describe("calculatePickPoints", () => {
it("splits ties for every pattern that records them as a repeated position", () => {
// Both patterns write the SAME finalPosition to each tied participant, so
// both need the tie count. season_standings was omitted for a long time,
// which meant tied F1 drivers each banked the full placement value.
for (const pattern of ["qualifying_points", "season_standings"]) {
expect(usesSharedPlacementSplit(pattern)).toBe(true);
expect(
calculatePickPoints(3, pattern, DEFAULT_SCORING, { tiedParticipants: 2 })
).toBe(45); // (50 + 40) / 2
}
});
it("awards the full placement value when untied", () => {
expect(
calculatePickPoints(3, "season_standings", DEFAULT_SCORING, { tiedParticipants: 1 })
).toBe(50);
});
it("derives bracket tiers from the bracket shape, not a tie count", () => {
// playoff_bracket ties are structural, so a stray tiedParticipants must not
// change the answer.
expect(usesSharedPlacementSplit("playoff_bracket")).toBe(false);
expect(
calculatePickPoints(5, "playoff_bracket", DEFAULT_SCORING, { tiedParticipants: 4 })
).toBe(20); // (25 + 25 + 15 + 15) / 4
});
it("falls back to a straight placement lookup for unknown patterns", () => {
expect(usesSharedPlacementSplit(null)).toBe(false);
expect(calculatePickPoints(2, null, DEFAULT_SCORING)).toBe(70);
});
});
@ -150,8 +197,8 @@ describe("Scoring Calculator", () => {
describe("Edge Cases", () => {
it("should handle all participants tying for 1st-8th", () => {
const points = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5
expect(points).toBe(42.5);
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43
expect(points).toBe(43);
});
it("should handle placements with same point values", () => {

View file

@ -41,8 +41,8 @@ describe("Season Standings (F1 Pattern)", () => {
it("should handle 3-way tie for 5th place", () => {
// Three drivers tied for 5th share 5th, 6th, and 7th place points
const points = calculateAveragedPoints([5, 6, 7], DEFAULT_SCORING);
// (25 + 25 + 15) / 3 = 21.67
expect(points).toBeCloseTo(21.67, 2);
// (25 + 25 + 15) / 3 = 21.67 → 22 (nearest whole point)
expect(points).toBe(22);
});
it("should handle 4-way tie for 1st place", () => {
@ -100,7 +100,7 @@ describe("Season Standings (F1 Pattern)", () => {
// Verify tied positions get averaged
const tiedPoints = calculateAveragedPoints([3, 4, 5], DEFAULT_SCORING);
expect(tiedPoints).toBeCloseTo(38.33, 2); // (50 + 40 + 25) / 3
expect(tiedPoints).toBe(38); // (50 + 40 + 25) / 3 = 38.33 → 38
// Verify remaining positions
expect(calculateFantasyPoints(6, DEFAULT_SCORING)).toBe(25);
@ -143,7 +143,7 @@ describe("Season Standings (F1 Pattern)", () => {
// Tied positions
const tied3rd = calculateAveragedPoints([3, 4], customScoring);
expect(tied3rd).toBe(67.5); // (75 + 60) / 2
expect(tied3rd).toBe(68); // (75 + 60) / 2 = 67.5 → 68 (halves round up)
});
});
@ -151,8 +151,8 @@ describe("Season Standings (F1 Pattern)", () => {
it("should handle all 8 positions tied", () => {
// Extremely unlikely but mathematically possible
const allTied = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5
expect(allTied).toBe(42.5);
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43
expect(allTied).toBe(43);
});
it("should handle only top 4 finishing (others DNF/DQ)", () => {

View file

@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Regression tests for getTeamScoreBreakdown the team detail page's points.
*
* This path used to branch only on `isBracket`, so a qualifying_points
* participant tied for a placement was awarded the FULL placement value here
* while calculateTeamScore (which writes team_standings) awarded the split. A
* team therefore read 225 on its team page and 218 in the standings.
*
* The load-bearing assertion is the last one: the breakdown's actualPoints must
* equal calculateTeamScore's totalPoints for the same fixture. Anything that
* makes these two disagree reintroduces the bug.
*/
vi.mock("~/services/ev-calculator", () => ({
calculateEV: vi.fn(),
}));
vi.mock("../participant-expected-value", () => ({
getParticipantEV: vi.fn(),
}));
import { getTeamScoreBreakdown } from "../standings";
import { calculateTeamScore } from "../scoring-calculator";
import { getParticipantEV } from "../participant-expected-value";
const SCORING = {
pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40,
pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15,
};
function makePick(
participantId: string,
name: string,
scoringPattern: string,
finalPosition: number | null,
{ sportsSeasonId = "ss-1", isPartialScore = false, pickNumber = 1 } = {}
) {
return {
pickNumber,
round: 1,
participant: {
id: participantId,
name,
sportsSeasonId,
results: finalPosition === null ? [] : [{ finalPosition, isPartialScore }],
sportsSeason: { scoringPattern, sport: { name: "Golf" } },
},
};
}
/**
* @param extraResults results for participants NOBODY drafted. They still count
* toward a tie span, which is exactly why the tie query cannot be narrowed to
* drafted participants.
*/
function makeDb(
picks: ReturnType<typeof makePick>[],
extraResults: { sportsSeasonId: string; finalPosition: number }[] = []
) {
const seasonResults = [
...picks.flatMap((pick) =>
pick.participant.results
.filter((r) => r.finalPosition !== null)
.map((r) => ({
sportsSeasonId: pick.participant.sportsSeasonId,
finalPosition: r.finalPosition,
}))
),
...extraResults,
];
return {
query: {
seasons: { findFirst: vi.fn().mockResolvedValue(SCORING) },
draftPicks: { findMany: vi.fn().mockResolvedValue(picks) },
scoringEvents: { findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }) },
seasonParticipantResults: { findMany: vi.fn().mockResolvedValue(seasonResults) },
teams: { findFirst: vi.fn().mockResolvedValue({ id: "team-1", name: "Mike" }) },
},
} as any;
}
describe("getTeamScoreBreakdown", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getParticipantEV).mockResolvedValue(null as any);
});
it("splits a tied qualifying_points placement", async () => {
// Rahm tied for 8th with an undrafted player: (15 + 0) / 2 = 7.5 → 8.
const db = makeDb(
[makePick("rahm", "Jon Rahm", "qualifying_points", 8)],
[{ sportsSeasonId: "ss-1", finalPosition: 8 }]
);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0]).toMatchObject({
participant: expect.objectContaining({ name: "Jon Rahm" }),
finalPosition: 8,
points: 8,
});
expect(breakdown?.actualPoints).toBe(8);
});
it("awards the full placement value when untied", async () => {
const db = makeDb([makePick("rahm", "Jon Rahm", "qualifying_points", 8)]);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0].points).toBe(15);
expect(breakdown?.actualPoints).toBe(15);
});
it("counts undrafted participants in the tie span", async () => {
// Three-way tie for 6th where two of the three went undrafted:
// (25 + 15 + 15) / 3 = 18.33 → 18, the rules page's published example.
const db = makeDb(
[makePick("golfer", "Tied Golfer", "qualifying_points", 6)],
[
{ sportsSeasonId: "ss-1", finalPosition: 6 },
{ sportsSeasonId: "ss-1", finalPosition: 6 },
]
);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0].points).toBe(18);
});
it("splits a tied season_standings placement", async () => {
// processSeasonStandings writes the same finalPosition to every tied driver
// and leaves the split to scoring time. Two tied at 3rd share 3rd + 4th.
const db = makeDb(
[makePick("driver", "Tied Driver", "season_standings", 3)],
[{ sportsSeasonId: "ss-1", finalPosition: 3 }]
);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0].points).toBe(45); // (50 + 40) / 2
});
it("splits a four-way season_standings tie across the 5-8 tier", async () => {
const db = makeDb(
[makePick("driver", "Tied Driver", "season_standings", 5)],
[
{ sportsSeasonId: "ss-1", finalPosition: 5 },
{ sportsSeasonId: "ss-1", finalPosition: 5 },
{ sportsSeasonId: "ss-1", finalPosition: 5 },
]
);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0].points).toBe(20); // (25 + 25 + 15 + 15) / 4
});
it("still averages bracket tiers", async () => {
const db = makeDb([makePick("team-a", "Team A", "playoff_bracket", 5)]);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
// Four QF losers share 5th-8th: (25 + 25 + 15 + 15) / 4 = 20
expect(breakdown?.picks[0].points).toBe(20);
});
it("leaves picks without a result at zero points", async () => {
const db = makeDb([makePick("pending", "Pending Golfer", "qualifying_points", null)]);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0]).toMatchObject({ points: 0, isComplete: false });
expect(breakdown?.actualPoints).toBe(0);
});
it("agrees with calculateTeamScore on the same roster", async () => {
// The reported bug in miniature: a tied golfer plus untied picks. Both
// functions read the same fixture, so any divergence is a real regression.
const roster = () => [
makePick("rahm", "Jon Rahm", "qualifying_points", 8, { pickNumber: 1 }),
makePick("scheffler", "Scottie Scheffler", "qualifying_points", 1, { pickNumber: 2 }),
makePick("team-a", "Team A", "playoff_bracket", 5, {
sportsSeasonId: "ss-2",
pickNumber: 3,
}),
makePick("driver", "Tied Driver", "season_standings", 3, {
sportsSeasonId: "ss-3",
pickNumber: 4,
}),
];
const undrafted = [
{ sportsSeasonId: "ss-1", finalPosition: 8 },
{ sportsSeasonId: "ss-3", finalPosition: 3 },
];
const breakdown = await getTeamScoreBreakdown(
"team-1",
"season-1",
makeDb(roster(), undrafted)
);
const score = await calculateTeamScore(
"team-1",
"season-1",
makeDb(roster(), undrafted)
);
expect(breakdown?.actualPoints).toBe(score.totalPoints);
// 8 (golf T8) + 100 (golf 1st) + 20 (bracket T5-8) + 45 (F1 T3)
expect(breakdown?.actualPoints).toBe(173);
});
});

View file

@ -344,7 +344,7 @@ describe("calculateTeamScore", () => {
const result = await calculateTeamScore("team1", "season1", db);
expect(result.totalPoints).toBe(7.5); // (15 + 0) / 2
expect(result.totalPoints).toBe(8); // (15 + 0) / 2 = 7.5 → 8
expect(result.participantsCompleted).toBe(1);
expect(result.placementCounts[8]).toBe(1);
});

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/lib/logger", () => ({
logger: { error: vi.fn() },
logger: { error: vi.fn(), warn: vi.fn() },
}));
// ── DB mock helpers ────────────────────────────────────────────────────────
@ -10,13 +10,21 @@ function makeInsertChain() {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
const insert = vi.fn().mockReturnValue({ values });
return { insert, values, onConflictDoUpdate };
const deleteWhere = vi.fn().mockResolvedValue(undefined);
const del = vi.fn().mockReturnValue({ where: deleteWhere });
return { insert, values, onConflictDoUpdate, delete: del, deleteWhere };
}
interface MakeDbOpts {
sportsSeason?: { sport: { name: string } } | null;
seasonSports?: { seasonId: string }[];
picks?: { teamId: string; seasonId: string }[];
picks?: { teamId: string; seasonId: string; participantId?: string }[];
scoringEvent?: { id: string; name: string } | null;
participantResults?: {
participantId: string;
finalPosition: number | null;
sportsSeasonId?: string;
}[];
seasons?: {
id: string;
pointsFor1st: number; pointsFor2nd: number; pointsFor3rd: number;
@ -41,6 +49,8 @@ function makeDb(opts: MakeDbOpts = {}) {
seasons = [],
scoreEventRows = [],
participantRows = [],
scoringEvent = null,
participantResults = [],
} = opts;
const chain = makeInsertChain();
@ -48,6 +58,7 @@ function makeDb(opts: MakeDbOpts = {}) {
return {
db: {
insert: chain.insert,
delete: chain.delete,
query: {
sportsSeasons: {
findFirst: vi.fn().mockResolvedValue(sportsSeason),
@ -67,13 +78,26 @@ function makeDb(opts: MakeDbOpts = {}) {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue(participantRows),
},
scoringEvents: {
findFirst: vi.fn().mockResolvedValue(scoringEvent),
// Every event in the sports season, used to scope the pre-write delete.
findMany: vi.fn().mockResolvedValue(scoringEvent ? [{ id: scoringEvent.id }] : []),
},
seasonParticipantResults: {
findMany: vi.fn().mockResolvedValue(participantResults),
},
},
} as any,
chain,
};
}
import { recordTeamScoreEvent, recordMatchScoreEvents, getRecentTeamScoreEvents } from "../team-score-events";
import {
recordTeamScoreEvent,
recordMatchScoreEvents,
recordFinalPlacementScoreEvents,
getRecentTeamScoreEvents,
} from "../team-score-events";
const BASE_PARAMS = {
teamId: "team-1",
@ -358,3 +382,240 @@ describe("getRecentTeamScoreEvents", () => {
expect(result[0].participants).toEqual([]);
});
});
describe("recordFinalPlacementScoreEvents", () => {
beforeEach(() => vi.clearAllMocks());
const RULES = {
id: "season-1",
pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40,
pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15,
};
function makeQpDb(overrides: Partial<MakeDbOpts> = {}) {
return makeDb({
sportsSeason: { scoringPattern: "qualifying_points", sport: { name: "Golf" } } as any,
scoringEvent: { id: "event-9", name: "The Open" },
seasonSports: [{ seasonId: "season-1" }],
seasons: [RULES],
...overrides,
// Tie counts are grouped by sportsSeasonId, so result rows must carry it.
participantResults: (overrides.participantResults ?? []).map((r) => ({
sportsSeasonId: "ss-1",
...r,
})),
});
}
it("writes one row per team with the summed award and its participants", async () => {
const { db, chain } = makeQpDb({
participantResults: [
{ participantId: "rahm", finalPosition: 8 },
{ participantId: "scheffler", finalPosition: 1 },
],
picks: [
{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" },
{ teamId: "team-1", seasonId: "season-1", participantId: "scheffler" },
],
});
await recordFinalPlacementScoreEvents(
{ sportsSeasonId: "ss-1", eventName: "Final Standings" },
db
);
expect(chain.insert).toHaveBeenCalledTimes(1);
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({
teamId: "team-1",
seasonId: "season-1",
scoringEventId: "event-9",
scoringEventName: "Final Standings",
sportName: "Golf",
matchId: null,
pointsDelta: "115", // 100 (1st) + 15 (8th, untied)
participantIds: ["rahm", "scheffler"],
})
);
});
it("awards the split value when the placement is tied", async () => {
const { db, chain } = makeQpDb({
// Rahm ties for 8th with an UNDRAFTED player — the tie still halves it.
participantResults: [
{ participantId: "rahm", finalPosition: 8 },
{ participantId: "undrafted-guy", finalPosition: 8 },
],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
// (15 + 0) / 2 = 7.5 → 8, matching what the standings show.
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({ pointsDelta: "8", participantIds: ["rahm"] })
);
});
it("keeps each team's award separate", async () => {
const { db, chain } = makeQpDb({
participantResults: [
{ participantId: "rahm", finalPosition: 8 },
{ participantId: "scheffler", finalPosition: 1 },
],
picks: [
{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" },
{ teamId: "team-2", seasonId: "season-1", participantId: "scheffler" },
],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
expect(chain.insert).toHaveBeenCalledTimes(2);
const deltasByTeam = Object.fromEntries(
chain.values.mock.calls.map(([v]: any[]) => [v.teamId, v.pointsDelta])
);
expect(deltasByTeam).toEqual({ "team-1": "15", "team-2": "100" });
});
it("upserts on the event-level target so re-finalizing does not duplicate", async () => {
const { db, chain } = makeQpDb({
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
const conflictArg = chain.onConflictDoUpdate.mock.calls[0][0];
expect(conflictArg.target).toHaveLength(3);
expect(conflictArg.targetWhere).toBeDefined();
expect(conflictArg.set).toMatchObject({ pointsDelta: "15" });
});
it("skips the write when no scoring event can anchor the row", async () => {
// A null scoringEventId would defeat the partial unique index, since
// Postgres treats NULLs as distinct — better to skip than duplicate.
const { db, chain } = makeQpDb({
scoringEvent: null,
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
expect(chain.insert).not.toHaveBeenCalled();
});
it("ignores non-scoring placements", async () => {
const { db, chain } = makeQpDb({
participantResults: [
{ participantId: "rahm", finalPosition: 0 },
{ participantId: "other", finalPosition: null },
],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
expect(chain.insert).not.toHaveBeenCalled();
});
it("scores season_standings placements with the shared tier averaging", async () => {
const { db, chain } = makeQpDb({
sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any,
participantResults: [{ participantId: "driver", finalPosition: 3 }],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({ sportName: "F1", pointsDelta: "50" })
);
});
it("splits a tied season_standings placement", async () => {
// processSeasonStandings gives a tied group the same finalPosition and leaves
// the split to scoring time. Two drivers tied at 3rd share 3rd + 4th:
// (50 + 40) / 2 = 45.
const { db, chain } = makeQpDb({
sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any,
participantResults: [
{ participantId: "driver", finalPosition: 3 },
{ participantId: "other-driver", finalPosition: 3 },
],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({ pointsDelta: "45" })
);
});
it("anchors to a completed event rather than an incomplete one", async () => {
// Postgres orders DESC as NULLS FIRST and drizzle's desc() cannot express
// NULLS LAST, so an incomplete event (completedAt NULL) would otherwise win
// the anchor. The query must constrain isComplete itself.
const { db } = makeQpDb({
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
const anchorQuery = db.query.scoringEvents.findFirst.mock.calls[0][0];
expect(anchorQuery.where).toBeDefined();
// Ordering must not be a bare desc() on completedAt alone.
expect(anchorQuery.orderBy.length).toBeGreaterThan(1);
});
it("clears the season's existing rows before writing so a moved anchor cannot duplicate", async () => {
const { db, chain } = makeQpDb({
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
expect(chain.delete).toHaveBeenCalledTimes(1);
expect(chain.deleteWhere).toHaveBeenCalledTimes(1);
expect(chain.insert).toHaveBeenCalledTimes(1);
});
it("labels rows from the scoring pattern with no caller input", async () => {
const qp = makeQpDb({
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, qp.db);
expect(qp.chain.values).toHaveBeenCalledWith(
expect.objectContaining({ scoringEventName: "Final Standings" })
);
const f1 = makeQpDb({
sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any,
participantResults: [{ participantId: "driver", finalPosition: 3 }],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, f1.db);
expect(f1.chain.values).toHaveBeenCalledWith(
expect.objectContaining({ scoringEventName: "Season Complete" })
);
});
it("rewrites the label on conflict so a stale row can be repaired", async () => {
const { db, chain } = makeQpDb({
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
expect(chain.onConflictDoUpdate.mock.calls[0][0].set).toMatchObject({
scoringEventName: "Final Standings",
sportName: "Golf",
});
});
});

View file

@ -3,10 +3,13 @@ import * as schema from "~/database/schema";
import { eq, and, inArray, asc } from "drizzle-orm";
import {
getScoringRules,
calculateFantasyPoints,
calculateBracketPoints,
calculateSharedPlacementPoints,
calculatePickPoints,
usesSharedPlacementSplit,
} from "./scoring-rules";
import {
getSharedPlacementCounts,
lookupSharedPlacementCount,
} from "./participant-result";
export async function createDraftPick(data: {
seasonId: string;
@ -159,18 +162,23 @@ export async function getDraftedParticipantsWithPoints(
const bracketSeasonIds = new Set<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);
}
}
@ -203,19 +211,7 @@ export async function getDraftedParticipantsWithPoints(
}
}
const qpSharedPlacementCounts = new Map<string, Map<number, number>>();
if (finalizedQPSeasonIds.size > 0) {
const results = await db.query.seasonParticipantResults.findMany({
where: inArray(schema.seasonParticipantResults.sportsSeasonId, [...finalizedQPSeasonIds]),
columns: { sportsSeasonId: true, finalPosition: true },
});
for (const row of results) {
if (row.finalPosition === null || row.finalPosition <= 0) continue;
const counts = qpSharedPlacementCounts.get(row.sportsSeasonId) ?? new Map<number, number>();
qpSharedPlacementCounts.set(row.sportsSeasonId, counts);
counts.set(row.finalPosition, (counts.get(row.finalPosition) ?? 0) + 1);
}
}
const sharedPlacementCounts = await getSharedPlacementCounts([...tieSplitSeasonIds], db);
// Assemble result grouped by sportsSeasonId
const result = new Map<string, DraftedParticipantWithPoints[]>();
@ -233,23 +229,14 @@ export async function getDraftedParticipantsWithPoints(
currentQP = qpMap.get(id) ?? null;
} else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) {
// Finalized result for any pattern (including finalized QP seasons)
if (pattern === "playoff_bracket") {
earnedPoints = calculateBracketPoints(
resultRow.finalPosition,
scoringRules,
bracketTemplateMap.get(sportsSeasonId) ?? null
);
} else if (pattern === "qualifying_points") {
const tiedParticipants =
qpSharedPlacementCounts.get(sportsSeasonId)?.get(resultRow.finalPosition) ?? 1;
earnedPoints = calculateSharedPlacementPoints(
resultRow.finalPosition,
tiedParticipants,
scoringRules
);
} else {
earnedPoints = calculateFantasyPoints(resultRow.finalPosition, scoringRules);
}
earnedPoints = calculatePickPoints(resultRow.finalPosition, pattern, scoringRules, {
bracketTemplateId: bracketTemplateMap.get(sportsSeasonId) ?? null,
tiedParticipants: lookupSharedPlacementCount(
sharedPlacementCounts,
sportsSeasonId,
resultRow.finalPosition
),
});
}
const arr = result.get(sportsSeasonId) ?? [];

View file

@ -1,4 +1,4 @@
import { eq, and } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
@ -76,6 +76,73 @@ export async function findParticipantResultsBySportsSeasonId(
});
}
/**
* Tallies how many participants share each scoring placement, per sports season.
*
* Pure counterpart to getSharedPlacementCounts, for callers that already hold the
* result rows. Split out so there is exactly one definition of what "tied" means
* the whole point of this module is that every screen counts ties identically.
*
* Positions <= 0 (no scoring placement) are excluded.
*/
export function countSharedPlacements(
rows: Array<{ sportsSeasonId: string; finalPosition: number | null }>
): Map<string, Map<number, number>> {
const counts = new Map<string, Map<number, number>>();
for (const row of rows) {
if (row.finalPosition === null || row.finalPosition <= 0) continue;
let bySeason = counts.get(row.sportsSeasonId);
if (!bySeason) {
bySeason = new Map<number, number>();
counts.set(row.sportsSeasonId, bySeason);
}
bySeason.set(row.finalPosition, (bySeason.get(row.finalPosition) ?? 0) + 1);
}
return counts;
}
/**
* How many participants share each scoring placement, per sports season.
*
* Returns sportsSeasonId (finalPosition count). Used to split a tied
* placement's points across the tied participants (see calculatePickPoints).
*
* The count spans EVERY result in the sports season, not just drafted ones a
* golfer tied for 8th with an undrafted player still only earns half the 8th
* place points, so narrowing this query to drafted participants would silently
* over-award.
*
* Callers that look up a position with no entry should treat it as 1 (no tie).
*/
export async function getSharedPlacementCounts(
sportsSeasonIds: string[],
providedDb?: ReturnType<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.
*/
export function lookupSharedPlacementCount(
counts: Map<string, Map<number, number>>,
sportsSeasonId: string,
finalPosition: number
): number {
return counts.get(sportsSeasonId)?.get(finalPosition) ?? 1;
}
export async function updateParticipantResult(
id: string,
data: Partial<NewParticipantResult>

View file

@ -3,10 +3,11 @@ import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm";
import {
getScoringRules,
calculateFantasyPoints,
calculateBracketPoints,
calculateSharedPlacementPoints,
calculatePickPoints,
usesSharedPlacementSplit,
type ScoringRules,
} from "./scoring-rules";
import { getSharedPlacementCounts } from "./participant-result";
import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
@ -16,7 +17,10 @@ import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-
import { getUserDisplayName } from "~/models/user";
import { findDiscordIdsByUserIds } from "~/models/account";
import { createDailySnapshot } from "~/models/standings";
import { recordMatchScoreEvents } from "~/models/team-score-events";
import {
recordMatchScoreEvents,
recordFinalPlacementScoreEvents,
} from "~/models/team-score-events";
import { logger } from "~/lib/logger";
import { getEventResults } from "./event-result";
import {
@ -595,6 +599,11 @@ async function upsertParticipantResult(
}
}
/**
* Lazily populates `cache` (sportsSeasonId placement count) one sports
* season at a time, then reads the tie count for `finalPosition`. The counting
* itself lives in getSharedPlacementCounts so every screen shares one definition.
*/
async function getSharedPlacementCount(
sportsSeasonId: string,
finalPosition: number,
@ -602,17 +611,8 @@ async function getSharedPlacementCount(
cache: Map<string, Map<number, number>>
): Promise<number> {
if (!cache.has(sportsSeasonId)) {
const results = await db.query.seasonParticipantResults.findMany({
where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
columns: { finalPosition: true },
});
const counts = new Map<number, number>();
for (const result of results) {
if (result.finalPosition !== null && result.finalPosition > 0) {
counts.set(result.finalPosition, (counts.get(result.finalPosition) ?? 0) + 1);
}
}
cache.set(sportsSeasonId, counts);
const counts = await getSharedPlacementCounts([sportsSeasonId], db);
cache.set(sportsSeasonId, counts.get(sportsSeasonId) ?? new Map<number, number>());
}
return cache.get(sportsSeasonId)?.get(finalPosition) ?? 1;
@ -1178,6 +1178,20 @@ export async function finalizeQualifyingPoints(
})
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
// Ledger the placement points so this season shows up in Recent Scores —
// QP seasons award everything here, with no per-match deltas to record.
// Guarded like the probability refresh below: the season is already marked
// completed at this point, so letting a ledger failure escape would abort
// finalization before standings recalculate and no Discord update would fire.
try {
await recordFinalPlacementScoreEvents({ sportsSeasonId }, db);
} catch (error) {
logger.error(
`[ScoringCalculator] Failed to record final placement score events for sports season ${sportsSeasonId}:`,
error
);
}
// Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" });
@ -1281,6 +1295,18 @@ export async function processSeasonStandings(
}
}
// Ledger the placement points — season_standings has the same one-shot award
// shape as qualifying_points and was likewise absent from Recent Scores.
// Guarded so a ledger failure cannot abort finalization (see above).
try {
await recordFinalPlacementScoreEvents({ sportsSeasonId }, db);
} catch (error) {
logger.error(
`[ScoringCalculator] Failed to record final placement score events for sports season ${sportsSeasonId}:`,
error
);
}
// Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });
@ -1376,27 +1402,21 @@ export async function calculateTeamScore(
const result = pick.participant.results[0];
if (result && result.finalPosition !== null && result.finalPosition > 0) {
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points";
let points: number;
if (isBracket) {
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
} else if (isQualifyingPoints) {
const tiedParticipants = await getSharedPlacementCount(
pick.participant.sportsSeasonId,
result.finalPosition,
db,
sharedPlacementCountCache
);
points = calculateSharedPlacementPoints(
result.finalPosition,
tiedParticipants,
scoringRules
);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
const pattern = pick.participant.sportsSeason?.scoringPattern;
const points = calculatePickPoints(result.finalPosition, pattern, scoringRules, {
bracketTemplateId:
pattern === "playoff_bracket"
? await getBracketTemplate(pick.participant.sportsSeasonId)
: null,
tiedParticipants: usesSharedPlacementSplit(pattern)
? await getSharedPlacementCount(
pick.participant.sportsSeasonId,
result.finalPosition,
db,
sharedPlacementCountCache
)
: 1,
});
totalPoints += points;
// All participants with a valid position count toward the placement tiebreaker,
@ -1483,57 +1503,51 @@ export async function calculateTeamProjectedScore(
return templateId;
}
// Points for one pick's placement. Identical for finalized results and for the
// provisional floor of a still-alive participant — only what we do with the
// number afterwards differs. `rules` re-binds the null-checked scoringRules
// because the hoisted declaration below cannot see that narrowing.
const rules: ScoringRules = scoringRules;
async function pointsForPick(
sportsSeasonId: string,
pattern: string | null | undefined,
finalPosition: number
): Promise<number> {
return calculatePickPoints(finalPosition, pattern, rules, {
bracketTemplateId:
pattern === "playoff_bracket" ? await getBracketTemplate(sportsSeasonId) : null,
tiedParticipants: usesSharedPlacementSplit(pattern)
? await getSharedPlacementCount(
sportsSeasonId,
finalPosition,
db,
sharedPlacementCountCache
)
: 1,
});
}
// Separate finished vs unfinished participants
for (const pick of picks) {
const result = pick.participant.results[0];
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points";
const pattern = pick.participant.sportsSeason?.scoringPattern;
if (result && result.finalPosition !== null && !result.isPartialScore) {
// Participant is fully finalized — use bracket-averaged points
let points: number;
if (isBracket) {
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
} else if (isQualifyingPoints) {
const tiedParticipants = await getSharedPlacementCount(
pick.participant.sportsSeasonId,
result.finalPosition,
db,
sharedPlacementCountCache
);
points = calculateSharedPlacementPoints(
result.finalPosition,
tiedParticipants,
scoringRules
);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
actualPoints += points;
// Participant is fully finalized
actualPoints += await pointsForPick(
pick.participant.sportsSeasonId,
pattern,
result.finalPosition
);
participantsFinished++;
} else if (result && result.finalPosition !== null && result.isPartialScore) {
// Still alive with a provisional floor — count floor as actual, EV for projection.
// Note: NOT incremented in participantsFinished; these participants are still competing.
const templateId = isBracket ? await getBracketTemplate(pick.participant.sportsSeasonId) : null;
let floorPoints: number;
if (isBracket) {
floorPoints = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
} else if (isQualifyingPoints) {
const tiedParticipants = await getSharedPlacementCount(
pick.participant.sportsSeasonId,
result.finalPosition,
db,
sharedPlacementCountCache
);
floorPoints = calculateSharedPlacementPoints(
result.finalPosition,
tiedParticipants,
scoringRules
);
} else {
floorPoints = calculateFantasyPoints(result.finalPosition, scoringRules);
}
const floorPoints = await pointsForPick(
pick.participant.sportsSeasonId,
pattern,
result.finalPosition
);
actualPoints += floorPoints;
// EV already accounts for their full projected value, so subtract floor to avoid
// double-counting when we do actualPoints + evSum below

View file

@ -90,6 +90,13 @@ export function calculateFantasyPoints(
*
* Example: 4 teams lose in quarterfinals, they share positions 5-8
* Average = (25 + 25 + 15 + 15) / 4 = 20 points each
*
* The result is rounded to the nearest whole point, matching the published rule
* on the /rules page: "the points for all tied positions are combined and split
* equally among them, rounded to the nearest whole point". That page's own
* example rounds down a three-way tie for 6th8th is (25 + 15 + 15) / 3 =
* 18.33 18 so this is nearest, not ceiling. Season point values are integer
* columns, so this averaging is the only place fractional points can arise.
*/
export function calculateAveragedPoints(
placements: number[],
@ -101,7 +108,7 @@ export function calculateAveragedPoints(
return sum + calculateFantasyPoints(placement, rules);
}, 0);
return total / placements.length;
return Math.round(total / placements.length);
}
/**
@ -190,6 +197,74 @@ export function calculateBracketPoints(
return 0;
}
/**
* Scoring patterns whose participants can share a final placement, so that a tied
* group splits the combined points of the positions it spans.
*
* Both patterns record ties by writing the SAME finalPosition to every tied
* participant, leaving the split to scoring time:
* - qualifying_points finalizeQualifyingPoints groups participants by QP total
* - season_standings processSeasonStandings gives a tied group the first
* placement in its range ("if 4 people tie for 5th they all get placement 5")
*
* playoff_bracket is deliberately absent: its ties are structural (both SF losers
* tie for 3rd) and calculateBracketPoints derives the span from the bracket shape
* rather than from a count of results.
*/
const TIE_SPLIT_PATTERNS = new Set(["qualifying_points", "season_standings"]);
/**
* Whether a scoring pattern needs a tie count to score a placement correctly.
*
* Callers must gate their tie-count lookups on this rather than testing a pattern
* name directly. A caller that hardcodes one pattern silently passes
* tiedParticipants: 1 for the other, which reads as "no tie" and awards the full
* placement value the failure mode that left F1 ties unsplit.
*/
export function usesSharedPlacementSplit(
scoringPattern: string | null | undefined
): boolean {
return TIE_SPLIT_PATTERNS.has(scoringPattern ?? "");
}
/**
* Fantasy points earned by a single drafted participant, for any scoring pattern.
*
* This is the ONE place the bracket / tie-split / default cascade lives. It
* previously existed as a hand-rolled if/else at six call sites, two of which
* were missing the qualifying_points arm entirely that divergence is what made
* a tied golfer worth 15 points on the team page and draft board but 7.5 in the
* standings. Every caller must route through here.
*
* @param finalPosition - Placement in the sports season (1-8 scores, 0 = none).
* @param scoringPattern - The sports season's scoringPattern column.
* @param rules - The fantasy season's point values.
* @param opts.bracketTemplateId - Required for playoff_bracket to pick the right
* tier structure (e.g. AFL/LLWS split 58 into two pairs).
* @param opts.tiedParticipants - Required for any pattern where
* usesSharedPlacementSplit is true: how many participants share this
* finalPosition across the WHOLE sports season, not just the ones that were
* drafted. Defaults to 1 (no tie).
*/
export function calculatePickPoints(
finalPosition: number,
scoringPattern: string | null | undefined,
rules: ScoringRules,
opts?: { bracketTemplateId?: string | null; tiedParticipants?: number }
): number {
if (scoringPattern === "playoff_bracket") {
return calculateBracketPoints(finalPosition, rules, opts?.bracketTemplateId ?? null);
}
if (usesSharedPlacementSplit(scoringPattern)) {
return calculateSharedPlacementPoints(
finalPosition,
opts?.tiedParticipants ?? 1,
rules
);
}
return calculateFantasyPoints(finalPosition, rules);
}
/**
* Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th]
* Useful for display purposes

View file

@ -2,7 +2,11 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
import { calculatePickPoints } from "~/models/scoring-rules";
import {
getSharedPlacementCounts,
lookupSharedPlacementCount,
} from "~/models/participant-result";
import { logger } from "~/lib/logger";
import { getParticipantEV } from "./participant-expected-value";
import { calculateEV } from "~/services/ev-calculator";
@ -157,6 +161,14 @@ export async function getTeamScoreBreakdown(
pointsFor8th: season.pointsFor8th,
};
// Tie counts for qualifying_points picks, so a golfer tied for 8th is worth the
// split award here exactly as it is in calculateTeamScore. Counted across every
// participant in the sports season — an undrafted tie partner still halves it.
const sharedPlacementCounts = await getSharedPlacementCounts(
[...new Set(picks.map((p) => p.participant.sportsSeasonId))],
db
);
// Cache bracket template IDs per sports season (same approach as calculateTeamScore)
const bracketTemplateCache = new Map<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
@ -176,7 +188,7 @@ export async function getTeamScoreBreakdown(
const pickBreakdown = await Promise.all(
picks.map(async (pick) => {
const result = pick.participant.results[0];
const isBracket = pick.participant.sportsSeason.scoringPattern === "playoff_bracket";
const pattern = pick.participant.sportsSeason.scoringPattern;
let points = 0;
let projectedPoints: number | null = null;
@ -199,13 +211,17 @@ export async function getTeamScoreBreakdown(
};
if (result && result.finalPosition !== null && result.finalPosition > 0) {
// Calculate points using bracket-averaged scoring for bracket sports
if (isBracket) {
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
points = calculatePickPoints(result.finalPosition, pattern, scoringRules, {
bracketTemplateId:
pattern === "playoff_bracket"
? await getBracketTemplate(pick.participant.sportsSeasonId)
: null,
tiedParticipants: lookupSharedPlacementCount(
sharedPlacementCounts,
pick.participant.sportsSeasonId,
result.finalPosition
),
});
if (result.isPartialScore) {
// Still alive with a floor position — use EV for projected since they can advance

View file

@ -1,7 +1,15 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, inArray, desc, sql, and } from "drizzle-orm";
import { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules";
import { eq, inArray, desc, sql, and, isNull } from "drizzle-orm";
import {
calculateBracketPoints,
calculatePickPoints,
type ScoringRules,
} from "~/models/scoring-rules";
import {
countSharedPlacements,
lookupSharedPlacementCount,
} from "~/models/participant-result";
import { findParticipantNamesByIds } from "~/models/season-participant";
import { logger } from "~/lib/logger";
@ -78,6 +86,10 @@ export async function recordTeamScoreEvent(
set: {
participantIds: params.participantIds,
pointsDelta: params.pointsDelta.toString(),
// Refreshed too, so a row written with a stale label can be repaired
// by a later correct run instead of keeping the wrong name forever.
scoringEventName: params.scoringEventName,
sportName: params.sportName,
},
});
}
@ -190,6 +202,223 @@ export async function recordMatchScoreEvents(
}
}
/**
* Ledger label for a one-shot finalization, derived from the scoring pattern so
* every caller agrees. Deriving it here rather than requiring a parameter is
* deliberate: recordTeamScoreEvent's upsert can rewrite the label, but a caller
* that simply forgets to pass one would otherwise stamp rows with the anchor
* event's own name ("The Open") instead of "Final Standings".
*/
function defaultEventName(scoringPattern: string | null | undefined): string {
return scoringPattern === "season_standings" ? "Season Complete" : "Final Standings";
}
/**
* Records ledger rows for a sports season whose final placements have just been
* assigned qualifying_points (golf, tennis, CS2) and season_standings (F1).
*
* These patterns award all of their points in one step at finalization, so they
* have no per-match deltas and were never written to team_score_events at all:
* recordMatchScoreEvents is bracket-only and fires from match processing. The
* effect was that golf results silently never appeared in Recent Scores.
*
* One row per team (matching the event-level unique index), carrying that team's
* summed award and every participant that contributed. Points come from
* calculatePickPoints, so a tied golfer contributes the same split award the
* standings show.
*
* Every row is anchored to a real scoring event, because the event-level unique
* index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as
* distinct a null anchor would defeat the upsert entirely. If no anchor can be
* found the ledger write is skipped rather than risking duplicates; standings are
* unaffected either way.
*
* Re-running is safe even if the anchor moves (a later event completes, or the
* backfill runs against changed data). Stale rows for this sports season are
* cleared before writing, so the upsert alone is not load-bearing.
*/
export async function recordFinalPlacementScoreEvents(
params: {
sportsSeasonId: string;
eventId?: string | null;
eventName?: string | null;
},
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, params.sportsSeasonId),
columns: { id: true, scoringPattern: true },
with: { sport: { columns: { name: true } } },
});
if (!sportsSeason) return;
// Resolve the anchor event: the caller's, else the most recently completed
// event for this sports season.
//
// The completed filter is not cosmetic. drizzle's desc() emits a bare `desc`,
// and Postgres orders DESC as NULLS FIRST, so ordering on completedAt alone
// would rank a never-completed event (common for sibling major windows) above
// every finished one. Restrict to completed events and order explicitly.
let eventId = params.eventId ?? null;
const eventName = params.eventName ?? defaultEventName(sportsSeason.scoringPattern);
if (!eventId) {
const anchor = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId),
eq(schema.scoringEvents.isComplete, true)
),
columns: { id: true },
orderBy: [
sql`${schema.scoringEvents.completedAt} DESC NULLS LAST`,
sql`${schema.scoringEvents.eventDate} DESC NULLS LAST`,
desc(schema.scoringEvents.id),
],
});
if (!anchor) {
logger.warn(
`[TeamScoreEvents] No completed scoring event to anchor final placements for sports season ${params.sportsSeasonId}; skipping ledger write`
);
return;
}
eventId = anchor.id;
}
// Scoring placements for this sports season, plus the tie spans they imply.
// Counted from these same rows rather than re-querying them.
const results = await db.query.seasonParticipantResults.findMany({
where: eq(schema.seasonParticipantResults.sportsSeasonId, params.sportsSeasonId),
columns: { sportsSeasonId: true, participantId: true, finalPosition: true },
});
const positionByParticipantId = new Map<string, number>();
for (const row of results) {
if (row.finalPosition !== null && row.finalPosition > 0) {
positionByParticipantId.set(row.participantId, row.finalPosition);
}
}
if (positionByParticipantId.size === 0) return;
const sharedPlacementCounts = countSharedPlacements(results);
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId),
columns: { seasonId: true },
});
if (seasonSports.length === 0) return;
const seasonIds = seasonSports.map((ss) => ss.seasonId);
const picks = await db.query.draftPicks.findMany({
where: and(
inArray(schema.draftPicks.seasonId, seasonIds),
inArray(schema.draftPicks.participantId, [...positionByParticipantId.keys()])
),
columns: { teamId: true, seasonId: true, participantId: true },
});
if (picks.length === 0) return;
const seasonRows = await db.query.seasons.findMany({
where: inArray(schema.seasons.id, seasonIds),
columns: {
id: true,
pointsFor1st: true, pointsFor2nd: true, pointsFor3rd: true,
pointsFor4th: true, pointsFor5th: true, pointsFor6th: true,
pointsFor7th: true, pointsFor8th: true,
},
});
const rulesBySeasonId = new Map<string, ScoringRules>(
seasonRows.map((s) => [s.id, {
pointsFor1st: s.pointsFor1st, pointsFor2nd: s.pointsFor2nd,
pointsFor3rd: s.pointsFor3rd, pointsFor4th: s.pointsFor4th,
pointsFor5th: s.pointsFor5th, pointsFor6th: s.pointsFor6th,
pointsFor7th: s.pointsFor7th, pointsFor8th: s.pointsFor8th,
}])
);
// Accumulate per (season, team) — the ledger's event-level grain.
const byTeam = new Map<
string,
{ teamId: string; seasonId: string; points: number; participantIds: string[] }
>();
for (const pick of picks) {
const rules = rulesBySeasonId.get(pick.seasonId);
const finalPosition = positionByParticipantId.get(pick.participantId);
if (!rules || finalPosition === undefined) continue;
const points = calculatePickPoints(
finalPosition,
sportsSeason.scoringPattern,
rules,
{
tiedParticipants: lookupSharedPlacementCount(
sharedPlacementCounts,
params.sportsSeasonId,
finalPosition
),
}
);
if (points <= 0) continue;
const key = `${pick.seasonId}:${pick.teamId}`;
const entry = byTeam.get(key) ?? {
teamId: pick.teamId,
seasonId: pick.seasonId,
points: 0,
participantIds: [],
};
entry.points += points;
entry.participantIds.push(pick.participantId);
byTeam.set(key, entry);
}
// Clear this sports season's existing event-level rows before writing, so a
// re-run whose anchor landed on a different event replaces rather than adds.
// Scoped to match_id IS NULL, which is what makes this safe: one-shot patterns
// write no other event-level rows (QP majors award qualifying points, not
// fantasy points), and qualifying-bracket matches carry a non-null matchId.
const eventIds = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId),
columns: { id: true },
});
if (eventIds.length > 0) {
await db
.delete(schema.teamScoreEvents)
.where(
and(
isNull(schema.teamScoreEvents.matchId),
inArray(
schema.teamScoreEvents.scoringEventId,
eventIds.map((e) => e.id)
),
inArray(schema.teamScoreEvents.seasonId, seasonIds)
)
);
}
for (const entry of byTeam.values()) {
try {
await recordTeamScoreEvent(
{
teamId: entry.teamId,
seasonId: entry.seasonId,
scoringEventId: eventId,
scoringEventName: eventName,
sportName: sportsSeason.sport?.name ?? null,
participantIds: entry.participantIds,
pointsDelta: entry.points,
},
db
);
} catch (err) {
logger.error(
`[TeamScoreEvents] Failed to record final placement score event for team ${entry.teamId} sports season ${params.sportsSeasonId}:`,
err
);
}
}
}
export interface TeamScoreEventEntry {
id: string;
teamId: string;

View file

@ -12,6 +12,7 @@ import { Button } from "~/components/ui/button";
import { ArrowLeft } from "lucide-react";
import logomarkUrl from "../../../public/logomark.svg?url";
import { computeCoronaStates } from "~/lib/corona-states";
import { getSharedPlacementCounts } from "~/models/participant-result";
import type { CoronaState } from "~/components/draft/DraftPickCell";
import type { Route } from "./+types/$leagueId.draft-board.$seasonId";
@ -157,12 +158,19 @@ export async function loader(args: Route.LoaderArgs) {
pointsFor8th: season.pointsFor8th,
};
// Counted across every participant in each sports season, not just the
// drafted ones above — an undrafted player tied for the same placement still
// halves the award.
const sharedPlacementCountsBySportsSeason =
await getSharedPlacementCounts(sportsSeasonIds, db);
coronaStates = computeCoronaStates(
draftPicks,
resultByParticipant,
bracketTemplateBySportsSeason,
scoringRules,
season.pointsFor1st,
sharedPlacementCountsBySportsSeason,
);
}

View file

@ -0,0 +1,176 @@
/**
* Backfill: recalculate standings after tie splits became whole points, and
* ledger the final placements that were never recorded.
*
* Two stored artifacts went stale when the tie-split math was unified:
*
* 1. team_standings.total_points / actual_points / projected_points hold
* pre-rounding values (a team carrying a golfer tied for 8th sits at
* 217.50 rather than 218). Standings are only rewritten when something
* re-triggers a recalculation, so leagues whose seasons already finished
* would keep the old figures indefinitely.
*
* 2. qualifying_points and season_standings sports were never written to
* team_score_events at all, so their results are missing from Recent
* Scores. recordFinalPlacementScoreEvents now writes them at
* finalization, but only for seasons finalized from here on.
*
* The ledger pass covers every already-finalized one-shot sports season and is
* safe to repeat: recordFinalPlacementScoreEvents clears that season's existing
* event-level rows before writing.
*
* The standings pass is deliberately narrow. recalculateStandings rewrites
* previousRank, so any season it touches loses its rank-movement arrows until the
* next scoring event it is NOT a pure recompute. Only leagues drafting from a
* sports season that actually contains a tied placement are recalculated; every
* other league is left alone.
*
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
*
* npx tsx scripts/backfill-rounded-tie-splits.ts # apply
* npx tsx scripts/backfill-rounded-tie-splits.ts --dry # report only
*/
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { inArray, eq } from "drizzle-orm";
import * as schema from "../database/schema.js";
import { DatabaseContext, database } from "../database/context.js";
import { recalculateStandings } from "../app/models/scoring-calculator.js";
import { recordFinalPlacementScoreEvents } from "../app/models/team-score-events.js";
import { countSharedPlacements } from "../app/models/participant-result.js";
const DRY = process.argv.includes("--dry");
const log = (...a: unknown[]) => console.log(...a);
/** Patterns that award all of their points at once, with no per-match deltas. */
type ScoringPattern = NonNullable<
(typeof schema.sportsSeasons.$inferSelect)["scoringPattern"]
>;
const ONE_SHOT_PATTERNS: ScoringPattern[] = ["qualifying_points", "season_standings"];
async function run() {
const db = database();
// Ledger pass: one-shot sports seasons that already have final placements.
const oneShotSeasons = await db.query.sportsSeasons.findMany({
where: inArray(schema.sportsSeasons.scoringPattern, ONE_SHOT_PATTERNS),
columns: { id: true, name: true, scoringPattern: true },
});
const finalized: typeof oneShotSeasons = [];
for (const ss of oneShotSeasons) {
const anyPlacement = await db.query.seasonParticipantResults.findFirst({
where: (r, { eq, and, gt }) =>
and(eq(r.sportsSeasonId, ss.id), gt(r.finalPosition, 0)),
columns: { id: true },
});
if (anyPlacement) finalized.push(ss);
}
log(
`One-shot sports seasons with final placements: ${finalized.length} of ${oneShotSeasons.length}`
);
let ledgered = 0;
let ledgerFailed = 0;
for (const ss of finalized) {
if (DRY) {
log(` (dry) ${ss.name} [${ss.scoringPattern}] — would write ledger rows`);
continue;
}
try {
await recordFinalPlacementScoreEvents({ sportsSeasonId: ss.id }, db);
ledgered += 1;
log(` ${ss.name} [${ss.scoringPattern}]: ledgered`);
} catch (e) {
ledgerFailed += 1;
log(` ! ${ss.name}: ${(e as Error).message}`);
}
}
// Standings pass, restricted to leagues that can actually change.
//
// recalculateStandings is NOT a pure recompute: it sets previousRank =
// currentRank, so every season it touches loses its rank-movement arrows until
// the next real scoring event. Sweeping all seasons would spend that cost on
// leagues holding no tied placement at all, so scope it to sports seasons that
// genuinely have a tie, then to the fantasy seasons drafting from them.
const allResults = await db.query.seasonParticipantResults.findMany({
columns: { sportsSeasonId: true, finalPosition: true },
});
const tiedSportsSeasonIds = new Set<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 seasonId of affectedSeasonIds) {
if (DRY) {
log(` (dry) season ${seasonId} — would recalculate standings`);
continue;
}
try {
await recalculateStandings(seasonId, db);
recalculated += 1;
} catch (e) {
recalcFailed += 1;
log(` ! season ${seasonId}: ${(e as Error).message}`);
}
}
log(
`\nDone${DRY ? " (dry run — no writes)" : ""}. ` +
`ledgered=${ledgered} (failed ${ledgerFailed}), ` +
`standings recalculated=${recalculated} (failed ${recalcFailed}).`
);
}
async function main() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("ERROR: DATABASE_URL is required");
process.exit(1);
}
const client = postgres(dbUrl, { max: 1 });
const db = drizzle(client, { schema });
try {
await DatabaseContext.run(db, run);
} finally {
await client.end();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});