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 ): Promise { 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, providedDb?: ReturnType ): Promise { 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 = { 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, ]; }