Fix QP tie rounding and finalized scoring (#440)
This commit is contained in:
parent
4bbcac1949
commit
1f0fcf970b
14 changed files with 555 additions and 57 deletions
55
app/components/__tests__/QualifyingPointsStandings.test.tsx
Normal file
55
app/components/__tests__/QualifyingPointsStandings.test.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings";
|
||||
|
||||
const scoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 50,
|
||||
pointsFor4th: 40,
|
||||
pointsFor5th: 25,
|
||||
pointsFor6th: 25,
|
||||
pointsFor7th: 15,
|
||||
pointsFor8th: 15,
|
||||
};
|
||||
|
||||
describe("QualifyingPointsStandings", () => {
|
||||
it("renders fractional QP to hundredths", () => {
|
||||
render(
|
||||
<QualifyingPointsStandings
|
||||
standings={[
|
||||
{
|
||||
id: "standing-1",
|
||||
totalQualifyingPoints: "14.67",
|
||||
eventsScored: 1,
|
||||
finalRanking: null,
|
||||
participant: { id: "participant-1", name: "Player One" },
|
||||
},
|
||||
{
|
||||
id: "standing-2",
|
||||
totalQualifyingPoints: "0.50",
|
||||
eventsScored: 1,
|
||||
finalRanking: null,
|
||||
participant: { id: "participant-2", name: "Player Two" },
|
||||
},
|
||||
{
|
||||
id: "standing-3",
|
||||
totalQualifyingPoints: "0.43",
|
||||
eventsScored: 1,
|
||||
finalRanking: null,
|
||||
participant: { id: "participant-3", name: "Player Three" },
|
||||
},
|
||||
]}
|
||||
scoringRules={scoringRules}
|
||||
isFinalized={false}
|
||||
totalMajors={4}
|
||||
majorsCompleted={1}
|
||||
canFinalize={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.50 QP")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -54,7 +54,7 @@ interface QualifyingPointsStandingsProps {
|
|||
function formatQP(raw: string): string {
|
||||
const n = parseFloat(raw);
|
||||
if (isNaN(n)) return "—";
|
||||
return n % 1 === 0 ? n.toString() : n.toFixed(1);
|
||||
return n % 1 === 0 ? n.toString() : n.toFixed(2);
|
||||
}
|
||||
|
||||
export function QualifyingPointsStandings({
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ interface MakeDbOpts {
|
|||
picks?: PickRow[];
|
||||
scoringEvents?: { sportsSeasonId: string; bracketTemplateId: string | null }[];
|
||||
qualifyingTotals?: { participantId: string; totalQualifyingPoints: string }[];
|
||||
seasonParticipantResults?: { sportsSeasonId: string; finalPosition: number | null }[];
|
||||
}
|
||||
|
||||
function makeDb(opts: MakeDbOpts = {}) {
|
||||
|
|
@ -32,6 +33,7 @@ function makeDb(opts: MakeDbOpts = {}) {
|
|||
picks = [],
|
||||
scoringEvents = [],
|
||||
qualifyingTotals = [],
|
||||
seasonParticipantResults = [],
|
||||
} = opts;
|
||||
|
||||
return {
|
||||
|
|
@ -48,6 +50,9 @@ function makeDb(opts: MakeDbOpts = {}) {
|
|||
seasonParticipantQualifyingTotals: {
|
||||
findMany: vi.fn().mockResolvedValue(qualifyingTotals),
|
||||
},
|
||||
seasonParticipantResults: {
|
||||
findMany: vi.fn().mockResolvedValue(seasonParticipantResults),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
|
@ -185,6 +190,24 @@ describe("getDraftedParticipantsWithPoints", () => {
|
|||
currentQP: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("splits earnedPoints for tied QP placements", async () => {
|
||||
const db = makeDb({
|
||||
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "qualifying_points", finalPosition: 2 })],
|
||||
qualifyingTotals: [{ participantId: "p-1", totalQualifyingPoints: "99" }],
|
||||
seasonParticipantResults: [
|
||||
{ sportsSeasonId: "ss-1", finalPosition: 2 },
|
||||
{ sportsSeasonId: "ss-1", finalPosition: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
||||
|
||||
expect(result.get("ss-1")?.[0]).toMatchObject({
|
||||
earnedPoints: 62.5, // (75 + 50) / 2
|
||||
currentQP: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("season_standings pattern", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { DEFAULT_QP_VALUES } from "../qualifying-points";
|
||||
import {
|
||||
calculateSplitQualifyingPoints,
|
||||
DEFAULT_QP_VALUES,
|
||||
hasProcessedQualifyingPlacement,
|
||||
} from "../qualifying-points";
|
||||
|
||||
describe("Qualifying Points Configuration", () => {
|
||||
describe("DEFAULT_QP_VALUES", () => {
|
||||
|
|
@ -163,6 +167,14 @@ describe("Qualifying Points Configuration", () => {
|
|||
});
|
||||
|
||||
describe("Tie Handling During QP Awards", () => {
|
||||
const pointsByPlacement = new Map(
|
||||
DEFAULT_QP_VALUES.map(({ placement, points }) => [placement, points])
|
||||
);
|
||||
|
||||
it("rounds a 4-way golf tie for 15th to the nearest hundredth", () => {
|
||||
expect(calculateSplitQualifyingPoints(15, 4, pointsByPlacement)).toBe(0.5);
|
||||
});
|
||||
|
||||
it("should correctly split QP among tied participants", () => {
|
||||
// Scenario: 7 players tied for 14th place
|
||||
// They occupy positions 14-20
|
||||
|
|
@ -192,6 +204,7 @@ describe("Qualifying Points Configuration", () => {
|
|||
const qpPerPlayer = totalQP / tieCount;
|
||||
|
||||
expect(totalQP).toBe(3);
|
||||
expect(calculateSplitQualifyingPoints(14, 7, pointsByPlacement)).toBe(0.43);
|
||||
expect(qpPerPlayer).toBeCloseTo(0.428571, 5);
|
||||
});
|
||||
|
||||
|
|
@ -232,6 +245,7 @@ describe("Qualifying Points Configuration", () => {
|
|||
const qpPerPlayer = totalQP / tieCount;
|
||||
|
||||
expect(totalQP).toBe(44);
|
||||
expect(calculateSplitQualifyingPoints(1, 3, pointsByPlacement)).toBe(14.67);
|
||||
expect(qpPerPlayer).toBeCloseTo(14.666667, 5);
|
||||
});
|
||||
});
|
||||
|
|
@ -279,6 +293,32 @@ describe("Qualifying Points Configuration", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Processed event detection", () => {
|
||||
it("treats a placed zero-QP result as already processed", () => {
|
||||
expect(
|
||||
hasProcessedQualifyingPlacement([
|
||||
{ placement: 20, qualifyingPointsAwarded: "0.00" },
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat filler zero-QP rows without placements as processed", () => {
|
||||
expect(
|
||||
hasProcessedQualifyingPlacement([
|
||||
{ placement: null, qualifyingPointsAwarded: "0" },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat unprocessed placed rows as processed", () => {
|
||||
expect(
|
||||
hasProcessedQualifyingPlacement([
|
||||
{ placement: 15, qualifyingPointsAwarded: null },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scoring Workflow", () => {
|
||||
it("should process 4 majors and calculate final standings", () => {
|
||||
// Simulating a full season with 4 majors
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { calculateFantasyPoints, type ScoringRules } from "../scoring-rules";
|
||||
import { processQualifyingEvent } from "../scoring-calculator";
|
||||
import { DEFAULT_QP_VALUES } from "../qualifying-points";
|
||||
|
||||
const DEFAULT_SCORING: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
|
|
@ -196,4 +198,97 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
expect(points).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processQualifyingEvent", () => {
|
||||
function makeProcessQPMockDb(eventResults: Array<Record<string, unknown>>) {
|
||||
const setCalls: unknown[] = [];
|
||||
const updateChain = {
|
||||
set: (values: unknown) => {
|
||||
setCalls.push(values);
|
||||
return {
|
||||
where: async () => [],
|
||||
};
|
||||
},
|
||||
};
|
||||
const db = {
|
||||
query: {
|
||||
scoringEvents: {
|
||||
findFirst: async () => ({
|
||||
id: "event-1",
|
||||
sportsSeasonId: "sports-season-1",
|
||||
isQualifyingEvent: true,
|
||||
sportsSeason: { majorsCompleted: 1 },
|
||||
}),
|
||||
},
|
||||
eventResults: {
|
||||
findMany: async () => eventResults,
|
||||
},
|
||||
qualifyingPointConfig: {
|
||||
findMany: async () =>
|
||||
DEFAULT_QP_VALUES.map((qp) => ({
|
||||
placement: qp.placement,
|
||||
points: qp.points.toString(),
|
||||
})),
|
||||
},
|
||||
seasonParticipantQualifyingTotals: {
|
||||
findFirst: async ({ where }: { where?: unknown }) => ({
|
||||
id: String(where ?? "total"),
|
||||
totalQualifyingPoints: "0",
|
||||
eventsScored: 0,
|
||||
}),
|
||||
},
|
||||
},
|
||||
update: () => updateChain,
|
||||
} as any;
|
||||
|
||||
return { db, setCalls };
|
||||
}
|
||||
|
||||
it("clears stale QP awards before reprocessing corrected placements", async () => {
|
||||
const eventResults = [
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: null,
|
||||
qualifyingPointsAwarded: "1.00",
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
{
|
||||
id: "result-2",
|
||||
seasonParticipantId: "participant-2",
|
||||
placement: 15,
|
||||
qualifyingPointsAwarded: "0.00",
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
];
|
||||
const { db, setCalls } = makeProcessQPMockDb(eventResults);
|
||||
|
||||
await processQualifyingEvent("event-1", db);
|
||||
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "0" }),
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "1.00" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
|
||||
const { db, setCalls } = makeProcessQPMockDb([
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 20,
|
||||
qualifyingPointsAwarded: "0.00",
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
]);
|
||||
|
||||
await processQualifyingEvent("event-1", db);
|
||||
|
||||
expect(setCalls).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ majorsCompleted: expect.any(Number) })])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
|
||||
import {
|
||||
calculateFantasyPoints,
|
||||
calculateAveragedPoints,
|
||||
calculateSharedPlacementPoints,
|
||||
type ScoringRules,
|
||||
} from "../scoring-rules";
|
||||
|
||||
const DEFAULT_SCORING: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
|
|
@ -98,6 +103,16 @@ describe("Scoring Calculator", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("calculateSharedPlacementPoints", () => {
|
||||
it("splits two participants tied for 2nd across 2nd and 3rd", () => {
|
||||
expect(calculateSharedPlacementPoints(2, 2, DEFAULT_SCORING)).toBe(60);
|
||||
});
|
||||
|
||||
it("treats positions beyond 8th as zero at the scoring cutoff", () => {
|
||||
expect(calculateSharedPlacementPoints(8, 2, DEFAULT_SCORING)).toBe(7.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playoff Placement Scenarios", () => {
|
||||
it("should handle 8-team single elimination bracket", () => {
|
||||
// Champion: 1st (100 pts)
|
||||
|
|
|
|||
66
app/models/__tests__/scoring-event-delete.test.ts
Normal file
66
app/models/__tests__/scoring-event-delete.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../scoring-calculator", () => ({
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../qualifying-points", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
recalculateParticipantQP: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { deleteScoringEvent } from "../scoring-event";
|
||||
|
||||
describe("deleteScoringEvent", () => {
|
||||
it("decrements majorsCompleted for a processed zero-QP qualifying event", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
||||
const updateChain = {
|
||||
set: vi.fn((values: unknown) => {
|
||||
setCalls.push(values);
|
||||
return { where: vi.fn().mockResolvedValue(undefined) };
|
||||
}),
|
||||
};
|
||||
const db = {
|
||||
query: {
|
||||
scoringEvents: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "event-1",
|
||||
sportsSeasonId: "sports-season-1",
|
||||
eventType: "major_tournament",
|
||||
isQualifyingEvent: true,
|
||||
}),
|
||||
},
|
||||
eventResults: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 20,
|
||||
qualifyingPointsAwarded: "0.00",
|
||||
},
|
||||
]),
|
||||
},
|
||||
sportsSeasons: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "sports-season-1",
|
||||
majorsCompleted: 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
transaction: vi.fn(async (callback) => callback(db)),
|
||||
delete: vi.fn(() => deleteChain),
|
||||
update: vi.fn(() => updateChain),
|
||||
} as any;
|
||||
|
||||
await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ majorsCompleted: 0 }),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -59,7 +59,17 @@ function makePick(
|
|||
}
|
||||
|
||||
/** Build a mock DB that returns the given picks and no bracketTemplateId */
|
||||
function makeDb(picks: ReturnType<typeof makePick>[]) {
|
||||
function makeDb(
|
||||
picks: ReturnType<typeof makePick>[],
|
||||
seasonResults = picks.flatMap((pick) =>
|
||||
pick.participant.results
|
||||
.filter((result) => result.finalPosition !== null)
|
||||
.map((result) => ({
|
||||
sportsSeasonId: pick.participant.sportsSeasonId,
|
||||
finalPosition: result.finalPosition,
|
||||
}))
|
||||
)
|
||||
) {
|
||||
return {
|
||||
query: {
|
||||
seasons: {
|
||||
|
|
@ -71,6 +81,9 @@ function makeDb(picks: ReturnType<typeof makePick>[]) {
|
|||
scoringEvents: {
|
||||
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
|
||||
},
|
||||
seasonParticipantResults: {
|
||||
findMany: vi.fn().mockResolvedValue(seasonResults),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
|
@ -129,6 +142,23 @@ describe("calculateTeamProjectedScore", () => {
|
|||
|
||||
expect(result.actualPoints).toBe(50); // raw pointsFor3rd
|
||||
});
|
||||
|
||||
it("splits finalized QP tie points by shared fantasy slots", async () => {
|
||||
const db = makeDb(
|
||||
[
|
||||
makePick("p1", "ss1", "qualifying_points", { finalPosition: 2, isPartialScore: false }),
|
||||
],
|
||||
[
|
||||
{ sportsSeasonId: "ss1", finalPosition: 2 },
|
||||
{ sportsSeasonId: "ss1", finalPosition: 2 },
|
||||
]
|
||||
);
|
||||
|
||||
const result = await calculateTeamProjectedScore("team1", "season1", db);
|
||||
|
||||
expect(result.actualPoints).toBe(60); // (70 + 50) / 2
|
||||
expect(result.projectedPoints).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partial-score bracket participant (still alive)", () => {
|
||||
|
|
@ -300,4 +330,22 @@ describe("calculateTeamScore", () => {
|
|||
expect(result.placementCounts[1]).toBe(1);
|
||||
expect(result.placementCounts[5]).toBe(1); // partial position counts toward tiebreaker
|
||||
});
|
||||
|
||||
it("splits finalized QP tie points across the 8th-place cutoff", async () => {
|
||||
const db = makeDb(
|
||||
[
|
||||
makePick("p1", "ss1", "qualifying_points", { finalPosition: 8, isPartialScore: false }),
|
||||
],
|
||||
[
|
||||
{ sportsSeasonId: "ss1", finalPosition: 8 },
|
||||
{ sportsSeasonId: "ss1", finalPosition: 8 },
|
||||
]
|
||||
);
|
||||
|
||||
const result = await calculateTeamScore("team1", "season1", db);
|
||||
|
||||
expect(result.totalPoints).toBe(7.5); // (15 + 0) / 2
|
||||
expect(result.participantsCompleted).toBe(1);
|
||||
expect(result.placementCounts[8]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, inArray, asc } from "drizzle-orm";
|
||||
import { getScoringRules, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules";
|
||||
import {
|
||||
getScoringRules,
|
||||
calculateFantasyPoints,
|
||||
calculateBracketPoints,
|
||||
calculateSharedPlacementPoints,
|
||||
} from "./scoring-rules";
|
||||
|
||||
export async function createDraftPick(data: {
|
||||
seasonId: string;
|
||||
|
|
@ -119,7 +124,7 @@ export interface DraftedParticipantWithPoints {
|
|||
*
|
||||
* - playoff_bracket / season_standings: earnedPoints = finalPosition → scoring rules
|
||||
* - qualifying_points (active): currentQP = accumulated QP from participantQualifyingTotals
|
||||
* - qualifying_points (finalized): earnedPoints = finalPosition → scoring rules (participantResults written)
|
||||
* - qualifying_points (finalized): earnedPoints = shared finalPosition slots → scoring rules
|
||||
* - No EV / projected points — actual earned only.
|
||||
*/
|
||||
export async function getDraftedParticipantsWithPoints(
|
||||
|
|
@ -154,6 +159,7 @@ export async function getDraftedParticipantsWithPoints(
|
|||
const bracketSeasonIds = new Set<string>();
|
||||
const qpSeasonIds = new Set<string>();
|
||||
const qpParticipantIds = new Set<string>();
|
||||
const finalizedQPSeasonIds = new Set<string>();
|
||||
|
||||
for (const pick of picks) {
|
||||
const pattern = pick.participant.sportsSeason.scoringPattern;
|
||||
|
|
@ -162,6 +168,9 @@ export async function getDraftedParticipantsWithPoints(
|
|||
if (pattern === "qualifying_points") {
|
||||
qpSeasonIds.add(ssId);
|
||||
qpParticipantIds.add(pick.participant.id);
|
||||
if (pick.participant.results[0]?.finalPosition !== null && pick.participant.results[0]?.finalPosition !== undefined) {
|
||||
finalizedQPSeasonIds.add(ssId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,6 +203,20 @@ export async function getDraftedParticipantsWithPoints(
|
|||
}
|
||||
}
|
||||
|
||||
const qpSharedPlacementCounts = new Map<string, Map<number, number>>();
|
||||
if (finalizedQPSeasonIds.size > 0) {
|
||||
const results = await db.query.seasonParticipantResults.findMany({
|
||||
where: inArray(schema.seasonParticipantResults.sportsSeasonId, [...finalizedQPSeasonIds]),
|
||||
columns: { sportsSeasonId: true, finalPosition: true },
|
||||
});
|
||||
for (const row of results) {
|
||||
if (row.finalPosition === null || row.finalPosition <= 0) continue;
|
||||
const counts = qpSharedPlacementCounts.get(row.sportsSeasonId) ?? new Map<number, number>();
|
||||
qpSharedPlacementCounts.set(row.sportsSeasonId, counts);
|
||||
counts.set(row.finalPosition, (counts.get(row.finalPosition) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble result grouped by sportsSeasonId
|
||||
const result = new Map<string, DraftedParticipantWithPoints[]>();
|
||||
|
||||
|
|
@ -210,13 +233,23 @@ export async function getDraftedParticipantsWithPoints(
|
|||
currentQP = qpMap.get(id) ?? null;
|
||||
} else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) {
|
||||
// Finalized result for any pattern (including finalized QP seasons)
|
||||
earnedPoints = pattern === "playoff_bracket"
|
||||
? calculateBracketPoints(
|
||||
resultRow.finalPosition,
|
||||
scoringRules,
|
||||
bracketTemplateMap.get(sportsSeasonId) ?? null
|
||||
)
|
||||
: calculateFantasyPoints(resultRow.finalPosition, scoringRules);
|
||||
if (pattern === "playoff_bracket") {
|
||||
earnedPoints = calculateBracketPoints(
|
||||
resultRow.finalPosition,
|
||||
scoringRules,
|
||||
bracketTemplateMap.get(sportsSeasonId) ?? null
|
||||
);
|
||||
} else if (pattern === "qualifying_points") {
|
||||
const tiedParticipants =
|
||||
qpSharedPlacementCounts.get(sportsSeasonId)?.get(resultRow.finalPosition) ?? 1;
|
||||
earnedPoints = calculateSharedPlacementPoints(
|
||||
resultRow.finalPosition,
|
||||
tiedParticipants,
|
||||
scoringRules
|
||||
);
|
||||
} else {
|
||||
earnedPoints = calculateFantasyPoints(resultRow.finalPosition, scoringRules);
|
||||
}
|
||||
}
|
||||
|
||||
const arr = result.get(sportsSeasonId) ?? [];
|
||||
|
|
|
|||
|
|
@ -29,6 +29,40 @@ export interface QualifyingPointConfigData {
|
|||
points: number;
|
||||
}
|
||||
|
||||
export function roundQualifyingPoints(points: number): number {
|
||||
return Math.round((points + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
export function calculateSplitQualifyingPoints(
|
||||
placement: number,
|
||||
tieCount: number,
|
||||
pointsByPlacement: Map<number, number>
|
||||
): number {
|
||||
if (placement <= 0 || tieCount <= 0) return 0;
|
||||
|
||||
let totalQP = 0;
|
||||
for (let pos = placement; pos < placement + tieCount; pos++) {
|
||||
totalQP += pointsByPlacement.get(pos) ?? 0;
|
||||
}
|
||||
|
||||
return roundQualifyingPoints(totalQP / tieCount);
|
||||
}
|
||||
|
||||
export function hasProcessedQualifyingPlacement(
|
||||
results: Array<{
|
||||
placement?: number | null;
|
||||
qualifyingPointsAwarded?: string | null;
|
||||
}>
|
||||
): boolean {
|
||||
return results.some(
|
||||
(result) =>
|
||||
result.placement !== null &&
|
||||
result.placement !== undefined &&
|
||||
result.qualifyingPointsAwarded !== null &&
|
||||
result.qualifyingPointsAwarded !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default qualifying point configuration for a sports season
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { getScoringRules, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules";
|
||||
import {
|
||||
getScoringRules,
|
||||
calculateFantasyPoints,
|
||||
calculateBracketPoints,
|
||||
calculateSharedPlacementPoints,
|
||||
} from "./scoring-rules";
|
||||
import { getSeasonResults } from "./participant-season-result";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
||||
|
|
@ -13,7 +18,14 @@ import { createDailySnapshot } from "~/models/standings";
|
|||
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getEventResults } from "./event-result";
|
||||
import { getQPForPlacement, recalculateParticipantQP, getQPStandings, updateFinalRankings } from "./qualifying-points";
|
||||
import {
|
||||
calculateSplitQualifyingPoints,
|
||||
getQPConfig,
|
||||
hasProcessedQualifyingPlacement,
|
||||
recalculateParticipantQP,
|
||||
getQPStandings,
|
||||
updateFinalRankings,
|
||||
} from "./qualifying-points";
|
||||
import { getParticipantEV } from "./participant-expected-value";
|
||||
import { calculateEV } from "~/services/ev-calculator";
|
||||
|
||||
|
|
@ -540,6 +552,29 @@ async function upsertParticipantResult(
|
|||
}
|
||||
}
|
||||
|
||||
async function getSharedPlacementCount(
|
||||
sportsSeasonId: string,
|
||||
finalPosition: number,
|
||||
db: ReturnType<typeof database>,
|
||||
cache: Map<string, Map<number, number>>
|
||||
): Promise<number> {
|
||||
if (!cache.has(sportsSeasonId)) {
|
||||
const results = await db.query.seasonParticipantResults.findMany({
|
||||
where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
|
||||
columns: { finalPosition: true },
|
||||
});
|
||||
const counts = new Map<number, number>();
|
||||
for (const result of results) {
|
||||
if (result.finalPosition !== null && result.finalPosition > 0) {
|
||||
counts.set(result.finalPosition, (counts.get(result.finalPosition) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
cache.set(sportsSeasonId, counts);
|
||||
}
|
||||
|
||||
return cache.get(sportsSeasonId)?.get(finalPosition) ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the guaranteed minimum final position for winners of a given round.
|
||||
*
|
||||
|
|
@ -611,7 +646,23 @@ export async function processQualifyingEvent(
|
|||
const results = await getEventResults(eventId, db);
|
||||
|
||||
// Check if this was already processed (for majorsCompleted counter)
|
||||
const wasAlreadyProcessed = results.some(r => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0);
|
||||
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
|
||||
|
||||
// Clear stale QP before reprocessing so removed/null placements do not keep old awards.
|
||||
for (const result of results) {
|
||||
await db
|
||||
.update(schema.eventResults)
|
||||
.set({
|
||||
qualifyingPointsAwarded: "0",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.eventResults.id, result.id));
|
||||
}
|
||||
|
||||
const qpConfig = await getQPConfig(event.sportsSeasonId, db);
|
||||
const pointsByPlacement = new Map(
|
||||
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
|
||||
);
|
||||
|
||||
// Group results by placement to handle ties
|
||||
const placementGroups = new Map<number, typeof results>();
|
||||
|
|
@ -627,23 +678,18 @@ export async function processQualifyingEvent(
|
|||
for (const [placement, group] of placementGroups) {
|
||||
const tieCount = group.length;
|
||||
|
||||
// Calculate total QP for the positions this group occupies
|
||||
// A tie for Nth place occupies positions N through N + tieCount - 1
|
||||
let totalQP = 0;
|
||||
for (let pos = placement; pos < placement + tieCount; pos++) {
|
||||
const qpForPos = await getQPForPlacement(event.sportsSeasonId, pos, db);
|
||||
totalQP += qpForPos;
|
||||
}
|
||||
|
||||
// Divide equally among tied participants
|
||||
const qpPerParticipant = totalQP / tieCount;
|
||||
const qpPerParticipant = calculateSplitQualifyingPoints(
|
||||
placement,
|
||||
tieCount,
|
||||
pointsByPlacement
|
||||
);
|
||||
|
||||
// Update the event result with the QP awarded
|
||||
for (const result of group) {
|
||||
await db
|
||||
.update(schema.eventResults)
|
||||
.set({
|
||||
qualifyingPointsAwarded: qpPerParticipant.toString(),
|
||||
qualifyingPointsAwarded: qpPerParticipant.toFixed(2),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.eventResults.id, result.id));
|
||||
|
|
@ -714,33 +760,14 @@ export async function finalizeQualifyingPoints(
|
|||
let currentPlacement = 1;
|
||||
|
||||
for (const [_, group] of sortedGroups) {
|
||||
// Stop if we've gone beyond the top 8 fantasy placements
|
||||
if (currentPlacement > 8) break;
|
||||
|
||||
const participantsInGroup = group.length;
|
||||
|
||||
// Calculate how many placements this group shares
|
||||
const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1);
|
||||
const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1;
|
||||
|
||||
// Assign placements to participants in this group
|
||||
for (let i = 0; i < Math.min(participantsInGroup, actualParticipantsScoring); i++) {
|
||||
const standing = group[i];
|
||||
for (const standing of group) {
|
||||
await upsertParticipantResult(
|
||||
standing.participantId,
|
||||
sportsSeasonId,
|
||||
currentPlacement,
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
// If there are more participants than scoring positions, assign 0 to the rest
|
||||
for (let i = actualParticipantsScoring; i < participantsInGroup; i++) {
|
||||
const standing = group[i];
|
||||
await upsertParticipantResult(
|
||||
standing.participantId,
|
||||
sportsSeasonId,
|
||||
0, // Beyond top 8
|
||||
currentPlacement <= 8 ? currentPlacement : 0,
|
||||
db
|
||||
);
|
||||
}
|
||||
|
|
@ -959,6 +986,7 @@ export async function calculateTeamScore(
|
|||
// Cache bracket template IDs per sports season (one extra query per unique bracket season).
|
||||
// Needed to determine the correct scoring tier structure (e.g. AFL splits 5–8 into two pairs).
|
||||
const bracketTemplateCache = new Map<string, string | null>();
|
||||
const sharedPlacementCountCache = new Map<string, Map<number, number>>();
|
||||
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
|
||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||
|
|
@ -979,10 +1007,23 @@ export async function calculateTeamScore(
|
|||
|
||||
if (result && result.finalPosition !== null && result.finalPosition > 0) {
|
||||
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
|
||||
const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points";
|
||||
let points: number;
|
||||
if (isBracket) {
|
||||
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
|
||||
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
|
||||
} else if (isQualifyingPoints) {
|
||||
const tiedParticipants = await getSharedPlacementCount(
|
||||
pick.participant.sportsSeasonId,
|
||||
result.finalPosition,
|
||||
db,
|
||||
sharedPlacementCountCache
|
||||
);
|
||||
points = calculateSharedPlacementPoints(
|
||||
result.finalPosition,
|
||||
tiedParticipants,
|
||||
scoringRules
|
||||
);
|
||||
} else {
|
||||
points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
||||
}
|
||||
|
|
@ -1057,6 +1098,7 @@ export async function calculateTeamProjectedScore(
|
|||
|
||||
// Cache bracket template IDs per sports season
|
||||
const bracketTemplateCache = new Map<string, string | null>();
|
||||
const sharedPlacementCountCache = new Map<string, Map<number, number>>();
|
||||
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
|
||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||
|
|
@ -1074,6 +1116,7 @@ export async function calculateTeamProjectedScore(
|
|||
for (const pick of picks) {
|
||||
const result = pick.participant.results[0];
|
||||
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
|
||||
const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points";
|
||||
|
||||
if (result && result.finalPosition !== null && !result.isPartialScore) {
|
||||
// Participant is fully finalized — use bracket-averaged points
|
||||
|
|
@ -1081,6 +1124,18 @@ export async function calculateTeamProjectedScore(
|
|||
if (isBracket) {
|
||||
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
|
||||
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
|
||||
} else if (isQualifyingPoints) {
|
||||
const tiedParticipants = await getSharedPlacementCount(
|
||||
pick.participant.sportsSeasonId,
|
||||
result.finalPosition,
|
||||
db,
|
||||
sharedPlacementCountCache
|
||||
);
|
||||
points = calculateSharedPlacementPoints(
|
||||
result.finalPosition,
|
||||
tiedParticipants,
|
||||
scoringRules
|
||||
);
|
||||
} else {
|
||||
points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
||||
}
|
||||
|
|
@ -1090,9 +1145,24 @@ export async function calculateTeamProjectedScore(
|
|||
// Still alive with a provisional floor — count floor as actual, EV for projection.
|
||||
// Note: NOT incremented in participantsFinished; these participants are still competing.
|
||||
const templateId = isBracket ? await getBracketTemplate(pick.participant.sportsSeasonId) : null;
|
||||
const floorPoints = isBracket
|
||||
? calculateBracketPoints(result.finalPosition, scoringRules, templateId)
|
||||
: calculateFantasyPoints(result.finalPosition, scoringRules);
|
||||
let floorPoints: number;
|
||||
if (isBracket) {
|
||||
floorPoints = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
|
||||
} else if (isQualifyingPoints) {
|
||||
const tiedParticipants = await getSharedPlacementCount(
|
||||
pick.participant.sportsSeasonId,
|
||||
result.finalPosition,
|
||||
db,
|
||||
sharedPlacementCountCache
|
||||
);
|
||||
floorPoints = calculateSharedPlacementPoints(
|
||||
result.finalPosition,
|
||||
tiedParticipants,
|
||||
scoringRules
|
||||
);
|
||||
} else {
|
||||
floorPoints = calculateFantasyPoints(result.finalPosition, scoringRules);
|
||||
}
|
||||
actualPoints += floorPoints;
|
||||
// EV already accounts for their full projected value, so subtract floor to avoid
|
||||
// double-counting when we do actualPoints + evSum below
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as schema from "~/database/schema";
|
|||
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import type { BracketRegion } from "~/lib/bracket-templates";
|
||||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||
import { recalculateParticipantQP } from "./qualifying-points";
|
||||
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
||||
|
||||
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
||||
|
||||
|
|
@ -228,9 +228,7 @@ export async function deleteScoringEvent(
|
|||
where: eq(schema.eventResults.scoringEventId, eventId),
|
||||
});
|
||||
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
||||
wasQPProcessed = results.some(
|
||||
(r) => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0
|
||||
);
|
||||
wasQPProcessed = hasProcessedQualifyingPlacement(results);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
|
|
|
|||
|
|
@ -104,6 +104,27 @@ export function calculateAveragedPoints(
|
|||
return total / placements.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate points for participants sharing a standings placement.
|
||||
*
|
||||
* Example: two participants tied for 2nd share 2nd and 3rd place points.
|
||||
* If a tie extends beyond the scoring range, the extra slots contribute 0.
|
||||
*/
|
||||
export function calculateSharedPlacementPoints(
|
||||
startPlacement: number,
|
||||
tiedParticipants: number,
|
||||
rules: ScoringRules
|
||||
): number {
|
||||
if (startPlacement <= 0 || tiedParticipants <= 0) return 0;
|
||||
|
||||
const placements = Array.from(
|
||||
{ length: tiedParticipants },
|
||||
(_, index) => startPlacement + index
|
||||
);
|
||||
|
||||
return calculateAveragedPoints(placements, rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier definitions for brackets where positions 5–8 split into two separate pairs.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -418,7 +418,7 @@ describe("DartsSimulator.simulate() — Path B (pre-bracket)", () => {
|
|||
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
expect(colSum).toBeCloseTo(1.0, 2);
|
||||
}
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("world #1 (highest Elo) has the highest probFirst", async () => {
|
||||
const sim = new DartsSimulator();
|
||||
|
|
@ -427,7 +427,7 @@ describe("DartsSimulator.simulate() — Path B (pre-bracket)", () => {
|
|||
const sorted = [...results].toSorted((a, b) => b.probabilities.probFirst - a.probabilities.probFirst);
|
||||
// player-1 is world #1 with the highest Elo — should have highest win probability
|
||||
expect(sorted[0].participantId).toBe("player-1");
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("throws if fewer than 2 participants exist", async () => {
|
||||
const { database } = await import("~/database/context");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue