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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
This commit is contained in:
Claude 2026-08-07 07:29:37 +00:00 committed by chrisp
parent 3c7272392e
commit 430526104c
11 changed files with 258 additions and 147 deletions

View file

@ -1,4 +1,4 @@
import { calculateFantasyPoints, calculateBracketPoints } from "~/models/scoring-rules"; import { calculatePickPoints } from "~/models/scoring-rules";
export type CoronaState = export type CoronaState =
| { type: "eliminated"; points: 0 } | { type: "eliminated"; points: 0 }
@ -27,12 +27,22 @@ interface ScoringRules {
pointsFor8th: number; 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( export function computeCoronaStates(
picks: PickEntry[], picks: PickEntry[],
resultByParticipant: Map<string, ResultEntry>, resultByParticipant: Map<string, ResultEntry>,
bracketTemplateBySportsSeason: Map<string, string | null>, bracketTemplateBySportsSeason: Map<string, string | null>,
scoringRules: ScoringRules, scoringRules: ScoringRules,
maxPoints: number, maxPoints: number,
sharedPlacementCountsBySportsSeason: Map<string, Map<number, number>>,
): Record<string, CoronaState> { ): Record<string, CoronaState> {
const coronaStates: Record<string, CoronaState> = {}; const coronaStates: Record<string, CoronaState> = {};
@ -50,13 +60,19 @@ export function computeCoronaStates(
} }
if (result.finalPosition > 0) { if (result.finalPosition > 0) {
const isBracket = pick.scoringPattern === "playoff_bracket"; const points = calculatePickPoints(
const templateId = isBracket result.finalPosition,
? (bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null) pick.scoringPattern,
: null; scoringRules,
const points = isBracket {
? calculateBracketPoints(result.finalPosition, scoringRules, templateId) bracketTemplateId:
: calculateFantasyPoints(result.finalPosition, scoringRules); 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; const brightness = maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0;
coronaStates[pick.participant.id] = { type: "scored", brightness, points }; coronaStates[pick.participant.id] = { type: "scored", brightness, points };
continue; continue;

View file

@ -204,7 +204,7 @@ describe("getDraftedParticipantsWithPoints", () => {
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db); const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(result.get("ss-1")?.[0]).toMatchObject({ expect(result.get("ss-1")?.[0]).toMatchObject({
earnedPoints: 62.5, // (75 + 50) / 2 earnedPoints: 63, // (75 + 50) / 2 = 62.5 → 63
currentQP: null, currentQP: null,
}); });
}); });

View file

@ -71,8 +71,8 @@ describe("Scoring Calculator", () => {
it("should average points for three-way tie", () => { it("should average points for three-way tie", () => {
const points = calculateAveragedPoints([1, 2, 3], DEFAULT_SCORING); const points = calculateAveragedPoints([1, 2, 3], DEFAULT_SCORING);
// (100 + 70 + 50) / 3 = 73.33... // (100 + 70 + 50) / 3 = 73.33... → 73 (nearest whole point)
expect(points).toBeCloseTo(73.33, 2); expect(points).toBe(73);
}); });
it("should handle single placement (no tie)", () => { 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", () => { 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", () => { describe("Edge Cases", () => {
it("should handle all participants tying for 1st-8th", () => { it("should handle all participants tying for 1st-8th", () => {
const points = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING); const points = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 // (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43
expect(points).toBe(42.5); expect(points).toBe(43);
}); });
it("should handle placements with same point values", () => { it("should handle placements with same point values", () => {

View file

@ -41,8 +41,8 @@ describe("Season Standings (F1 Pattern)", () => {
it("should handle 3-way tie for 5th place", () => { it("should handle 3-way tie for 5th place", () => {
// Three drivers tied for 5th share 5th, 6th, and 7th place points // Three drivers tied for 5th share 5th, 6th, and 7th place points
const points = calculateAveragedPoints([5, 6, 7], DEFAULT_SCORING); const points = calculateAveragedPoints([5, 6, 7], DEFAULT_SCORING);
// (25 + 25 + 15) / 3 = 21.67 // (25 + 25 + 15) / 3 = 21.67 → 22 (nearest whole point)
expect(points).toBeCloseTo(21.67, 2); expect(points).toBe(22);
}); });
it("should handle 4-way tie for 1st place", () => { it("should handle 4-way tie for 1st place", () => {
@ -100,7 +100,7 @@ describe("Season Standings (F1 Pattern)", () => {
// Verify tied positions get averaged // Verify tied positions get averaged
const tiedPoints = calculateAveragedPoints([3, 4, 5], DEFAULT_SCORING); 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 // Verify remaining positions
expect(calculateFantasyPoints(6, DEFAULT_SCORING)).toBe(25); expect(calculateFantasyPoints(6, DEFAULT_SCORING)).toBe(25);
@ -143,7 +143,7 @@ describe("Season Standings (F1 Pattern)", () => {
// Tied positions // Tied positions
const tied3rd = calculateAveragedPoints([3, 4], customScoring); 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", () => { it("should handle all 8 positions tied", () => {
// Extremely unlikely but mathematically possible // Extremely unlikely but mathematically possible
const allTied = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING); const allTied = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 // (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43
expect(allTied).toBe(42.5); expect(allTied).toBe(43);
}); });
it("should handle only top 4 finishing (others DNF/DQ)", () => { it("should handle only top 4 finishing (others DNF/DQ)", () => {

View file

@ -344,7 +344,7 @@ describe("calculateTeamScore", () => {
const result = await calculateTeamScore("team1", "season1", db); 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.participantsCompleted).toBe(1);
expect(result.placementCounts[8]).toBe(1); expect(result.placementCounts[8]).toBe(1);
}); });

View file

@ -1,12 +1,11 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, inArray, asc } from "drizzle-orm"; import { eq, and, inArray, asc } from "drizzle-orm";
import { getScoringRules, calculatePickPoints } from "./scoring-rules";
import { import {
getScoringRules, getSharedPlacementCounts,
calculateFantasyPoints, lookupSharedPlacementCount,
calculateBracketPoints, } from "./participant-result";
calculateSharedPlacementPoints,
} from "./scoring-rules";
export async function createDraftPick(data: { export async function createDraftPick(data: {
seasonId: string; seasonId: string;
@ -203,19 +202,10 @@ export async function getDraftedParticipantsWithPoints(
} }
} }
const qpSharedPlacementCounts = new Map<string, Map<number, number>>(); const qpSharedPlacementCounts = await getSharedPlacementCounts(
if (finalizedQPSeasonIds.size > 0) { [...finalizedQPSeasonIds],
const results = await db.query.seasonParticipantResults.findMany({ db
where: inArray(schema.seasonParticipantResults.sportsSeasonId, [...finalizedQPSeasonIds]), );
columns: { sportsSeasonId: true, finalPosition: true },
});
for (const row of results) {
if (row.finalPosition === null || row.finalPosition <= 0) continue;
const counts = qpSharedPlacementCounts.get(row.sportsSeasonId) ?? new Map<number, number>();
qpSharedPlacementCounts.set(row.sportsSeasonId, counts);
counts.set(row.finalPosition, (counts.get(row.finalPosition) ?? 0) + 1);
}
}
// Assemble result grouped by sportsSeasonId // Assemble result grouped by sportsSeasonId
const result = new Map<string, DraftedParticipantWithPoints[]>(); const result = new Map<string, DraftedParticipantWithPoints[]>();
@ -233,23 +223,14 @@ export async function getDraftedParticipantsWithPoints(
currentQP = qpMap.get(id) ?? null; currentQP = qpMap.get(id) ?? null;
} else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) { } else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) {
// Finalized result for any pattern (including finalized QP seasons) // Finalized result for any pattern (including finalized QP seasons)
if (pattern === "playoff_bracket") { earnedPoints = calculatePickPoints(resultRow.finalPosition, pattern, scoringRules, {
earnedPoints = calculateBracketPoints( bracketTemplateId: bracketTemplateMap.get(sportsSeasonId) ?? null,
resultRow.finalPosition, tiedParticipants: lookupSharedPlacementCount(
scoringRules, qpSharedPlacementCounts,
bracketTemplateMap.get(sportsSeasonId) ?? null sportsSeasonId,
); resultRow.finalPosition
} 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) ?? []; const arr = result.get(sportsSeasonId) ?? [];

View file

@ -1,4 +1,4 @@
import { eq, and } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; 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<typeof database>
): Promise<Map<string, Map<number, number>>> {
const counts = new Map<string, Map<number, number>>();
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<number, number>();
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<string, Map<number, number>>,
sportsSeasonId: string,
finalPosition: number
): number {
return counts.get(sportsSeasonId)?.get(finalPosition) ?? 1;
}
export async function updateParticipantResult( export async function updateParticipantResult(
id: string, id: string,
data: Partial<NewParticipantResult> data: Partial<NewParticipantResult>

View file

@ -1,12 +1,8 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { import { getScoringRules, calculatePickPoints, type ScoringRules } from "./scoring-rules";
getScoringRules, import { getSharedPlacementCounts } from "./participant-result";
calculateFantasyPoints,
calculateBracketPoints,
calculateSharedPlacementPoints,
} from "./scoring-rules";
import { getSeasonResults } from "./participant-season-result"; import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord"; 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( async function getSharedPlacementCount(
sportsSeasonId: string, sportsSeasonId: string,
finalPosition: number, finalPosition: number,
@ -602,17 +603,8 @@ async function getSharedPlacementCount(
cache: Map<string, Map<number, number>> cache: Map<string, Map<number, number>>
): Promise<number> { ): Promise<number> {
if (!cache.has(sportsSeasonId)) { if (!cache.has(sportsSeasonId)) {
const results = await db.query.seasonParticipantResults.findMany({ const counts = await getSharedPlacementCounts([sportsSeasonId], db);
where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId), cache.set(sportsSeasonId, counts.get(sportsSeasonId) ?? new Map<number, number>());
columns: { finalPosition: true },
});
const counts = new Map<number, number>();
for (const result of results) {
if (result.finalPosition !== null && result.finalPosition > 0) {
counts.set(result.finalPosition, (counts.get(result.finalPosition) ?? 0) + 1);
}
}
cache.set(sportsSeasonId, counts);
} }
return cache.get(sportsSeasonId)?.get(finalPosition) ?? 1; return cache.get(sportsSeasonId)?.get(finalPosition) ?? 1;
@ -1376,27 +1368,22 @@ export async function calculateTeamScore(
const result = pick.participant.results[0]; const result = pick.participant.results[0];
if (result && result.finalPosition !== null && result.finalPosition > 0) { if (result && result.finalPosition !== null && result.finalPosition > 0) {
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket"; const pattern = pick.participant.sportsSeason?.scoringPattern;
const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points"; const points = calculatePickPoints(result.finalPosition, pattern, scoringRules, {
let points: number; bracketTemplateId:
if (isBracket) { pattern === "playoff_bracket"
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId); ? await getBracketTemplate(pick.participant.sportsSeasonId)
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId); : null,
} else if (isQualifyingPoints) { tiedParticipants:
const tiedParticipants = await getSharedPlacementCount( pattern === "qualifying_points"
? await getSharedPlacementCount(
pick.participant.sportsSeasonId, pick.participant.sportsSeasonId,
result.finalPosition, result.finalPosition,
db, db,
sharedPlacementCountCache sharedPlacementCountCache
); )
points = calculateSharedPlacementPoints( : 1,
result.finalPosition, });
tiedParticipants,
scoringRules
);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
totalPoints += points; totalPoints += points;
// All participants with a valid position count toward the placement tiebreaker, // All participants with a valid position count toward the placement tiebreaker,
@ -1483,57 +1470,52 @@ export async function calculateTeamProjectedScore(
return templateId; 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<number> {
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 // Separate finished vs unfinished participants
for (const pick of picks) { for (const pick of picks) {
const result = pick.participant.results[0]; const result = pick.participant.results[0];
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket"; const pattern = pick.participant.sportsSeason?.scoringPattern;
const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points";
if (result && result.finalPosition !== null && !result.isPartialScore) { if (result && result.finalPosition !== null && !result.isPartialScore) {
// Participant is fully finalized — use bracket-averaged points // Participant is fully finalized
let points: number; actualPoints += await pointsForPick(
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, pick.participant.sportsSeasonId,
result.finalPosition, pattern,
db, result.finalPosition
sharedPlacementCountCache
); );
points = calculateSharedPlacementPoints(
result.finalPosition,
tiedParticipants,
scoringRules
);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
actualPoints += points;
participantsFinished++; participantsFinished++;
} else if (result && result.finalPosition !== null && result.isPartialScore) { } else if (result && result.finalPosition !== null && result.isPartialScore) {
// Still alive with a provisional floor — count floor as actual, EV for projection. // Still alive with a provisional floor — count floor as actual, EV for projection.
// Note: NOT incremented in participantsFinished; these participants are still competing. // Note: NOT incremented in participantsFinished; these participants are still competing.
const templateId = isBracket ? await getBracketTemplate(pick.participant.sportsSeasonId) : null; const floorPoints = await pointsForPick(
let floorPoints: number;
if (isBracket) {
floorPoints = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
} else if (isQualifyingPoints) {
const tiedParticipants = await getSharedPlacementCount(
pick.participant.sportsSeasonId, pick.participant.sportsSeasonId,
result.finalPosition, pattern,
db, result.finalPosition
sharedPlacementCountCache
); );
floorPoints = calculateSharedPlacementPoints(
result.finalPosition,
tiedParticipants,
scoringRules
);
} else {
floorPoints = calculateFantasyPoints(result.finalPosition, scoringRules);
}
actualPoints += floorPoints; actualPoints += floorPoints;
// EV already accounts for their full projected value, so subtract floor to avoid // EV already accounts for their full projected value, so subtract floor to avoid
// double-counting when we do actualPoints + evSum below // double-counting when we do actualPoints + evSum below

View file

@ -90,6 +90,13 @@ export function calculateFantasyPoints(
* *
* Example: 4 teams lose in quarterfinals, they share positions 5-8 * Example: 4 teams lose in quarterfinals, they share positions 5-8
* Average = (25 + 25 + 15 + 15) / 4 = 20 points each * 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 6th8th 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( export function calculateAveragedPoints(
placements: number[], placements: number[],
@ -101,7 +108,9 @@ export function calculateAveragedPoints(
return sum + calculateFantasyPoints(placement, rules); return sum + calculateFantasyPoints(placement, rules);
}, 0); }, 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; 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 58 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] * Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th]
* Useful for display purposes * Useful for display purposes

View file

@ -2,7 +2,11 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings"; 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 { logger } from "~/lib/logger";
import { getParticipantEV } from "./participant-expected-value"; import { getParticipantEV } from "./participant-expected-value";
import { calculateEV } from "~/services/ev-calculator"; import { calculateEV } from "~/services/ev-calculator";
@ -157,6 +161,14 @@ export async function getTeamScoreBreakdown(
pointsFor8th: season.pointsFor8th, 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) // Cache bracket template IDs per sports season (same approach as calculateTeamScore)
const bracketTemplateCache = new Map<string, string | null>(); const bracketTemplateCache = new Map<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> { async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
@ -176,7 +188,7 @@ export async function getTeamScoreBreakdown(
const pickBreakdown = await Promise.all( const pickBreakdown = await Promise.all(
picks.map(async (pick) => { picks.map(async (pick) => {
const result = pick.participant.results[0]; const result = pick.participant.results[0];
const isBracket = pick.participant.sportsSeason.scoringPattern === "playoff_bracket"; const pattern = pick.participant.sportsSeason.scoringPattern;
let points = 0; let points = 0;
let projectedPoints: number | null = null; let projectedPoints: number | null = null;
@ -199,13 +211,17 @@ export async function getTeamScoreBreakdown(
}; };
if (result && result.finalPosition !== null && result.finalPosition > 0) { if (result && result.finalPosition !== null && result.finalPosition > 0) {
// Calculate points using bracket-averaged scoring for bracket sports points = calculatePickPoints(result.finalPosition, pattern, scoringRules, {
if (isBracket) { bracketTemplateId:
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId); pattern === "playoff_bracket"
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId); ? await getBracketTemplate(pick.participant.sportsSeasonId)
} else { : null,
points = calculateFantasyPoints(result.finalPosition, scoringRules); tiedParticipants: lookupSharedPlacementCount(
} sharedPlacementCounts,
pick.participant.sportsSeasonId,
result.finalPosition
),
});
if (result.isPartialScore) { if (result.isPartialScore) {
// Still alive with a floor position — use EV for projected since they can advance // Still alive with a floor position — use EV for projected since they can advance

View file

@ -12,6 +12,7 @@ import { Button } from "~/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import logomarkUrl from "../../../public/logomark.svg?url"; import logomarkUrl from "../../../public/logomark.svg?url";
import { computeCoronaStates } from "~/lib/corona-states"; import { computeCoronaStates } from "~/lib/corona-states";
import { getSharedPlacementCounts } from "~/models/participant-result";
import type { CoronaState } from "~/components/draft/DraftPickCell"; import type { CoronaState } from "~/components/draft/DraftPickCell";
import type { Route } from "./+types/$leagueId.draft-board.$seasonId"; import type { Route } from "./+types/$leagueId.draft-board.$seasonId";
@ -157,12 +158,19 @@ export async function loader(args: Route.LoaderArgs) {
pointsFor8th: season.pointsFor8th, 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( coronaStates = computeCoronaStates(
draftPicks, draftPicks,
resultByParticipant, resultByParticipant,
bracketTemplateBySportsSeason, bracketTemplateBySportsSeason,
scoringRules, scoringRules,
season.pointsFor1st, season.pointsFor1st,
sharedPlacementCountsBySportsSeason,
); );
} }