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