claude/points-split-rounding-inconsistency-7yn7xp #141

Open
chrisp wants to merge 4 commits from claude/points-split-rounding-inconsistency-7yn7xp into main
11 changed files with 258 additions and 147 deletions
Showing only changes of commit 430526104c - Show all commits

View file

@ -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<string, ResultEntry>,
bracketTemplateBySportsSeason: Map<string, string | null>,
scoringRules: ScoringRules,
maxPoints: number,
sharedPlacementCountsBySportsSeason: Map<string, Map<number, number>>,
): Record<string, CoronaState> {
const coronaStates: Record<string, CoronaState> = {};
@ -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;

View file

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

View file

@ -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", () => {

View file

@ -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)", () => {

View file

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

View file

@ -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<string, Map<number, number>>();
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<number, number>();
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<string, DraftedParticipantWithPoints[]>();
@ -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) ?? [];

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 * 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(
id: string,
data: Partial<NewParticipantResult>

View file

@ -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<string, Map<number, number>>
): Promise<number> {
if (!cache.has(sportsSeasonId)) {
const results = await db.query.seasonParticipantResults.findMany({
where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
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);
const counts = await getSharedPlacementCounts([sportsSeasonId], db);
cache.set(sportsSeasonId, counts.get(sportsSeasonId) ?? new Map<number, number>());
}
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<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
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

View file

@ -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 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(
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 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]
* Useful for display purposes

View file

@ -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<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
@ -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

View file

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