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
254 lines
8.6 KiB
TypeScript
254 lines
8.6 KiB
TypeScript
import { database } from "~/database/context";
|
||
import * as schema from "~/database/schema";
|
||
import { eq } from "drizzle-orm";
|
||
import { DEFAULT_SCORING_RULES, type ScoringRules } from "~/lib/scoring-types";
|
||
|
||
// Re-export for convenience
|
||
export { DEFAULT_SCORING_RULES, type ScoringRules };
|
||
|
||
/**
|
||
* Get scoring rules for a season
|
||
*/
|
||
export async function getScoringRules(
|
||
seasonId: string,
|
||
providedDb?: ReturnType<typeof database>
|
||
): Promise<ScoringRules | null> {
|
||
const db = providedDb || database();
|
||
|
||
const season = await db.query.seasons.findFirst({
|
||
where: eq(schema.seasons.id, seasonId),
|
||
});
|
||
|
||
if (!season) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
pointsFor1st: season.pointsFor1st,
|
||
pointsFor2nd: season.pointsFor2nd,
|
||
pointsFor3rd: season.pointsFor3rd,
|
||
pointsFor4th: season.pointsFor4th,
|
||
pointsFor5th: season.pointsFor5th,
|
||
pointsFor6th: season.pointsFor6th,
|
||
pointsFor7th: season.pointsFor7th,
|
||
pointsFor8th: season.pointsFor8th,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Update scoring rules for a season
|
||
*/
|
||
export async function updateScoringRules(
|
||
seasonId: string,
|
||
rules: Partial<ScoringRules>,
|
||
providedDb?: ReturnType<typeof database>
|
||
): Promise<ScoringRules> {
|
||
const db = providedDb || database();
|
||
|
||
const [updated] = await db
|
||
.update(schema.seasons)
|
||
.set(rules)
|
||
.where(eq(schema.seasons.id, seasonId))
|
||
.returning({
|
||
pointsFor1st: schema.seasons.pointsFor1st,
|
||
pointsFor2nd: schema.seasons.pointsFor2nd,
|
||
pointsFor3rd: schema.seasons.pointsFor3rd,
|
||
pointsFor4th: schema.seasons.pointsFor4th,
|
||
pointsFor5th: schema.seasons.pointsFor5th,
|
||
pointsFor6th: schema.seasons.pointsFor6th,
|
||
pointsFor7th: schema.seasons.pointsFor7th,
|
||
pointsFor8th: schema.seasons.pointsFor8th,
|
||
});
|
||
|
||
return updated;
|
||
}
|
||
|
||
/**
|
||
* Calculate fantasy points for a given placement based on season scoring rules
|
||
*/
|
||
export function calculateFantasyPoints(
|
||
placement: number,
|
||
rules: ScoringRules
|
||
): number {
|
||
const pointsMap: Record<number, number> = {
|
||
1: rules.pointsFor1st,
|
||
2: rules.pointsFor2nd,
|
||
3: rules.pointsFor3rd,
|
||
4: rules.pointsFor4th,
|
||
5: rules.pointsFor5th,
|
||
6: rules.pointsFor6th,
|
||
7: rules.pointsFor7th,
|
||
8: rules.pointsFor8th,
|
||
};
|
||
|
||
return pointsMap[placement] || 0;
|
||
}
|
||
|
||
/**
|
||
* Calculate averaged points for shared placements (e.g., playoff ties)
|
||
* Used when multiple participants share positions
|
||
*
|
||
* 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[],
|
||
rules: ScoringRules
|
||
): number {
|
||
if (placements.length === 0) return 0;
|
||
|
||
const total = placements.reduce((sum, placement) => {
|
||
return sum + calculateFantasyPoints(placement, rules);
|
||
}, 0);
|
||
|
||
// 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);
|
||
}
|
||
|
||
/**
|
||
* Calculate points for participants sharing a standings placement.
|
||
*
|
||
* Example: two participants tied for 2nd share 2nd and 3rd place points.
|
||
* If a tie extends beyond the scoring range, the extra slots contribute 0.
|
||
*/
|
||
export function calculateSharedPlacementPoints(
|
||
startPlacement: number,
|
||
tiedParticipants: number,
|
||
rules: ScoringRules
|
||
): number {
|
||
if (startPlacement <= 0 || tiedParticipants <= 0) return 0;
|
||
|
||
const placements = Array.from(
|
||
{ length: tiedParticipants },
|
||
(_, index) => startPlacement + index
|
||
);
|
||
|
||
return calculateAveragedPoints(placements, rules);
|
||
}
|
||
|
||
/**
|
||
* Tier definitions for brackets where positions 5–8 split into two separate pairs.
|
||
*
|
||
* Most brackets (standard single-elimination) have ONE tier covering positions 5–8:
|
||
* four QF losers all tie and share the combined prize pool → avg([5,6,7,8]).
|
||
*
|
||
* AFL is different: it has TWO distinct tiers in the 5–8 zone:
|
||
* - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6])
|
||
* - T7-T8: Elimination Finals losers (positions 7 and 8) → avg([7,8])
|
||
*
|
||
* LLWS has the same shape from its two elimination brackets:
|
||
* - T5-T6: Elimination Final losers (one per side) → avg([5,6])
|
||
* - T7-T8: Elimination Round 4 losers (one per side) → avg([7,8])
|
||
*/
|
||
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]);
|
||
|
||
/**
|
||
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
|
||
* (not averaged). Standard brackets average them because both SF losers tie.
|
||
*
|
||
* llws_20's Consolation Third Place game decides 3rd and 4th head-to-head between
|
||
* the two side runners-up.
|
||
*/
|
||
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]);
|
||
|
||
/**
|
||
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
||
*
|
||
* Standard single-elimination bracket tiers:
|
||
* 1st: solo winner
|
||
* 2nd: solo finalist
|
||
* 3rd-4th: two SF losers share these positions → averaged
|
||
* 5th-8th: four QF losers share these positions → averaged
|
||
*
|
||
* AFL bracket tiers (afl_10):
|
||
* 5th-6th: Semi-Finals losers → averaged separately from 7th-8th
|
||
* 7th-8th: Elimination Finals losers → averaged separately from 5th-6th
|
||
*
|
||
* Use this instead of calculateFantasyPoints for playoff_bracket scoring.
|
||
*/
|
||
export function calculateBracketPoints(
|
||
finalPosition: number,
|
||
rules: ScoringRules,
|
||
bracketTemplateId?: string | null
|
||
): number {
|
||
if (finalPosition <= 0) return 0;
|
||
if (finalPosition === 1) return rules.pointsFor1st;
|
||
if (finalPosition === 2) return rules.pointsFor2nd;
|
||
if (finalPosition === 3 || finalPosition === 4) {
|
||
if (bracketTemplateId && DISTINCT_34_TEMPLATE_IDS.has(bracketTemplateId))
|
||
return finalPosition === 3 ? rules.pointsFor3rd : rules.pointsFor4th;
|
||
return calculateAveragedPoints([3, 4], rules);
|
||
}
|
||
if (finalPosition >= 5 && finalPosition <= 8) {
|
||
if (bracketTemplateId && SPLIT_5678_TEMPLATE_IDS.has(bracketTemplateId)) {
|
||
// AFL-style: two separate 2-team tiers within 5–8
|
||
if (finalPosition <= 6) return calculateAveragedPoints([5, 6], rules);
|
||
return calculateAveragedPoints([7, 8], rules);
|
||
}
|
||
// Standard: all four QF losers share one tier
|
||
return calculateAveragedPoints([5, 6, 7, 8], rules);
|
||
}
|
||
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
|
||
*/
|
||
export function getScoringRulesArray(rules: ScoringRules): number[] {
|
||
return [
|
||
rules.pointsFor1st,
|
||
rules.pointsFor2nd,
|
||
rules.pointsFor3rd,
|
||
rules.pointsFor4th,
|
||
rules.pointsFor5th,
|
||
rules.pointsFor6th,
|
||
rules.pointsFor7th,
|
||
rules.pointsFor8th,
|
||
];
|
||
}
|