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 =
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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)", () => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) ?? [];
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 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(
|
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 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]
|
* Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th]
|
||||||
* Useful for display purposes
|
* Useful for display purposes
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue