Compare commits
No commits in common. "9024391d2f9cbe205e3d8014cd01a562c3d9ac70" and "81d813d3f3894d644da1d77949fee79b9afe3ffe" have entirely different histories.
9024391d2f
...
81d813d3f3
16 changed files with 152 additions and 1057 deletions
|
|
@ -1,122 +0,0 @@
|
||||||
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("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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { calculatePickPoints } from "~/models/scoring-rules";
|
import { calculateFantasyPoints, calculateBracketPoints } from "~/models/scoring-rules";
|
||||||
|
|
||||||
export type CoronaState =
|
export type CoronaState =
|
||||||
| { type: "eliminated"; points: 0 }
|
| { type: "eliminated"; points: 0 }
|
||||||
|
|
@ -27,22 +27,12 @@ interface ScoringRules {
|
||||||
pointsFor8th: number;
|
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(
|
export function computeCoronaStates(
|
||||||
picks: PickEntry[],
|
picks: PickEntry[],
|
||||||
resultByParticipant: Map<string, ResultEntry>,
|
resultByParticipant: Map<string, ResultEntry>,
|
||||||
bracketTemplateBySportsSeason: Map<string, string | null>,
|
bracketTemplateBySportsSeason: Map<string, string | null>,
|
||||||
scoringRules: ScoringRules,
|
scoringRules: ScoringRules,
|
||||||
maxPoints: number,
|
maxPoints: number,
|
||||||
sharedPlacementCountsBySportsSeason: Map<string, Map<number, number>>,
|
|
||||||
): Record<string, CoronaState> {
|
): Record<string, CoronaState> {
|
||||||
const coronaStates: Record<string, CoronaState> = {};
|
const coronaStates: Record<string, CoronaState> = {};
|
||||||
|
|
||||||
|
|
@ -60,19 +50,13 @@ export function computeCoronaStates(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.finalPosition > 0) {
|
if (result.finalPosition > 0) {
|
||||||
const points = calculatePickPoints(
|
const isBracket = pick.scoringPattern === "playoff_bracket";
|
||||||
result.finalPosition,
|
const templateId = isBracket
|
||||||
pick.scoringPattern,
|
? (bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null)
|
||||||
scoringRules,
|
: null;
|
||||||
{
|
const points = isBracket
|
||||||
bracketTemplateId:
|
? calculateBracketPoints(result.finalPosition, scoringRules, templateId)
|
||||||
bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null,
|
: calculateFantasyPoints(result.finalPosition, scoringRules);
|
||||||
tiedParticipants:
|
|
||||||
sharedPlacementCountsBySportsSeason
|
|
||||||
.get(pick.participant.sportsSeasonId)
|
|
||||||
?.get(result.finalPosition) ?? 1,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const brightness = maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0;
|
const brightness = maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0;
|
||||||
coronaStates[pick.participant.id] = { type: "scored", brightness, points };
|
coronaStates[pick.participant.id] = { type: "scored", brightness, points };
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ describe("getDraftedParticipantsWithPoints", () => {
|
||||||
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||||
|
|
||||||
expect(result.get("ss-1")?.[0]).toMatchObject({
|
expect(result.get("ss-1")?.[0]).toMatchObject({
|
||||||
earnedPoints: 63, // (75 + 50) / 2 = 62.5 → 63
|
earnedPoints: 62.5, // (75 + 50) / 2
|
||||||
currentQP: null,
|
currentQP: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,8 @@ describe("Scoring Calculator", () => {
|
||||||
|
|
||||||
it("should average points for three-way tie", () => {
|
it("should average points for three-way tie", () => {
|
||||||
const points = calculateAveragedPoints([1, 2, 3], DEFAULT_SCORING);
|
const points = calculateAveragedPoints([1, 2, 3], DEFAULT_SCORING);
|
||||||
// (100 + 70 + 50) / 3 = 73.33... → 73 (nearest whole point)
|
// (100 + 70 + 50) / 3 = 73.33...
|
||||||
expect(points).toBe(73);
|
expect(points).toBeCloseTo(73.33, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle single placement (no tie)", () => {
|
it("should handle single placement (no tie)", () => {
|
||||||
|
|
@ -109,18 +109,7 @@ describe("Scoring Calculator", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats positions beyond 8th as zero at the scoring cutoff", () => {
|
it("treats positions beyond 8th as zero at the scoring cutoff", () => {
|
||||||
// Two tied for 8th share 8th + 9th; 9th is outside the scoring range and
|
expect(calculateSharedPlacementPoints(8, 2, DEFAULT_SCORING)).toBe(7.5);
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -161,8 +150,8 @@ describe("Scoring Calculator", () => {
|
||||||
describe("Edge Cases", () => {
|
describe("Edge Cases", () => {
|
||||||
it("should handle all participants tying for 1st-8th", () => {
|
it("should handle all participants tying for 1st-8th", () => {
|
||||||
const points = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
|
const points = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
|
||||||
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43
|
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5
|
||||||
expect(points).toBe(43);
|
expect(points).toBe(42.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle placements with same point values", () => {
|
it("should handle placements with same point values", () => {
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ describe("Season Standings (F1 Pattern)", () => {
|
||||||
it("should handle 3-way tie for 5th place", () => {
|
it("should handle 3-way tie for 5th place", () => {
|
||||||
// Three drivers tied for 5th share 5th, 6th, and 7th place points
|
// Three drivers tied for 5th share 5th, 6th, and 7th place points
|
||||||
const points = calculateAveragedPoints([5, 6, 7], DEFAULT_SCORING);
|
const points = calculateAveragedPoints([5, 6, 7], DEFAULT_SCORING);
|
||||||
// (25 + 25 + 15) / 3 = 21.67 → 22 (nearest whole point)
|
// (25 + 25 + 15) / 3 = 21.67
|
||||||
expect(points).toBe(22);
|
expect(points).toBeCloseTo(21.67, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle 4-way tie for 1st place", () => {
|
it("should handle 4-way tie for 1st place", () => {
|
||||||
|
|
@ -100,7 +100,7 @@ describe("Season Standings (F1 Pattern)", () => {
|
||||||
|
|
||||||
// Verify tied positions get averaged
|
// Verify tied positions get averaged
|
||||||
const tiedPoints = calculateAveragedPoints([3, 4, 5], DEFAULT_SCORING);
|
const tiedPoints = calculateAveragedPoints([3, 4, 5], DEFAULT_SCORING);
|
||||||
expect(tiedPoints).toBe(38); // (50 + 40 + 25) / 3 = 38.33 → 38
|
expect(tiedPoints).toBeCloseTo(38.33, 2); // (50 + 40 + 25) / 3
|
||||||
|
|
||||||
// Verify remaining positions
|
// Verify remaining positions
|
||||||
expect(calculateFantasyPoints(6, DEFAULT_SCORING)).toBe(25);
|
expect(calculateFantasyPoints(6, DEFAULT_SCORING)).toBe(25);
|
||||||
|
|
@ -143,7 +143,7 @@ describe("Season Standings (F1 Pattern)", () => {
|
||||||
|
|
||||||
// Tied positions
|
// Tied positions
|
||||||
const tied3rd = calculateAveragedPoints([3, 4], customScoring);
|
const tied3rd = calculateAveragedPoints([3, 4], customScoring);
|
||||||
expect(tied3rd).toBe(68); // (75 + 60) / 2 = 67.5 → 68 (halves round up)
|
expect(tied3rd).toBe(67.5); // (75 + 60) / 2
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -151,8 +151,8 @@ describe("Season Standings (F1 Pattern)", () => {
|
||||||
it("should handle all 8 positions tied", () => {
|
it("should handle all 8 positions tied", () => {
|
||||||
// Extremely unlikely but mathematically possible
|
// Extremely unlikely but mathematically possible
|
||||||
const allTied = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
|
const allTied = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
|
||||||
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43
|
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5
|
||||||
expect(allTied).toBe(43);
|
expect(allTied).toBe(42.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle only top 4 finishing (others DNF/DQ)", () => {
|
it("should handle only top 4 finishing (others DNF/DQ)", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
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("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,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
const undrafted = [{ sportsSeasonId: "ss-1", finalPosition: 8 }];
|
|
||||||
|
|
||||||
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);
|
|
||||||
expect(breakdown?.actualPoints).toBe(128); // 8 (T8) + 100 (1st) + 20 (T5-8)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -344,7 +344,7 @@ describe("calculateTeamScore", () => {
|
||||||
|
|
||||||
const result = await calculateTeamScore("team1", "season1", db);
|
const result = await calculateTeamScore("team1", "season1", db);
|
||||||
|
|
||||||
expect(result.totalPoints).toBe(8); // (15 + 0) / 2 = 7.5 → 8
|
expect(result.totalPoints).toBe(7.5); // (15 + 0) / 2
|
||||||
expect(result.participantsCompleted).toBe(1);
|
expect(result.participantsCompleted).toBe(1);
|
||||||
expect(result.placementCounts[8]).toBe(1);
|
expect(result.placementCounts[8]).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("~/lib/logger", () => ({
|
vi.mock("~/lib/logger", () => ({
|
||||||
logger: { error: vi.fn(), warn: vi.fn() },
|
logger: { error: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── DB mock helpers ────────────────────────────────────────────────────────
|
// ── DB mock helpers ────────────────────────────────────────────────────────
|
||||||
|
|
@ -16,13 +16,7 @@ function makeInsertChain() {
|
||||||
interface MakeDbOpts {
|
interface MakeDbOpts {
|
||||||
sportsSeason?: { sport: { name: string } } | null;
|
sportsSeason?: { sport: { name: string } } | null;
|
||||||
seasonSports?: { seasonId: string }[];
|
seasonSports?: { seasonId: string }[];
|
||||||
picks?: { teamId: string; seasonId: string; participantId?: string }[];
|
picks?: { teamId: string; seasonId: string }[];
|
||||||
scoringEvent?: { id: string; name: string } | null;
|
|
||||||
participantResults?: {
|
|
||||||
participantId: string;
|
|
||||||
finalPosition: number | null;
|
|
||||||
sportsSeasonId?: string;
|
|
||||||
}[];
|
|
||||||
seasons?: {
|
seasons?: {
|
||||||
id: string;
|
id: string;
|
||||||
pointsFor1st: number; pointsFor2nd: number; pointsFor3rd: number;
|
pointsFor1st: number; pointsFor2nd: number; pointsFor3rd: number;
|
||||||
|
|
@ -47,8 +41,6 @@ function makeDb(opts: MakeDbOpts = {}) {
|
||||||
seasons = [],
|
seasons = [],
|
||||||
scoreEventRows = [],
|
scoreEventRows = [],
|
||||||
participantRows = [],
|
participantRows = [],
|
||||||
scoringEvent = null,
|
|
||||||
participantResults = [],
|
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const chain = makeInsertChain();
|
const chain = makeInsertChain();
|
||||||
|
|
@ -75,24 +67,13 @@ function makeDb(opts: MakeDbOpts = {}) {
|
||||||
seasonParticipants: {
|
seasonParticipants: {
|
||||||
findMany: vi.fn().mockResolvedValue(participantRows),
|
findMany: vi.fn().mockResolvedValue(participantRows),
|
||||||
},
|
},
|
||||||
scoringEvents: {
|
|
||||||
findFirst: vi.fn().mockResolvedValue(scoringEvent),
|
|
||||||
},
|
|
||||||
seasonParticipantResults: {
|
|
||||||
findMany: vi.fn().mockResolvedValue(participantResults),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
chain,
|
chain,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
import {
|
import { recordTeamScoreEvent, recordMatchScoreEvents, getRecentTeamScoreEvents } from "../team-score-events";
|
||||||
recordTeamScoreEvent,
|
|
||||||
recordMatchScoreEvents,
|
|
||||||
recordFinalPlacementScoreEvents,
|
|
||||||
getRecentTeamScoreEvents,
|
|
||||||
} from "../team-score-events";
|
|
||||||
|
|
||||||
const BASE_PARAMS = {
|
const BASE_PARAMS = {
|
||||||
teamId: "team-1",
|
teamId: "team-1",
|
||||||
|
|
@ -377,155 +358,3 @@ describe("getRecentTeamScoreEvents", () => {
|
||||||
expect(result[0].participants).toEqual([]);
|
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" })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, inArray, asc } from "drizzle-orm";
|
import { eq, and, inArray, asc } from "drizzle-orm";
|
||||||
import { getScoringRules, calculatePickPoints } from "./scoring-rules";
|
|
||||||
import {
|
import {
|
||||||
getSharedPlacementCounts,
|
getScoringRules,
|
||||||
lookupSharedPlacementCount,
|
calculateFantasyPoints,
|
||||||
} from "./participant-result";
|
calculateBracketPoints,
|
||||||
|
calculateSharedPlacementPoints,
|
||||||
|
} from "./scoring-rules";
|
||||||
|
|
||||||
export async function createDraftPick(data: {
|
export async function createDraftPick(data: {
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
|
|
@ -202,10 +203,19 @@ export async function getDraftedParticipantsWithPoints(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const qpSharedPlacementCounts = await getSharedPlacementCounts(
|
const qpSharedPlacementCounts = new Map<string, Map<number, number>>();
|
||||||
[...finalizedQPSeasonIds],
|
if (finalizedQPSeasonIds.size > 0) {
|
||||||
db
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Assemble result grouped by sportsSeasonId
|
// Assemble result grouped by sportsSeasonId
|
||||||
const result = new Map<string, DraftedParticipantWithPoints[]>();
|
const result = new Map<string, DraftedParticipantWithPoints[]>();
|
||||||
|
|
@ -223,14 +233,23 @@ export async function getDraftedParticipantsWithPoints(
|
||||||
currentQP = qpMap.get(id) ?? null;
|
currentQP = qpMap.get(id) ?? null;
|
||||||
} else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) {
|
} else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) {
|
||||||
// Finalized result for any pattern (including finalized QP seasons)
|
// Finalized result for any pattern (including finalized QP seasons)
|
||||||
earnedPoints = calculatePickPoints(resultRow.finalPosition, pattern, scoringRules, {
|
if (pattern === "playoff_bracket") {
|
||||||
bracketTemplateId: bracketTemplateMap.get(sportsSeasonId) ?? null,
|
earnedPoints = calculateBracketPoints(
|
||||||
tiedParticipants: lookupSharedPlacementCount(
|
resultRow.finalPosition,
|
||||||
qpSharedPlacementCounts,
|
scoringRules,
|
||||||
sportsSeasonId,
|
bracketTemplateMap.get(sportsSeasonId) ?? null
|
||||||
resultRow.finalPosition
|
);
|
||||||
),
|
} 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const arr = result.get(sportsSeasonId) ?? [];
|
const arr = result.get(sportsSeasonId) ?? [];
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
|
@ -76,57 +76,6 @@ export async function findParticipantResultsBySportsSeasonId(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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. Positions <= 0 (no scoring placement) are excluded.
|
|
||||||
*
|
|
||||||
* Callers that look up a position with no entry should treat it as 1 (no tie).
|
|
||||||
*/
|
|
||||||
export async function getSharedPlacementCounts(
|
|
||||||
sportsSeasonIds: string[],
|
|
||||||
providedDb?: ReturnType<typeof database>
|
|
||||||
): Promise<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;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(
|
export async function updateParticipantResult(
|
||||||
id: string,
|
id: string,
|
||||||
data: Partial<NewParticipantResult>
|
data: Partial<NewParticipantResult>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { getScoringRules, calculatePickPoints, type ScoringRules } from "./scoring-rules";
|
import {
|
||||||
import { getSharedPlacementCounts } from "./participant-result";
|
getScoringRules,
|
||||||
|
calculateFantasyPoints,
|
||||||
|
calculateBracketPoints,
|
||||||
|
calculateSharedPlacementPoints,
|
||||||
|
} from "./scoring-rules";
|
||||||
import { getSeasonResults } from "./participant-season-result";
|
import { getSeasonResults } from "./participant-season-result";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
|
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
|
||||||
|
|
@ -12,10 +16,7 @@ import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-
|
||||||
import { getUserDisplayName } from "~/models/user";
|
import { getUserDisplayName } from "~/models/user";
|
||||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
import {
|
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
||||||
recordMatchScoreEvents,
|
|
||||||
recordFinalPlacementScoreEvents,
|
|
||||||
} from "~/models/team-score-events";
|
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getEventResults } from "./event-result";
|
import { getEventResults } from "./event-result";
|
||||||
import {
|
import {
|
||||||
|
|
@ -594,11 +595,6 @@ 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(
|
async function getSharedPlacementCount(
|
||||||
sportsSeasonId: string,
|
sportsSeasonId: string,
|
||||||
finalPosition: number,
|
finalPosition: number,
|
||||||
|
|
@ -606,8 +602,17 @@ async function getSharedPlacementCount(
|
||||||
cache: Map<string, Map<number, number>>
|
cache: Map<string, Map<number, number>>
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
if (!cache.has(sportsSeasonId)) {
|
if (!cache.has(sportsSeasonId)) {
|
||||||
const counts = await getSharedPlacementCounts([sportsSeasonId], db);
|
const results = await db.query.seasonParticipantResults.findMany({
|
||||||
cache.set(sportsSeasonId, counts.get(sportsSeasonId) ?? new Map<number, number>());
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return cache.get(sportsSeasonId)?.get(finalPosition) ?? 1;
|
return cache.get(sportsSeasonId)?.get(finalPosition) ?? 1;
|
||||||
|
|
@ -1173,13 +1178,6 @@ export async function finalizeQualifyingPoints(
|
||||||
})
|
})
|
||||||
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
|
.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.
|
|
||||||
await recordFinalPlacementScoreEvents(
|
|
||||||
{ sportsSeasonId, eventName: "Final Standings" },
|
|
||||||
db
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trigger recalculation for all affected leagues
|
// Trigger recalculation for all affected leagues
|
||||||
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" });
|
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" });
|
||||||
|
|
||||||
|
|
@ -1283,13 +1281,6 @@ 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
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trigger recalculation for all affected leagues
|
// Trigger recalculation for all affected leagues
|
||||||
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });
|
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });
|
||||||
|
|
||||||
|
|
@ -1385,22 +1376,27 @@ export async function calculateTeamScore(
|
||||||
const result = pick.participant.results[0];
|
const result = pick.participant.results[0];
|
||||||
|
|
||||||
if (result && result.finalPosition !== null && result.finalPosition > 0) {
|
if (result && result.finalPosition !== null && result.finalPosition > 0) {
|
||||||
const pattern = pick.participant.sportsSeason?.scoringPattern;
|
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
|
||||||
const points = calculatePickPoints(result.finalPosition, pattern, scoringRules, {
|
const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points";
|
||||||
bracketTemplateId:
|
let points: number;
|
||||||
pattern === "playoff_bracket"
|
if (isBracket) {
|
||||||
? await getBracketTemplate(pick.participant.sportsSeasonId)
|
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
|
||||||
: null,
|
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
|
||||||
tiedParticipants:
|
} else if (isQualifyingPoints) {
|
||||||
pattern === "qualifying_points"
|
const tiedParticipants = await getSharedPlacementCount(
|
||||||
? await getSharedPlacementCount(
|
pick.participant.sportsSeasonId,
|
||||||
pick.participant.sportsSeasonId,
|
result.finalPosition,
|
||||||
result.finalPosition,
|
db,
|
||||||
db,
|
sharedPlacementCountCache
|
||||||
sharedPlacementCountCache
|
);
|
||||||
)
|
points = calculateSharedPlacementPoints(
|
||||||
: 1,
|
result.finalPosition,
|
||||||
});
|
tiedParticipants,
|
||||||
|
scoringRules
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
||||||
|
}
|
||||||
totalPoints += points;
|
totalPoints += points;
|
||||||
|
|
||||||
// All participants with a valid position count toward the placement tiebreaker,
|
// All participants with a valid position count toward the placement tiebreaker,
|
||||||
|
|
@ -1487,52 +1483,57 @@ export async function calculateTeamProjectedScore(
|
||||||
return templateId;
|
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:
|
|
||||||
pattern === "qualifying_points"
|
|
||||||
? await getSharedPlacementCount(
|
|
||||||
sportsSeasonId,
|
|
||||||
finalPosition,
|
|
||||||
db,
|
|
||||||
sharedPlacementCountCache
|
|
||||||
)
|
|
||||||
: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate finished vs unfinished participants
|
// Separate finished vs unfinished participants
|
||||||
for (const pick of picks) {
|
for (const pick of picks) {
|
||||||
const result = pick.participant.results[0];
|
const result = pick.participant.results[0];
|
||||||
const pattern = pick.participant.sportsSeason?.scoringPattern;
|
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
|
||||||
|
const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points";
|
||||||
|
|
||||||
if (result && result.finalPosition !== null && !result.isPartialScore) {
|
if (result && result.finalPosition !== null && !result.isPartialScore) {
|
||||||
// Participant is fully finalized
|
// Participant is fully finalized — use bracket-averaged points
|
||||||
actualPoints += await pointsForPick(
|
let points: number;
|
||||||
pick.participant.sportsSeasonId,
|
if (isBracket) {
|
||||||
pattern,
|
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
|
||||||
result.finalPosition
|
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;
|
||||||
participantsFinished++;
|
participantsFinished++;
|
||||||
} else if (result && result.finalPosition !== null && result.isPartialScore) {
|
} else if (result && result.finalPosition !== null && result.isPartialScore) {
|
||||||
// Still alive with a provisional floor — count floor as actual, EV for projection.
|
// Still alive with a provisional floor — count floor as actual, EV for projection.
|
||||||
// Note: NOT incremented in participantsFinished; these participants are still competing.
|
// Note: NOT incremented in participantsFinished; these participants are still competing.
|
||||||
const floorPoints = await pointsForPick(
|
const templateId = isBracket ? await getBracketTemplate(pick.participant.sportsSeasonId) : null;
|
||||||
pick.participant.sportsSeasonId,
|
let floorPoints: number;
|
||||||
pattern,
|
if (isBracket) {
|
||||||
result.finalPosition
|
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);
|
||||||
|
}
|
||||||
actualPoints += floorPoints;
|
actualPoints += floorPoints;
|
||||||
// EV already accounts for their full projected value, so subtract floor to avoid
|
// EV already accounts for their full projected value, so subtract floor to avoid
|
||||||
// double-counting when we do actualPoints + evSum below
|
// double-counting when we do actualPoints + evSum below
|
||||||
|
|
|
||||||
|
|
@ -90,13 +90,6 @@ export function calculateFantasyPoints(
|
||||||
*
|
*
|
||||||
* Example: 4 teams lose in quarterfinals, they share positions 5-8
|
* Example: 4 teams lose in quarterfinals, they share positions 5-8
|
||||||
* Average = (25 + 25 + 15 + 15) / 4 = 20 points each
|
* Average = (25 + 25 + 15 + 15) / 4 = 20 points each
|
||||||
*
|
|
||||||
* The result is rounded to the nearest whole point, matching the published rule
|
|
||||||
* on the /rules page: "the points for all tied positions are combined and split
|
|
||||||
* equally among them, rounded to the nearest whole point". That page's own
|
|
||||||
* example rounds down — a three-way tie for 6th–8th is (25 + 15 + 15) / 3 =
|
|
||||||
* 18.33 → 18 — so this is nearest, not ceiling. Season point values are integer
|
|
||||||
* columns, so this averaging is the only place fractional points can arise.
|
|
||||||
*/
|
*/
|
||||||
export function calculateAveragedPoints(
|
export function calculateAveragedPoints(
|
||||||
placements: number[],
|
placements: number[],
|
||||||
|
|
@ -108,9 +101,7 @@ export function calculateAveragedPoints(
|
||||||
return sum + calculateFantasyPoints(placement, rules);
|
return sum + calculateFantasyPoints(placement, rules);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Epsilon guard mirrors roundQualifyingPoints — keeps values that are exactly
|
return total / placements.length;
|
||||||
// representable-adjacent (e.g. 18.499999999999996) from rounding the wrong way.
|
|
||||||
return Math.round(total / placements.length + Number.EPSILON);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -199,43 +190,6 @@ export function calculateBracketPoints(
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
* were missing the qualifying_points arm entirely — that divergence is what made
|
|
||||||
* a tied golfer worth 15 points on the team page and draft board but 7.5 in the
|
|
||||||
* standings. Every caller must route through here.
|
|
||||||
*
|
|
||||||
* @param finalPosition - Placement in the sports season (1-8 scores, 0 = none).
|
|
||||||
* @param scoringPattern - The sports season's scoringPattern column.
|
|
||||||
* @param rules - The fantasy season's point values.
|
|
||||||
* @param opts.bracketTemplateId - Required for playoff_bracket to pick the right
|
|
||||||
* tier structure (e.g. AFL/LLWS split 5–8 into two pairs).
|
|
||||||
* @param opts.tiedParticipants - Required for 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).
|
|
||||||
*/
|
|
||||||
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 (scoringPattern === "qualifying_points") {
|
|
||||||
return calculateSharedPlacementPoints(
|
|
||||||
finalPosition,
|
|
||||||
opts?.tiedParticipants ?? 1,
|
|
||||||
rules
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return calculateFantasyPoints(finalPosition, rules);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th]
|
* Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th]
|
||||||
* Useful for display purposes
|
* Useful for display purposes
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,7 @@ import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
|
import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
|
||||||
import { calculatePickPoints } from "~/models/scoring-rules";
|
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
|
||||||
import {
|
|
||||||
getSharedPlacementCounts,
|
|
||||||
lookupSharedPlacementCount,
|
|
||||||
} from "~/models/participant-result";
|
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getParticipantEV } from "./participant-expected-value";
|
import { getParticipantEV } from "./participant-expected-value";
|
||||||
import { calculateEV } from "~/services/ev-calculator";
|
import { calculateEV } from "~/services/ev-calculator";
|
||||||
|
|
@ -161,14 +157,6 @@ export async function getTeamScoreBreakdown(
|
||||||
pointsFor8th: season.pointsFor8th,
|
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)
|
// Cache bracket template IDs per sports season (same approach as calculateTeamScore)
|
||||||
const bracketTemplateCache = new Map<string, string | null>();
|
const bracketTemplateCache = new Map<string, string | null>();
|
||||||
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
|
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
|
||||||
|
|
@ -188,7 +176,7 @@ export async function getTeamScoreBreakdown(
|
||||||
const pickBreakdown = await Promise.all(
|
const pickBreakdown = await Promise.all(
|
||||||
picks.map(async (pick) => {
|
picks.map(async (pick) => {
|
||||||
const result = pick.participant.results[0];
|
const result = pick.participant.results[0];
|
||||||
const pattern = pick.participant.sportsSeason.scoringPattern;
|
const isBracket = pick.participant.sportsSeason.scoringPattern === "playoff_bracket";
|
||||||
let points = 0;
|
let points = 0;
|
||||||
let projectedPoints: number | null = null;
|
let projectedPoints: number | null = null;
|
||||||
|
|
||||||
|
|
@ -211,17 +199,13 @@ export async function getTeamScoreBreakdown(
|
||||||
};
|
};
|
||||||
|
|
||||||
if (result && result.finalPosition !== null && result.finalPosition > 0) {
|
if (result && result.finalPosition !== null && result.finalPosition > 0) {
|
||||||
points = calculatePickPoints(result.finalPosition, pattern, scoringRules, {
|
// Calculate points using bracket-averaged scoring for bracket sports
|
||||||
bracketTemplateId:
|
if (isBracket) {
|
||||||
pattern === "playoff_bracket"
|
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
|
||||||
? await getBracketTemplate(pick.participant.sportsSeasonId)
|
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
|
||||||
: null,
|
} else {
|
||||||
tiedParticipants: lookupSharedPlacementCount(
|
points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
||||||
sharedPlacementCounts,
|
}
|
||||||
pick.participant.sportsSeasonId,
|
|
||||||
result.finalPosition
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.isPartialScore) {
|
if (result.isPartialScore) {
|
||||||
// Still alive with a floor position — use EV for projected since they can advance
|
// Still alive with a floor position — use EV for projected since they can advance
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, inArray, desc, sql, and } from "drizzle-orm";
|
import { eq, inArray, desc, sql, and } from "drizzle-orm";
|
||||||
import {
|
import { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules";
|
||||||
calculateBracketPoints,
|
|
||||||
calculatePickPoints,
|
|
||||||
type ScoringRules,
|
|
||||||
} from "~/models/scoring-rules";
|
|
||||||
import {
|
|
||||||
getSharedPlacementCounts,
|
|
||||||
lookupSharedPlacementCount,
|
|
||||||
} from "~/models/participant-result";
|
|
||||||
import { findParticipantNamesByIds } from "~/models/season-participant";
|
import { findParticipantNamesByIds } from "~/models/season-participant";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
||||||
|
|
@ -198,174 +190,6 @@ export async function recordMatchScoreEvents(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* `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
|
|
||||||
* unaffected either way.
|
|
||||||
*/
|
|
||||||
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.
|
|
||||||
let eventId = params.eventId ?? null;
|
|
||||||
let eventName = params.eventName ?? null;
|
|
||||||
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)],
|
|
||||||
});
|
|
||||||
if (!anchor) {
|
|
||||||
logger.warn(
|
|
||||||
`[TeamScoreEvents] No 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.
|
|
||||||
const results = await db.query.seasonParticipantResults.findMany({
|
|
||||||
where: eq(schema.seasonParticipantResults.sportsSeasonId, params.sportsSeasonId),
|
|
||||||
columns: { 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 = await getSharedPlacementCounts([params.sportsSeasonId], db);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
export interface TeamScoreEventEntry {
|
||||||
id: string;
|
id: string;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import { Button } from "~/components/ui/button";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import logomarkUrl from "../../../public/logomark.svg?url";
|
import logomarkUrl from "../../../public/logomark.svg?url";
|
||||||
import { computeCoronaStates } from "~/lib/corona-states";
|
import { computeCoronaStates } from "~/lib/corona-states";
|
||||||
import { getSharedPlacementCounts } from "~/models/participant-result";
|
|
||||||
import type { CoronaState } from "~/components/draft/DraftPickCell";
|
import type { CoronaState } from "~/components/draft/DraftPickCell";
|
||||||
import type { Route } from "./+types/$leagueId.draft-board.$seasonId";
|
import type { Route } from "./+types/$leagueId.draft-board.$seasonId";
|
||||||
|
|
||||||
|
|
@ -158,19 +157,12 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
pointsFor8th: season.pointsFor8th,
|
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(
|
coronaStates = computeCoronaStates(
|
||||||
draftPicks,
|
draftPicks,
|
||||||
resultByParticipant,
|
resultByParticipant,
|
||||||
bracketTemplateBySportsSeason,
|
bracketTemplateBySportsSeason,
|
||||||
scoringRules,
|
scoringRules,
|
||||||
season.pointsFor1st,
|
season.pointsFor1st,
|
||||||
sharedPlacementCountsBySportsSeason,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* 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 } 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";
|
|
||||||
|
|
||||||
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: 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}`);
|
|
||||||
|
|
||||||
let recalculated = 0;
|
|
||||||
let recalcFailed = 0;
|
|
||||||
for (const season of seasons) {
|
|
||||||
if (DRY) continue;
|
|
||||||
try {
|
|
||||||
await recalculateStandings(season.id, db);
|
|
||||||
recalculated += 1;
|
|
||||||
} catch (e) {
|
|
||||||
recalcFailed += 1;
|
|
||||||
log(` ! season ${season.id} (${season.year}): ${(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);
|
|
||||||
});
|
|
||||||
Loading…
Add table
Reference in a new issue