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
parent 81d813d3f3
commit f3d63922d0
No known key found for this signature in database
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 =
| { 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(
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
);
points = calculateSharedPlacementPoints(
result.finalPosition,
tiedParticipants,
scoringRules
);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
)
: 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(
// Participant is fully finalized
actualPoints += await pointsForPick(
pick.participant.sportsSeasonId,
result.finalPosition,
db,
sharedPlacementCountCache
pattern,
result.finalPosition
);
points = calculateSharedPlacementPoints(
result.finalPosition,
tiedParticipants,
scoringRules
);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
actualPoints += points;
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(
const floorPoints = await pointsForPick(
pick.participant.sportsSeasonId,
result.finalPosition,
db,
sharedPlacementCountCache
pattern,
result.finalPosition
);
floorPoints = calculateSharedPlacementPoints(
result.finalPosition,
tiedParticipants,
scoringRules
);
} else {
floorPoints = calculateFantasyPoints(result.finalPosition, scoringRules);
}
actualPoints += floorPoints;
// EV already accounts for their full projected value, so subtract floor to avoid
// double-counting when we do actualPoints + evSum below

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