Fix QP tie rounding and finalized scoring
This commit is contained in:
parent
4bbcac1949
commit
3c63d6ff6c
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 {
|
function formatQP(raw: string): string {
|
||||||
const n = parseFloat(raw);
|
const n = parseFloat(raw);
|
||||||
if (isNaN(n)) return "—";
|
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({
|
export function QualifyingPointsStandings({
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ interface MakeDbOpts {
|
||||||
picks?: PickRow[];
|
picks?: PickRow[];
|
||||||
scoringEvents?: { sportsSeasonId: string; bracketTemplateId: string | null }[];
|
scoringEvents?: { sportsSeasonId: string; bracketTemplateId: string | null }[];
|
||||||
qualifyingTotals?: { participantId: string; totalQualifyingPoints: string }[];
|
qualifyingTotals?: { participantId: string; totalQualifyingPoints: string }[];
|
||||||
|
seasonParticipantResults?: { sportsSeasonId: string; finalPosition: number | null }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDb(opts: MakeDbOpts = {}) {
|
function makeDb(opts: MakeDbOpts = {}) {
|
||||||
|
|
@ -32,6 +33,7 @@ function makeDb(opts: MakeDbOpts = {}) {
|
||||||
picks = [],
|
picks = [],
|
||||||
scoringEvents = [],
|
scoringEvents = [],
|
||||||
qualifyingTotals = [],
|
qualifyingTotals = [],
|
||||||
|
seasonParticipantResults = [],
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -48,6 +50,9 @@ function makeDb(opts: MakeDbOpts = {}) {
|
||||||
seasonParticipantQualifyingTotals: {
|
seasonParticipantQualifyingTotals: {
|
||||||
findMany: vi.fn().mockResolvedValue(qualifyingTotals),
|
findMany: vi.fn().mockResolvedValue(qualifyingTotals),
|
||||||
},
|
},
|
||||||
|
seasonParticipantResults: {
|
||||||
|
findMany: vi.fn().mockResolvedValue(seasonParticipantResults),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +190,24 @@ describe("getDraftedParticipantsWithPoints", () => {
|
||||||
currentQP: null,
|
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", () => {
|
describe("season_standings pattern", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { describe, it, expect } from "vitest";
|
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("Qualifying Points Configuration", () => {
|
||||||
describe("DEFAULT_QP_VALUES", () => {
|
describe("DEFAULT_QP_VALUES", () => {
|
||||||
|
|
@ -163,6 +167,14 @@ describe("Qualifying Points Configuration", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Tie Handling During QP Awards", () => {
|
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", () => {
|
it("should correctly split QP among tied participants", () => {
|
||||||
// Scenario: 7 players tied for 14th place
|
// Scenario: 7 players tied for 14th place
|
||||||
// They occupy positions 14-20
|
// They occupy positions 14-20
|
||||||
|
|
@ -192,6 +204,7 @@ describe("Qualifying Points Configuration", () => {
|
||||||
const qpPerPlayer = totalQP / tieCount;
|
const qpPerPlayer = totalQP / tieCount;
|
||||||
|
|
||||||
expect(totalQP).toBe(3);
|
expect(totalQP).toBe(3);
|
||||||
|
expect(calculateSplitQualifyingPoints(14, 7, pointsByPlacement)).toBe(0.43);
|
||||||
expect(qpPerPlayer).toBeCloseTo(0.428571, 5);
|
expect(qpPerPlayer).toBeCloseTo(0.428571, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -232,6 +245,7 @@ describe("Qualifying Points Configuration", () => {
|
||||||
const qpPerPlayer = totalQP / tieCount;
|
const qpPerPlayer = totalQP / tieCount;
|
||||||
|
|
||||||
expect(totalQP).toBe(44);
|
expect(totalQP).toBe(44);
|
||||||
|
expect(calculateSplitQualifyingPoints(1, 3, pointsByPlacement)).toBe(14.67);
|
||||||
expect(qpPerPlayer).toBeCloseTo(14.666667, 5);
|
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", () => {
|
describe("Scoring Workflow", () => {
|
||||||
it("should process 4 majors and calculate final standings", () => {
|
it("should process 4 majors and calculate final standings", () => {
|
||||||
// Simulating a full season with 4 majors
|
// Simulating a full season with 4 majors
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { calculateFantasyPoints, type ScoringRules } from "../scoring-rules";
|
import { calculateFantasyPoints, type ScoringRules } from "../scoring-rules";
|
||||||
|
import { processQualifyingEvent } from "../scoring-calculator";
|
||||||
|
import { DEFAULT_QP_VALUES } from "../qualifying-points";
|
||||||
|
|
||||||
const DEFAULT_SCORING: ScoringRules = {
|
const DEFAULT_SCORING: ScoringRules = {
|
||||||
pointsFor1st: 100,
|
pointsFor1st: 100,
|
||||||
|
|
@ -196,4 +198,97 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
||||||
expect(points).toBe(0);
|
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 { 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 = {
|
const DEFAULT_SCORING: ScoringRules = {
|
||||||
pointsFor1st: 100,
|
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", () => {
|
describe("Playoff Placement Scenarios", () => {
|
||||||
it("should handle 8-team single elimination bracket", () => {
|
it("should handle 8-team single elimination bracket", () => {
|
||||||
// Champion: 1st (100 pts)
|
// 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 */
|
/** 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 {
|
return {
|
||||||
query: {
|
query: {
|
||||||
seasons: {
|
seasons: {
|
||||||
|
|
@ -71,6 +81,9 @@ function makeDb(picks: ReturnType<typeof makePick>[]) {
|
||||||
scoringEvents: {
|
scoringEvents: {
|
||||||
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
|
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
|
||||||
},
|
},
|
||||||
|
seasonParticipantResults: {
|
||||||
|
findMany: vi.fn().mockResolvedValue(seasonResults),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +142,23 @@ describe("calculateTeamProjectedScore", () => {
|
||||||
|
|
||||||
expect(result.actualPoints).toBe(50); // raw pointsFor3rd
|
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)", () => {
|
describe("partial-score bracket participant (still alive)", () => {
|
||||||
|
|
@ -300,4 +330,22 @@ describe("calculateTeamScore", () => {
|
||||||
expect(result.placementCounts[1]).toBe(1);
|
expect(result.placementCounts[1]).toBe(1);
|
||||||
expect(result.placementCounts[5]).toBe(1); // partial position counts toward tiebreaker
|
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 { 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, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules";
|
import {
|
||||||
|
getScoringRules,
|
||||||
|
calculateFantasyPoints,
|
||||||
|
calculateBracketPoints,
|
||||||
|
calculateSharedPlacementPoints,
|
||||||
|
} from "./scoring-rules";
|
||||||
|
|
||||||
export async function createDraftPick(data: {
|
export async function createDraftPick(data: {
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
|
|
@ -119,7 +124,7 @@ export interface DraftedParticipantWithPoints {
|
||||||
*
|
*
|
||||||
* - playoff_bracket / season_standings: earnedPoints = finalPosition → scoring rules
|
* - playoff_bracket / season_standings: earnedPoints = finalPosition → scoring rules
|
||||||
* - qualifying_points (active): currentQP = accumulated QP from participantQualifyingTotals
|
* - 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.
|
* - No EV / projected points — actual earned only.
|
||||||
*/
|
*/
|
||||||
export async function getDraftedParticipantsWithPoints(
|
export async function getDraftedParticipantsWithPoints(
|
||||||
|
|
@ -154,6 +159,7 @@ export async function getDraftedParticipantsWithPoints(
|
||||||
const bracketSeasonIds = new Set<string>();
|
const bracketSeasonIds = new Set<string>();
|
||||||
const qpSeasonIds = new Set<string>();
|
const qpSeasonIds = new Set<string>();
|
||||||
const qpParticipantIds = new Set<string>();
|
const qpParticipantIds = new Set<string>();
|
||||||
|
const finalizedQPSeasonIds = new Set<string>();
|
||||||
|
|
||||||
for (const pick of picks) {
|
for (const pick of picks) {
|
||||||
const pattern = pick.participant.sportsSeason.scoringPattern;
|
const pattern = pick.participant.sportsSeason.scoringPattern;
|
||||||
|
|
@ -162,6 +168,9 @@ export async function getDraftedParticipantsWithPoints(
|
||||||
if (pattern === "qualifying_points") {
|
if (pattern === "qualifying_points") {
|
||||||
qpSeasonIds.add(ssId);
|
qpSeasonIds.add(ssId);
|
||||||
qpParticipantIds.add(pick.participant.id);
|
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
|
// Assemble result grouped by sportsSeasonId
|
||||||
const result = new Map<string, DraftedParticipantWithPoints[]>();
|
const result = new Map<string, DraftedParticipantWithPoints[]>();
|
||||||
|
|
||||||
|
|
@ -210,13 +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 = pattern === "playoff_bracket"
|
if (pattern === "playoff_bracket") {
|
||||||
? calculateBracketPoints(
|
earnedPoints = calculateBracketPoints(
|
||||||
resultRow.finalPosition,
|
resultRow.finalPosition,
|
||||||
scoringRules,
|
scoringRules,
|
||||||
bracketTemplateMap.get(sportsSeasonId) ?? null
|
bracketTemplateMap.get(sportsSeasonId) ?? null
|
||||||
)
|
);
|
||||||
: calculateFantasyPoints(resultRow.finalPosition, scoringRules);
|
} 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) ?? [];
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,40 @@ export interface QualifyingPointConfigData {
|
||||||
points: number;
|
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
|
* Initialize default qualifying point configuration for a sports season
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,7 +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, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules";
|
import {
|
||||||
|
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 } from "~/services/discord";
|
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
||||||
|
|
@ -13,7 +18,14 @@ import { createDailySnapshot } from "~/models/standings";
|
||||||
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
import { recordMatchScoreEvents } 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 { getQPForPlacement, recalculateParticipantQP, getQPStandings, updateFinalRankings } from "./qualifying-points";
|
import {
|
||||||
|
calculateSplitQualifyingPoints,
|
||||||
|
getQPConfig,
|
||||||
|
hasProcessedQualifyingPlacement,
|
||||||
|
recalculateParticipantQP,
|
||||||
|
getQPStandings,
|
||||||
|
updateFinalRankings,
|
||||||
|
} from "./qualifying-points";
|
||||||
import { getParticipantEV } from "./participant-expected-value";
|
import { getParticipantEV } from "./participant-expected-value";
|
||||||
import { calculateEV } from "~/services/ev-calculator";
|
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.
|
* 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);
|
const results = await getEventResults(eventId, db);
|
||||||
|
|
||||||
// Check if this was already processed (for majorsCompleted counter)
|
// 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
|
// Group results by placement to handle ties
|
||||||
const placementGroups = new Map<number, typeof results>();
|
const placementGroups = new Map<number, typeof results>();
|
||||||
|
|
@ -627,23 +678,18 @@ export async function processQualifyingEvent(
|
||||||
for (const [placement, group] of placementGroups) {
|
for (const [placement, group] of placementGroups) {
|
||||||
const tieCount = group.length;
|
const tieCount = group.length;
|
||||||
|
|
||||||
// Calculate total QP for the positions this group occupies
|
const qpPerParticipant = calculateSplitQualifyingPoints(
|
||||||
// A tie for Nth place occupies positions N through N + tieCount - 1
|
placement,
|
||||||
let totalQP = 0;
|
tieCount,
|
||||||
for (let pos = placement; pos < placement + tieCount; pos++) {
|
pointsByPlacement
|
||||||
const qpForPos = await getQPForPlacement(event.sportsSeasonId, pos, db);
|
);
|
||||||
totalQP += qpForPos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Divide equally among tied participants
|
|
||||||
const qpPerParticipant = totalQP / tieCount;
|
|
||||||
|
|
||||||
// Update the event result with the QP awarded
|
// Update the event result with the QP awarded
|
||||||
for (const result of group) {
|
for (const result of group) {
|
||||||
await db
|
await db
|
||||||
.update(schema.eventResults)
|
.update(schema.eventResults)
|
||||||
.set({
|
.set({
|
||||||
qualifyingPointsAwarded: qpPerParticipant.toString(),
|
qualifyingPointsAwarded: qpPerParticipant.toFixed(2),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(schema.eventResults.id, result.id));
|
.where(eq(schema.eventResults.id, result.id));
|
||||||
|
|
@ -714,33 +760,14 @@ export async function finalizeQualifyingPoints(
|
||||||
let currentPlacement = 1;
|
let currentPlacement = 1;
|
||||||
|
|
||||||
for (const [_, group] of sortedGroups) {
|
for (const [_, group] of sortedGroups) {
|
||||||
// Stop if we've gone beyond the top 8 fantasy placements
|
|
||||||
if (currentPlacement > 8) break;
|
|
||||||
|
|
||||||
const participantsInGroup = group.length;
|
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
|
// Assign placements to participants in this group
|
||||||
for (let i = 0; i < Math.min(participantsInGroup, actualParticipantsScoring); i++) {
|
for (const standing of group) {
|
||||||
const standing = group[i];
|
|
||||||
await upsertParticipantResult(
|
await upsertParticipantResult(
|
||||||
standing.participantId,
|
standing.participantId,
|
||||||
sportsSeasonId,
|
sportsSeasonId,
|
||||||
currentPlacement,
|
currentPlacement <= 8 ? currentPlacement : 0,
|
||||||
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
|
|
||||||
db
|
db
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -959,6 +986,7 @@ export async function calculateTeamScore(
|
||||||
// Cache bracket template IDs per sports season (one extra query per unique bracket season).
|
// 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).
|
// 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 bracketTemplateCache = new Map<string, string | null>();
|
||||||
|
const sharedPlacementCountCache = new Map<string, Map<number, number>>();
|
||||||
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
|
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||||
|
|
@ -979,10 +1007,23 @@ export async function calculateTeamScore(
|
||||||
|
|
||||||
if (result && result.finalPosition !== null && result.finalPosition > 0) {
|
if (result && result.finalPosition !== null && result.finalPosition > 0) {
|
||||||
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
|
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
|
||||||
|
const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points";
|
||||||
let points: number;
|
let points: number;
|
||||||
if (isBracket) {
|
if (isBracket) {
|
||||||
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
|
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
|
||||||
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
|
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 {
|
} else {
|
||||||
points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
||||||
}
|
}
|
||||||
|
|
@ -1057,6 +1098,7 @@ export async function calculateTeamProjectedScore(
|
||||||
|
|
||||||
// Cache bracket template IDs per sports season
|
// Cache bracket template IDs per sports season
|
||||||
const bracketTemplateCache = new Map<string, string | null>();
|
const bracketTemplateCache = new Map<string, string | null>();
|
||||||
|
const sharedPlacementCountCache = new Map<string, Map<number, number>>();
|
||||||
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
|
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||||
|
|
@ -1074,6 +1116,7 @@ export async function calculateTeamProjectedScore(
|
||||||
for (const pick of picks) {
|
for (const pick of picks) {
|
||||||
const result = pick.participant.results[0];
|
const result = pick.participant.results[0];
|
||||||
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
|
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 — use bracket-averaged points
|
// Participant is fully finalized — use bracket-averaged points
|
||||||
|
|
@ -1081,6 +1124,18 @@ export async function calculateTeamProjectedScore(
|
||||||
if (isBracket) {
|
if (isBracket) {
|
||||||
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
|
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
|
||||||
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
|
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 {
|
} else {
|
||||||
points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
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.
|
// 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 templateId = isBracket ? await getBracketTemplate(pick.participant.sportsSeasonId) : null;
|
const templateId = isBracket ? await getBracketTemplate(pick.participant.sportsSeasonId) : null;
|
||||||
const floorPoints = isBracket
|
let floorPoints: number;
|
||||||
? calculateBracketPoints(result.finalPosition, scoringRules, templateId)
|
if (isBracket) {
|
||||||
: calculateFantasyPoints(result.finalPosition, scoringRules);
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
||||||
import type { BracketRegion } from "~/lib/bracket-templates";
|
import type { BracketRegion } from "~/lib/bracket-templates";
|
||||||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
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";
|
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),
|
where: eq(schema.eventResults.scoringEventId, eventId),
|
||||||
});
|
});
|
||||||
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
||||||
wasQPProcessed = results.some(
|
wasQPProcessed = hasProcessedQualifyingPlacement(results);
|
||||||
(r) => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,27 @@ export function calculateAveragedPoints(
|
||||||
return total / placements.length;
|
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.
|
* 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);
|
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||||
expect(colSum).toBeCloseTo(1.0, 2);
|
expect(colSum).toBeCloseTo(1.0, 2);
|
||||||
}
|
}
|
||||||
});
|
}, 15_000);
|
||||||
|
|
||||||
it("world #1 (highest Elo) has the highest probFirst", async () => {
|
it("world #1 (highest Elo) has the highest probFirst", async () => {
|
||||||
const sim = new DartsSimulator();
|
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);
|
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
|
// player-1 is world #1 with the highest Elo — should have highest win probability
|
||||||
expect(sorted[0].participantId).toBe("player-1");
|
expect(sorted[0].participantId).toBe("player-1");
|
||||||
});
|
}, 15_000);
|
||||||
|
|
||||||
it("throws if fewer than 2 participants exist", async () => {
|
it("throws if fewer than 2 participants exist", async () => {
|
||||||
const { database } = await import("~/database/context");
|
const { database } = await import("~/database/context");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue