Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all of their points in one step at finalization, so they produce no per-match deltas. recordMatchScoreEvents is bracket-only and fires from match processing, which meant these sports were never written to team_score_events at all and silently never appeared in Recent Scores. Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints and processSeasonStandings. It writes one row per team at the ledger's event-level grain, carrying that team's summed award and every contributing participant, with points from calculatePickPoints so a tied golfer contributes the same split award the standings show. The row is anchored to a real scoring event rather than a null one: the event-level unique index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as distinct, so a null anchor would duplicate rows on every re-finalization instead of upserting. When no anchor can be resolved the ledger write is skipped, which leaves standings unaffected. Adds regression coverage for the two screens that had diverged — getTeamScoreBreakdown and computeCoronaStates — including an assertion that the team page's actualPoints equals calculateTeamScore's totalPoints for the same roster, and cases proving undrafted participants still count toward a tie span. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
This commit is contained in:
parent
f3d63922d0
commit
2d571d8010
5 changed files with 669 additions and 5 deletions
122
app/lib/__tests__/corona-states.test.ts
Normal file
122
app/lib/__tests__/corona-states.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
178
app/models/__tests__/standings-breakdown.test.ts
Normal file
178
app/models/__tests__/standings-breakdown.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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() },
|
logger: { error: vi.fn(), warn: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── DB mock helpers ────────────────────────────────────────────────────────
|
// ── DB mock helpers ────────────────────────────────────────────────────────
|
||||||
|
|
@ -16,7 +16,13 @@ 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 }[];
|
picks?: { teamId: string; seasonId: string; participantId?: 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;
|
||||||
|
|
@ -41,6 +47,8 @@ function makeDb(opts: MakeDbOpts = {}) {
|
||||||
seasons = [],
|
seasons = [],
|
||||||
scoreEventRows = [],
|
scoreEventRows = [],
|
||||||
participantRows = [],
|
participantRows = [],
|
||||||
|
scoringEvent = null,
|
||||||
|
participantResults = [],
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const chain = makeInsertChain();
|
const chain = makeInsertChain();
|
||||||
|
|
@ -67,13 +75,24 @@ 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 { recordTeamScoreEvent, recordMatchScoreEvents, getRecentTeamScoreEvents } from "../team-score-events";
|
import {
|
||||||
|
recordTeamScoreEvent,
|
||||||
|
recordMatchScoreEvents,
|
||||||
|
recordFinalPlacementScoreEvents,
|
||||||
|
getRecentTeamScoreEvents,
|
||||||
|
} from "../team-score-events";
|
||||||
|
|
||||||
const BASE_PARAMS = {
|
const BASE_PARAMS = {
|
||||||
teamId: "team-1",
|
teamId: "team-1",
|
||||||
|
|
@ -358,3 +377,155 @@ 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" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ 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 { recordMatchScoreEvents } from "~/models/team-score-events";
|
import {
|
||||||
|
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 {
|
||||||
|
|
@ -1170,6 +1173,13 @@ 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" });
|
||||||
|
|
||||||
|
|
@ -1273,6 +1283,13 @@ 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" });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
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 { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules";
|
import {
|
||||||
|
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";
|
||||||
|
|
||||||
|
|
@ -190,6 +198,174 @@ 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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue