diff --git a/app/components/__tests__/QualifyingPointsStandings.test.tsx b/app/components/__tests__/QualifyingPointsStandings.test.tsx
new file mode 100644
index 0000000..133ff8e
--- /dev/null
+++ b/app/components/__tests__/QualifyingPointsStandings.test.tsx
@@ -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(
+
+ );
+
+ expect(screen.getByText("14.67 QP")).toBeInTheDocument();
+ expect(screen.getByText("0.50 QP")).toBeInTheDocument();
+ expect(screen.getByText("0.43 QP")).toBeInTheDocument();
+ });
+});
diff --git a/app/components/scoring/QualifyingPointsStandings.tsx b/app/components/scoring/QualifyingPointsStandings.tsx
index 9a8a45e..7f00b9d 100644
--- a/app/components/scoring/QualifyingPointsStandings.tsx
+++ b/app/components/scoring/QualifyingPointsStandings.tsx
@@ -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({
diff --git a/app/models/__tests__/draft-pick.test.ts b/app/models/__tests__/draft-pick.test.ts
index 38930d7..781df83 100644
--- a/app/models/__tests__/draft-pick.test.ts
+++ b/app/models/__tests__/draft-pick.test.ts
@@ -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", () => {
diff --git a/app/models/__tests__/qualifying-points.test.ts b/app/models/__tests__/qualifying-points.test.ts
index e326a81..e49ae78 100644
--- a/app/models/__tests__/qualifying-points.test.ts
+++ b/app/models/__tests__/qualifying-points.test.ts
@@ -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
diff --git a/app/models/__tests__/scoring-calculator-qualifying.test.ts b/app/models/__tests__/scoring-calculator-qualifying.test.ts
index 0744e7f..f2ef65f 100644
--- a/app/models/__tests__/scoring-calculator-qualifying.test.ts
+++ b/app/models/__tests__/scoring-calculator-qualifying.test.ts
@@ -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>) {
+ 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) })])
+ );
+ });
+ });
});
diff --git a/app/models/__tests__/scoring-calculator.test.ts b/app/models/__tests__/scoring-calculator.test.ts
index 1178ed2..b9a307d 100644
--- a/app/models/__tests__/scoring-calculator.test.ts
+++ b/app/models/__tests__/scoring-calculator.test.ts
@@ -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)
diff --git a/app/models/__tests__/scoring-event-delete.test.ts b/app/models/__tests__/scoring-event-delete.test.ts
new file mode 100644
index 0000000..c73741c
--- /dev/null
+++ b/app/models/__tests__/scoring-event-delete.test.ts
@@ -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),
+ 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 }),
+ ])
+ );
+ });
+});
diff --git a/app/models/__tests__/team-projected-score.test.ts b/app/models/__tests__/team-projected-score.test.ts
index 0354fee..bbce292 100644
--- a/app/models/__tests__/team-projected-score.test.ts
+++ b/app/models/__tests__/team-projected-score.test.ts
@@ -59,7 +59,17 @@ function makePick(
}
/** Build a mock DB that returns the given picks and no bracketTemplateId */
-function makeDb(picks: ReturnType[]) {
+function makeDb(
+ picks: ReturnType[],
+ 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[]) {
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);
+ });
});
diff --git a/app/models/draft-pick.ts b/app/models/draft-pick.ts
index 2ad46a3..7289046 100644
--- a/app/models/draft-pick.ts
+++ b/app/models/draft-pick.ts
@@ -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();
const qpSeasonIds = new Set();
const qpParticipantIds = new Set();
+ const finalizedQPSeasonIds = new Set();
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>();
+ 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();
+ qpSharedPlacementCounts.set(row.sportsSeasonId, counts);
+ counts.set(row.finalPosition, (counts.get(row.finalPosition) ?? 0) + 1);
+ }
+ }
+
// Assemble result grouped by sportsSeasonId
const result = new Map();
@@ -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) ?? [];
diff --git a/app/models/qualifying-points.ts b/app/models/qualifying-points.ts
index f2f4dbc..d069ac9 100644
--- a/app/models/qualifying-points.ts
+++ b/app/models/qualifying-points.ts
@@ -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 {
+ 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
*/
diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts
index 4259c39..dfd5a5c 100644
--- a/app/models/scoring-calculator.ts
+++ b/app/models/scoring-calculator.ts
@@ -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,
+ cache: Map>
+): Promise {
+ if (!cache.has(sportsSeasonId)) {
+ const results = await db.query.seasonParticipantResults.findMany({
+ where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
+ columns: { finalPosition: true },
+ });
+ const counts = new Map();
+ 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();
@@ -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();
+ const sharedPlacementCountCache = new Map>();
async function getBracketTemplate(sportsSeasonId: string): Promise {
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();
+ const sharedPlacementCountCache = new Map>();
async function getBracketTemplate(sportsSeasonId: string): Promise {
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
diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts
index 7780979..67c4078 100644
--- a/app/models/scoring-event.ts
+++ b/app/models/scoring-event.ts
@@ -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) => {
diff --git a/app/models/scoring-rules.ts b/app/models/scoring-rules.ts
index adbe52e..9145332 100644
--- a/app/models/scoring-rules.ts
+++ b/app/models/scoring-rules.ts
@@ -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.
*
diff --git a/app/services/simulations/__tests__/darts-simulator.test.ts b/app/services/simulations/__tests__/darts-simulator.test.ts
index bff08b1..8a45803 100644
--- a/app/services/simulations/__tests__/darts-simulator.test.ts
+++ b/app/services/simulations/__tests__/darts-simulator.test.ts
@@ -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");