From f3d63922d040e21dada2a42f3400ecf282b3edf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:29:37 +0000 Subject: [PATCH] Unify tie-split point math across every screen A participant tied for a scoring placement splits the combined points of the tied positions. Four code paths computed a pick's points, each re-implementing the same bracket/qualifying_points/default cascade, and two of them omitted the qualifying_points arm entirely. A golfer tied for 8th was therefore worth the full 15 points on the team page and draft board but the split 7.5 in the standings, so a team read 225 on one screen and 218 on another. Collapse the cascade into a single calculatePickPoints helper and route all six call sites through it, backed by one shared getSharedPlacementCounts loader replacing the two separate tie-count queries. Tie counts span every participant in the sports season, not just drafted ones, since an undrafted tie partner still halves the award. Also round split awards to the nearest whole point in calculateAveragedPoints. The /rules page states ties are "combined and split equally among them, rounded to the nearest whole point", and its own worked example rounds 18.33 down to 18, so this is nearest rather than ceiling. Season point values are integer columns, making this averaging the only source of fractional points; rounding here means the standings' 218 is now correct by construction rather than a display artifact, and per-pick values visibly sum to the team total. Existing assertions encoding the unrounded results are updated, and the rules page's two published examples are asserted directly so the code and the published rule cannot drift apart. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz --- app/lib/corona-states.ts | 32 +++- app/models/__tests__/draft-pick.test.ts | 2 +- .../__tests__/scoring-calculator.test.ts | 21 ++- app/models/__tests__/season-standings.test.ts | 12 +- .../__tests__/team-projected-score.test.ts | 2 +- app/models/draft-pick.ts | 51 ++----- app/models/participant-result.ts | 53 ++++++- app/models/scoring-calculator.ts | 142 ++++++++---------- app/models/scoring-rules.ts | 48 +++++- app/models/standings.ts | 34 +++-- .../$leagueId.draft-board.$seasonId.tsx | 8 + 11 files changed, 258 insertions(+), 147 deletions(-) diff --git a/app/lib/corona-states.ts b/app/lib/corona-states.ts index db35e03..fa0b112 100644 --- a/app/lib/corona-states.ts +++ b/app/lib/corona-states.ts @@ -1,4 +1,4 @@ -import { calculateFantasyPoints, calculateBracketPoints } from "~/models/scoring-rules"; +import { calculatePickPoints } from "~/models/scoring-rules"; export type CoronaState = | { type: "eliminated"; points: 0 } @@ -27,12 +27,22 @@ interface ScoringRules { pointsFor8th: number; } +/** + * Corona badge state for every pick on the draft board. + * + * @param sharedPlacementCountsBySportsSeason - sportsSeasonId → (finalPosition → + * how many participants share it), from getSharedPlacementCounts. Required for + * qualifying_points picks: a golfer tied for 8th earns the split award, and + * omitting this would show him at full value here while the standings show the + * split — the exact inconsistency this parameter exists to prevent. + */ export function computeCoronaStates( picks: PickEntry[], resultByParticipant: Map, bracketTemplateBySportsSeason: Map, scoringRules: ScoringRules, maxPoints: number, + sharedPlacementCountsBySportsSeason: Map>, ): Record { const coronaStates: Record = {}; @@ -50,13 +60,19 @@ export function computeCoronaStates( } if (result.finalPosition > 0) { - const isBracket = pick.scoringPattern === "playoff_bracket"; - const templateId = isBracket - ? (bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null) - : null; - const points = isBracket - ? calculateBracketPoints(result.finalPosition, scoringRules, templateId) - : calculateFantasyPoints(result.finalPosition, scoringRules); + const points = calculatePickPoints( + result.finalPosition, + pick.scoringPattern, + scoringRules, + { + bracketTemplateId: + bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null, + tiedParticipants: + sharedPlacementCountsBySportsSeason + .get(pick.participant.sportsSeasonId) + ?.get(result.finalPosition) ?? 1, + } + ); const brightness = maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0; coronaStates[pick.participant.id] = { type: "scored", brightness, points }; continue; diff --git a/app/models/__tests__/draft-pick.test.ts b/app/models/__tests__/draft-pick.test.ts index 781df83..2160e78 100644 --- a/app/models/__tests__/draft-pick.test.ts +++ b/app/models/__tests__/draft-pick.test.ts @@ -204,7 +204,7 @@ describe("getDraftedParticipantsWithPoints", () => { const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db); expect(result.get("ss-1")?.[0]).toMatchObject({ - earnedPoints: 62.5, // (75 + 50) / 2 + earnedPoints: 63, // (75 + 50) / 2 = 62.5 → 63 currentQP: null, }); }); diff --git a/app/models/__tests__/scoring-calculator.test.ts b/app/models/__tests__/scoring-calculator.test.ts index b9a307d..7d9525d 100644 --- a/app/models/__tests__/scoring-calculator.test.ts +++ b/app/models/__tests__/scoring-calculator.test.ts @@ -71,8 +71,8 @@ describe("Scoring Calculator", () => { it("should average points for three-way tie", () => { const points = calculateAveragedPoints([1, 2, 3], DEFAULT_SCORING); - // (100 + 70 + 50) / 3 = 73.33... - expect(points).toBeCloseTo(73.33, 2); + // (100 + 70 + 50) / 3 = 73.33... → 73 (nearest whole point) + expect(points).toBe(73); }); it("should handle single placement (no tie)", () => { @@ -109,7 +109,18 @@ describe("Scoring Calculator", () => { }); it("treats positions beyond 8th as zero at the scoring cutoff", () => { - expect(calculateSharedPlacementPoints(8, 2, DEFAULT_SCORING)).toBe(7.5); + // Two tied for 8th share 8th + 9th; 9th is outside the scoring range and + // contributes 0, so (15 + 0) / 2 = 7.5 → 8. + expect(calculateSharedPlacementPoints(8, 2, DEFAULT_SCORING)).toBe(8); + }); + + // The /rules page (app/routes/rules.tsx) publishes these two examples to + // players. They are asserted here so the code and the published rule cannot + // drift apart — note the second rounds DOWN, confirming "nearest whole + // point" rather than always rounding up. + it("matches the tie examples published on the rules page", () => { + expect(calculateSharedPlacementPoints(2, 2, DEFAULT_SCORING)).toBe(60); + expect(calculateSharedPlacementPoints(6, 3, DEFAULT_SCORING)).toBe(18); }); }); @@ -150,8 +161,8 @@ describe("Scoring Calculator", () => { describe("Edge Cases", () => { it("should handle all participants tying for 1st-8th", () => { const points = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING); - // (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 - expect(points).toBe(42.5); + // (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43 + expect(points).toBe(43); }); it("should handle placements with same point values", () => { diff --git a/app/models/__tests__/season-standings.test.ts b/app/models/__tests__/season-standings.test.ts index 45c1928..4d525d0 100644 --- a/app/models/__tests__/season-standings.test.ts +++ b/app/models/__tests__/season-standings.test.ts @@ -41,8 +41,8 @@ describe("Season Standings (F1 Pattern)", () => { it("should handle 3-way tie for 5th place", () => { // Three drivers tied for 5th share 5th, 6th, and 7th place points const points = calculateAveragedPoints([5, 6, 7], DEFAULT_SCORING); - // (25 + 25 + 15) / 3 = 21.67 - expect(points).toBeCloseTo(21.67, 2); + // (25 + 25 + 15) / 3 = 21.67 → 22 (nearest whole point) + expect(points).toBe(22); }); it("should handle 4-way tie for 1st place", () => { @@ -100,7 +100,7 @@ describe("Season Standings (F1 Pattern)", () => { // Verify tied positions get averaged const tiedPoints = calculateAveragedPoints([3, 4, 5], DEFAULT_SCORING); - expect(tiedPoints).toBeCloseTo(38.33, 2); // (50 + 40 + 25) / 3 + expect(tiedPoints).toBe(38); // (50 + 40 + 25) / 3 = 38.33 → 38 // Verify remaining positions expect(calculateFantasyPoints(6, DEFAULT_SCORING)).toBe(25); @@ -143,7 +143,7 @@ describe("Season Standings (F1 Pattern)", () => { // Tied positions const tied3rd = calculateAveragedPoints([3, 4], customScoring); - expect(tied3rd).toBe(67.5); // (75 + 60) / 2 + expect(tied3rd).toBe(68); // (75 + 60) / 2 = 67.5 → 68 (halves round up) }); }); @@ -151,8 +151,8 @@ describe("Season Standings (F1 Pattern)", () => { it("should handle all 8 positions tied", () => { // Extremely unlikely but mathematically possible const allTied = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING); - // (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 - expect(allTied).toBe(42.5); + // (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43 + expect(allTied).toBe(43); }); it("should handle only top 4 finishing (others DNF/DQ)", () => { diff --git a/app/models/__tests__/team-projected-score.test.ts b/app/models/__tests__/team-projected-score.test.ts index bbce292..a456704 100644 --- a/app/models/__tests__/team-projected-score.test.ts +++ b/app/models/__tests__/team-projected-score.test.ts @@ -344,7 +344,7 @@ describe("calculateTeamScore", () => { const result = await calculateTeamScore("team1", "season1", db); - expect(result.totalPoints).toBe(7.5); // (15 + 0) / 2 + expect(result.totalPoints).toBe(8); // (15 + 0) / 2 = 7.5 → 8 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 7289046..59e8f3c 100644 --- a/app/models/draft-pick.ts +++ b/app/models/draft-pick.ts @@ -1,12 +1,11 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, inArray, asc } from "drizzle-orm"; +import { getScoringRules, calculatePickPoints } from "./scoring-rules"; import { - getScoringRules, - calculateFantasyPoints, - calculateBracketPoints, - calculateSharedPlacementPoints, -} from "./scoring-rules"; + getSharedPlacementCounts, + lookupSharedPlacementCount, +} from "./participant-result"; export async function createDraftPick(data: { seasonId: string; @@ -203,19 +202,10 @@ 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); - } - } + const qpSharedPlacementCounts = await getSharedPlacementCounts( + [...finalizedQPSeasonIds], + db + ); // Assemble result grouped by sportsSeasonId const result = new Map(); @@ -233,23 +223,14 @@ 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) - 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); - } + earnedPoints = calculatePickPoints(resultRow.finalPosition, pattern, scoringRules, { + bracketTemplateId: bracketTemplateMap.get(sportsSeasonId) ?? null, + tiedParticipants: lookupSharedPlacementCount( + qpSharedPlacementCounts, + sportsSeasonId, + resultRow.finalPosition + ), + }); } const arr = result.get(sportsSeasonId) ?? []; diff --git a/app/models/participant-result.ts b/app/models/participant-result.ts index 4e36257..30fbca5 100644 --- a/app/models/participant-result.ts +++ b/app/models/participant-result.ts @@ -1,4 +1,4 @@ -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; @@ -76,6 +76,57 @@ export async function findParticipantResultsBySportsSeasonId( }); } +/** + * How many participants share each scoring placement, per sports season. + * + * Returns sportsSeasonId → (finalPosition → count). Used to split a tied + * placement's points across the tied participants (see calculatePickPoints). + * + * The count spans EVERY result in the sports season, not just drafted ones — a + * golfer tied for 8th with an undrafted player still only earns half the 8th + * place points, so narrowing this query to drafted participants would silently + * over-award. Positions <= 0 (no scoring placement) are excluded. + * + * Callers that look up a position with no entry should treat it as 1 (no tie). + */ +export async function getSharedPlacementCounts( + sportsSeasonIds: string[], + providedDb?: ReturnType +): Promise>> { + const counts = new Map>(); + if (sportsSeasonIds.length === 0) return counts; + + const db = providedDb || database(); + const rows = await db.query.seasonParticipantResults.findMany({ + where: inArray(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonIds), + columns: { sportsSeasonId: true, finalPosition: true }, + }); + + for (const row of rows) { + if (row.finalPosition === null || row.finalPosition <= 0) continue; + let bySeason = counts.get(row.sportsSeasonId); + if (!bySeason) { + bySeason = new Map(); + counts.set(row.sportsSeasonId, bySeason); + } + bySeason.set(row.finalPosition, (bySeason.get(row.finalPosition) ?? 0) + 1); + } + + return counts; +} + +/** + * Convenience lookup over getSharedPlacementCounts' result. Missing entries mean + * no other participant shares the placement, so the tie count is 1. + */ +export function lookupSharedPlacementCount( + counts: Map>, + sportsSeasonId: string, + finalPosition: number +): number { + return counts.get(sportsSeasonId)?.get(finalPosition) ?? 1; +} + export async function updateParticipantResult( id: string, data: Partial diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index cf6ef02..7b3ef84 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -1,12 +1,8 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, inArray } from "drizzle-orm"; -import { - getScoringRules, - calculateFantasyPoints, - calculateBracketPoints, - calculateSharedPlacementPoints, -} from "./scoring-rules"; +import { getScoringRules, calculatePickPoints, type ScoringRules } from "./scoring-rules"; +import { getSharedPlacementCounts } from "./participant-result"; import { getSeasonResults } from "./participant-season-result"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord"; @@ -595,6 +591,11 @@ async function upsertParticipantResult( } } +/** + * Lazily populates `cache` (sportsSeasonId → placement → count) one sports + * season at a time, then reads the tie count for `finalPosition`. The counting + * itself lives in getSharedPlacementCounts so every screen shares one definition. + */ async function getSharedPlacementCount( sportsSeasonId: string, finalPosition: number, @@ -602,17 +603,8 @@ async function getSharedPlacementCount( 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); + const counts = await getSharedPlacementCounts([sportsSeasonId], db); + cache.set(sportsSeasonId, counts.get(sportsSeasonId) ?? new Map()); } return cache.get(sportsSeasonId)?.get(finalPosition) ?? 1; @@ -1376,27 +1368,22 @@ export async function calculateTeamScore( const result = pick.participant.results[0]; 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); - } + const pattern = pick.participant.sportsSeason?.scoringPattern; + const points = calculatePickPoints(result.finalPosition, pattern, scoringRules, { + bracketTemplateId: + pattern === "playoff_bracket" + ? await getBracketTemplate(pick.participant.sportsSeasonId) + : null, + tiedParticipants: + pattern === "qualifying_points" + ? await getSharedPlacementCount( + pick.participant.sportsSeasonId, + result.finalPosition, + db, + sharedPlacementCountCache + ) + : 1, + }); totalPoints += points; // All participants with a valid position count toward the placement tiebreaker, @@ -1483,57 +1470,52 @@ export async function calculateTeamProjectedScore( return templateId; } + // Points for one pick's placement. Identical for finalized results and for the + // provisional floor of a still-alive participant — only what we do with the + // number afterwards differs. `rules` re-binds the null-checked scoringRules + // because the hoisted declaration below cannot see that narrowing. + const rules: ScoringRules = scoringRules; + async function pointsForPick( + sportsSeasonId: string, + pattern: string | null | undefined, + finalPosition: number + ): Promise { + return calculatePickPoints(finalPosition, pattern, rules, { + bracketTemplateId: + pattern === "playoff_bracket" ? await getBracketTemplate(sportsSeasonId) : null, + tiedParticipants: + pattern === "qualifying_points" + ? await getSharedPlacementCount( + sportsSeasonId, + finalPosition, + db, + sharedPlacementCountCache + ) + : 1, + }); + } + // Separate finished vs unfinished participants 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"; + const pattern = pick.participant.sportsSeason?.scoringPattern; if (result && result.finalPosition !== null && !result.isPartialScore) { - // Participant is fully finalized — use bracket-averaged 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); - } - actualPoints += points; + // Participant is fully finalized + actualPoints += await pointsForPick( + pick.participant.sportsSeasonId, + pattern, + result.finalPosition + ); participantsFinished++; } else if (result && result.finalPosition !== null && result.isPartialScore) { // 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; - 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); - } + const floorPoints = await pointsForPick( + pick.participant.sportsSeasonId, + pattern, + result.finalPosition + ); 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-rules.ts b/app/models/scoring-rules.ts index a05ed8f..26d4816 100644 --- a/app/models/scoring-rules.ts +++ b/app/models/scoring-rules.ts @@ -90,6 +90,13 @@ export function calculateFantasyPoints( * * Example: 4 teams lose in quarterfinals, they share positions 5-8 * Average = (25 + 25 + 15 + 15) / 4 = 20 points each + * + * The result is rounded to the nearest whole point, matching the published rule + * on the /rules page: "the points for all tied positions are combined and split + * equally among them, rounded to the nearest whole point". That page's own + * example rounds down — a three-way tie for 6th–8th is (25 + 15 + 15) / 3 = + * 18.33 → 18 — so this is nearest, not ceiling. Season point values are integer + * columns, so this averaging is the only place fractional points can arise. */ export function calculateAveragedPoints( placements: number[], @@ -101,7 +108,9 @@ export function calculateAveragedPoints( return sum + calculateFantasyPoints(placement, rules); }, 0); - return total / placements.length; + // Epsilon guard mirrors roundQualifyingPoints — keeps values that are exactly + // representable-adjacent (e.g. 18.499999999999996) from rounding the wrong way. + return Math.round(total / placements.length + Number.EPSILON); } /** @@ -190,6 +199,43 @@ export function calculateBracketPoints( return 0; } +/** + * Fantasy points earned by a single drafted participant, for any scoring pattern. + * + * This is the ONE place the bracket / qualifying_points / default cascade lives. + * It previously existed as a hand-rolled if/else at six call sites, two of which + * were missing the qualifying_points arm entirely — that divergence is what made + * a tied golfer worth 15 points on the team page and draft board but 7.5 in the + * standings. Every caller must route through here. + * + * @param finalPosition - Placement in the sports season (1-8 scores, 0 = none). + * @param scoringPattern - The sports season's scoringPattern column. + * @param rules - The fantasy season's point values. + * @param opts.bracketTemplateId - Required for playoff_bracket to pick the right + * tier structure (e.g. AFL/LLWS split 5–8 into two pairs). + * @param opts.tiedParticipants - Required for qualifying_points: how many + * participants share this finalPosition across the WHOLE sports season, not + * just the ones that were drafted. Defaults to 1 (no tie). + */ +export function calculatePickPoints( + finalPosition: number, + scoringPattern: string | null | undefined, + rules: ScoringRules, + opts?: { bracketTemplateId?: string | null; tiedParticipants?: number } +): number { + if (scoringPattern === "playoff_bracket") { + return calculateBracketPoints(finalPosition, rules, opts?.bracketTemplateId ?? null); + } + if (scoringPattern === "qualifying_points") { + return calculateSharedPlacementPoints( + finalPosition, + opts?.tiedParticipants ?? 1, + rules + ); + } + return calculateFantasyPoints(finalPosition, rules); +} + /** * Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th] * Useful for display purposes diff --git a/app/models/standings.ts b/app/models/standings.ts index 97acc5f..6416601 100644 --- a/app/models/standings.ts +++ b/app/models/standings.ts @@ -2,7 +2,11 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and } from "drizzle-orm"; import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings"; -import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules"; +import { calculatePickPoints } from "~/models/scoring-rules"; +import { + getSharedPlacementCounts, + lookupSharedPlacementCount, +} from "~/models/participant-result"; import { logger } from "~/lib/logger"; import { getParticipantEV } from "./participant-expected-value"; import { calculateEV } from "~/services/ev-calculator"; @@ -157,6 +161,14 @@ export async function getTeamScoreBreakdown( pointsFor8th: season.pointsFor8th, }; + // Tie counts for qualifying_points picks, so a golfer tied for 8th is worth the + // split award here exactly as it is in calculateTeamScore. Counted across every + // participant in the sports season — an undrafted tie partner still halves it. + const sharedPlacementCounts = await getSharedPlacementCounts( + [...new Set(picks.map((p) => p.participant.sportsSeasonId))], + db + ); + // Cache bracket template IDs per sports season (same approach as calculateTeamScore) const bracketTemplateCache = new Map(); async function getBracketTemplate(sportsSeasonId: string): Promise { @@ -176,7 +188,7 @@ export async function getTeamScoreBreakdown( const pickBreakdown = await Promise.all( picks.map(async (pick) => { const result = pick.participant.results[0]; - const isBracket = pick.participant.sportsSeason.scoringPattern === "playoff_bracket"; + const pattern = pick.participant.sportsSeason.scoringPattern; let points = 0; let projectedPoints: number | null = null; @@ -199,13 +211,17 @@ export async function getTeamScoreBreakdown( }; if (result && result.finalPosition !== null && result.finalPosition > 0) { - // Calculate points using bracket-averaged scoring for bracket sports - if (isBracket) { - const templateId = await getBracketTemplate(pick.participant.sportsSeasonId); - points = calculateBracketPoints(result.finalPosition, scoringRules, templateId); - } else { - points = calculateFantasyPoints(result.finalPosition, scoringRules); - } + points = calculatePickPoints(result.finalPosition, pattern, scoringRules, { + bracketTemplateId: + pattern === "playoff_bracket" + ? await getBracketTemplate(pick.participant.sportsSeasonId) + : null, + tiedParticipants: lookupSharedPlacementCount( + sharedPlacementCounts, + pick.participant.sportsSeasonId, + result.finalPosition + ), + }); if (result.isPartialScore) { // Still alive with a floor position — use EV for projected since they can advance diff --git a/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx b/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx index d1ed2ff..9379560 100644 --- a/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx @@ -12,6 +12,7 @@ import { Button } from "~/components/ui/button"; import { ArrowLeft } from "lucide-react"; import logomarkUrl from "../../../public/logomark.svg?url"; import { computeCoronaStates } from "~/lib/corona-states"; +import { getSharedPlacementCounts } from "~/models/participant-result"; import type { CoronaState } from "~/components/draft/DraftPickCell"; import type { Route } from "./+types/$leagueId.draft-board.$seasonId"; @@ -157,12 +158,19 @@ export async function loader(args: Route.LoaderArgs) { pointsFor8th: season.pointsFor8th, }; + // Counted across every participant in each sports season, not just the + // drafted ones above — an undrafted player tied for the same placement still + // halves the award. + const sharedPlacementCountsBySportsSeason = + await getSharedPlacementCounts(sportsSeasonIds, db); + coronaStates = computeCoronaStates( draftPicks, resultByParticipant, bracketTemplateBySportsSeason, scoringRules, season.pointsFor1st, + sharedPlacementCountsBySportsSeason, ); }