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