Fix QP tie rounding and finalized scoring (#440)

This commit is contained in:
Chris Parsons 2026-05-17 21:58:53 -07:00 committed by GitHub
parent 4bbcac1949
commit 1f0fcf970b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 555 additions and 57 deletions

View 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();
});
});

View file

@ -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({

View file

@ -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", () => {

View file

@ -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

View file

@ -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) })])
);
});
});
});

View file

@ -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)

View 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 }),
])
);
});
});

View file

@ -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);
});
});

View file

@ -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) ?? [];

View file

@ -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
*/

View file

@ -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 58 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

View file

@ -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) => {

View file

@ -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 58 split into two separate pairs.
*

View file

@ -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");