claude/points-split-rounding-inconsistency-7yn7xp #141
11 changed files with 258 additions and 147 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) ?? [];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue