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:
parent
3c7272392e
commit
430526104c
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