import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, inArray } from "drizzle-orm"; import { getScoringRules, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules"; import { getSeasonResults } from "./participant-season-result"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord"; import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; import { getUserDisplayName } from "~/models/user"; import { createDailySnapshot } from "~/models/standings"; import { logger } from "~/lib/logger"; /** * Core scoring calculation engine * This file contains the logic for calculating fantasy points based on participant results */ export type ScoringPattern = | "playoff_bracket" | "season_standings" | "qualifying_points"; // ── Round scoring configuration ────────────────────────────────────────────── interface RoundScoringConfig { /** Final position assigned to the loser of this round. */ loserPosition: number; /** true = loser is still competing (AFL double-chance); false = loser is eliminated. */ loserIsPartial: boolean; /** * Guaranteed minimum position for the winner. * null signals a finalization round: both winner and loser receive their exact positions. */ winnerFloor: number | null; /** * When winnerFloor is null, the winner is finalized at this position. * Defaults to 1 (champion) when omitted — set to 3 for a 3rd place game. */ winnerPosition?: number; } /** * Standard round name → scoring config mapping. * Covers all common bracket formats (NCAA, NBA, NFL, Darts, Snooker, AFL). */ const ROUND_CONFIG: Record = { // ── Finals equivalents ───────────────────────────────────────────────────── Finals: { loserPosition: 2, loserIsPartial: false, winnerFloor: null }, Championship: { loserPosition: 2, loserIsPartial: false, winnerFloor: null }, "Super Bowl": { loserPosition: 2, loserIsPartial: false, winnerFloor: null }, "NBA Finals": { loserPosition: 2, loserIsPartial: false, winnerFloor: null }, "Grand Final": { loserPosition: 2, loserIsPartial: false, winnerFloor: null }, // ── Semi-final equivalents ───────────────────────────────────────────────── Semifinals: { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 }, "Final Four": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 }, "Conference Finals": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 }, "Conference Championship": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 }, "Preliminary Finals": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 }, // ── Quarter-final equivalents ────────────────────────────────────────────── Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 }, "Elite Eight": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 }, Divisional: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 }, "Conference Semifinals": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 }, // ── AFL-specific rounds ──────────────────────────────────────────────────── // Qualifying Finals losers get a second chance via Semi-Finals (double-chance). "Qualifying Finals": { loserPosition: 5, loserIsPartial: true, winnerFloor: 3 }, // Elimination Finals losers are permanently out (7th–8th). "Elimination Finals": { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 }, }; /** * Per-template round config overrides. * Some round names mean different things in different bracket formats. * * IMPORTANT: Template overrides only apply when bracketTemplateId matches exactly. * For example, "Semi-Finals" in afl_10 eliminates the loser (5th–6th), while a * generic "Semi-Finals" round (without afl_10 template) is not in ROUND_CONFIG at * all — use "Semifinals" (no hyphen) for standard brackets. */ const TEMPLATE_ROUND_CONFIG: Record> = { afl_10: { "Semi-Finals": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 }, }, fifa_48: { // QF winner is guaranteed 4th at worst (lose SF → lose 3PG). Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 }, // SF loser still has the 3rd place game — provisional 4th until it's played. Semifinals: { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 }, // 3rd place game finalizes both positions distinctly. "Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 }, }, }; /** * Returns true if a non-scoring round's winners are entering the first scoring round * (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor). * * For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 → * Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring * bracket — Round of 64 and Round of 32 winners should not receive any floor yet. * * Falls back to true when template/round info is unavailable to preserve legacy behavior. */ function doesNonScoringRoundFeedIntoScoringRound( round: string, bracketTemplateId: string | null | undefined ): boolean { if (!bracketTemplateId) return true; // Legacy: preserve old behavior const template = BRACKET_TEMPLATES[bracketTemplateId]; if (!template) return true; // Unknown template: preserve old behavior const currentRound = template.rounds.find((r) => r.name === round); if (!currentRound) return true; // Unknown round: preserve old behavior const nextRoundName = currentRound.feedsInto; if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring) const nextRound = template.rounds.find((r) => r.name === nextRoundName); return nextRound?.isScoring === true; } /** * Look up the scoring config for a given round name, applying any template-specific * overrides before falling back to the standard ROUND_CONFIG. * Returns null for unrecognized rounds. */ function getRoundConfig( round: string, bracketTemplateId?: string | null ): RoundScoringConfig | null { if (bracketTemplateId && TEMPLATE_ROUND_CONFIG[bracketTemplateId]?.[round]) { return TEMPLATE_ROUND_CONFIG[bracketTemplateId][round]; } return ROUND_CONFIG[round] ?? null; } /** * Process a playoff event completion and assign final placements. * * Loser placement rules per round type: * Non-scoring rounds: losers get 0 pts (pre-bracket elimination) * Quarterfinals / equivalent: losers share T5–T8 * Semifinals / equivalent: losers share T3–T4 * Finals / equivalent: loser gets 2nd, winner gets 1st * * Progressive floor scoring: winners immediately earn a provisional placement * reflecting their worst-case finish from this point forward. */ export async function processPlayoffEvent( eventId: string, providedDb?: ReturnType, options?: { skipRecalculate?: boolean } ): Promise { const db = providedDb || database(); // Get the event const event = await db.query.scoringEvents.findFirst({ where: eq(schema.scoringEvents.id, eventId), with: { sportsSeason: { with: { seasonSports: { with: { season: true, }, }, }, }, }, }); if (!event) { throw new Error(`Event ${eventId} not found`); } if (!event.playoffRound) { throw new Error(`Event ${eventId} is not a playoff event`); } // Get all matches for this event in the specified round const matches = await db.query.playoffMatches.findMany({ where: and( eq(schema.playoffMatches.scoringEventId, eventId), eq(schema.playoffMatches.round, event.playoffRound) ), orderBy: (m, { asc }) => [asc(m.matchNumber)], }); // Process based on round const round = event.playoffRound; // Check if this is a scoring round // First try to get isScoring from the match (template-based) const isScoring = matches[0]?.isScoring ?? true; // Default to true for legacy brackets // PHASE 5.3: Handle teams that didn't make the playoffs // Get all participants in the sports season const allParticipants = await db.query.participants.findMany({ where: eq(schema.participants.sportsSeasonId, event.sportsSeasonId), }); // Get all participant IDs that are in ANY playoff match (winners or losers) const allMatches = await db.query.playoffMatches.findMany({ where: eq(schema.playoffMatches.scoringEventId, eventId), }); const participantsInBracket = new Set(); for (const match of allMatches) { if (match.winnerId) participantsInBracket.add(match.winnerId); if (match.loserId) participantsInBracket.add(match.loserId); if (match.participant1Id) participantsInBracket.add(match.participant1Id); if (match.participant2Id) participantsInBracket.add(match.participant2Id); } // Mark participants NOT in the bracket as eliminated (didn't make playoffs) for (const participant of allParticipants) { if (!participantsInBracket.has(participant.id)) { // Check if they already have a result (don't overwrite) const existingResult = await db.query.participantResults.findFirst({ where: and( eq(schema.participantResults.participantId, participant.id), eq(schema.participantResults.sportsSeasonId, event.sportsSeasonId) ), }); if (!existingResult) { await upsertParticipantResult( participant.id, event.sportsSeasonId, 0, // 0 = didn't make playoffs, eliminated db ); } } } // Get season scoring rules for the first affected season // (all seasons should have their own rules, but we use the event's sports season to find one) const seasonSport = event.sportsSeason.seasonSports[0]; if (!seasonSport) { throw new Error(`No fantasy seasons found for sports season ${event.sportsSeasonId}`); } const scoringRules = await getScoringRules(seasonSport.seasonId, db); if (!scoringRules) { throw new Error(`Scoring rules not found for season ${seasonSport.seasonId}`); } if (!isScoring) { // Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts). // Winners only bank a provisional T5–T8 floor if they're entering the first // scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences // like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should // receive floor points — R64 and R32 winners are not yet guaranteed top-8. const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId); for (const match of matches) { if (match.loserId) { await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db); } if (match.winnerId && awardFloor) { await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true); } } } else { const config = getRoundConfig(round, event.bracketTemplateId); if (config === null) { // Unrecognized scoring round: losers earn 0 pts (outside top-8). logger.warn( `[ScoringCalculator] Unrecognized scoring round "${round}" for event ${eventId}. Losers receive 0 pts.` ); for (const match of matches) { if (match.loserId) { await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db); } } } else if (config.winnerFloor === null) { // Finalization round: both participants receive their exact positions. // winnerPosition defaults to 1 (champion); set to 3 for a 3rd place game. const finalMatch = matches[0]; if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) { throw new Error("Finals match is not complete"); } await upsertParticipantResult(finalMatch.winnerId, event.sportsSeasonId, config.winnerPosition ?? 1, db); await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db); } else { // All other scoring rounds: assign loser's final (or provisional) position. for (const match of matches) { if (match.loserId) { await upsertParticipantResult( match.loserId, event.sportsSeasonId, config.loserPosition, db, config.loserIsPartial ); } } } } // Progressive floor scoring: assign guaranteed minimum points to winners. // For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the // winner is already finalized as 1st above. For non-scoring rounds it also // returns null (winners were given floor 5 inline above). const guaranteedMinimum = getGuaranteedMinimumPosition( round, event.bracketTemplateId, isScoring ); if (guaranteedMinimum !== null) { for (const match of matches) { if (match.winnerId) { await upsertParticipantResult( match.winnerId, event.sportsSeasonId, guaranteedMinimum, db, true // isPartialScore: still competing, floor will rise as they advance ); } } } // Recalculate standings for all affected leagues (skipped when caller handles it) if (!options?.skipRecalculate) { await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name, eventId }); } // Auto-trigger probability recalculation after result try { await updateProbabilitiesAfterResult(event.sportsSeasonId, true); logger.log( `[ScoringCalculator] Auto-updated probabilities for sports season ${event.sportsSeasonId}` ); } catch (error) { logger.error( `[ScoringCalculator] Failed to auto-update probabilities for sports season ${event.sportsSeasonId}:`, error ); } } /** * Process a single completed match and immediately award points. * * Called when a match winner is set, before the full round is complete. * Loser placement is final (isPartialScore=false); winner placement is * provisional (isPartialScore=true) reflecting their guaranteed minimum finish. * * @param skipSideEffects - When true, skips standings recalc and probability update. * Use this when processing multiple matches in a batch (call side effects once after * the loop instead of once per match). */ export async function processMatchResult( params: { round: string; winnerId: string; loserId: string; /** isScoring ?? true: default to true for legacy rows that pre-date the isScoring column. */ isScoring: boolean; sportsSeasonId: string; bracketTemplateId?: string | null; eventId?: string; eventName?: string; /** When set, Discord notification only shows this match (not all completed matches for the event). */ matchId?: string; skipSideEffects?: boolean; }, providedDb?: ReturnType ): Promise { const db = providedDb || database(); const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects } = params; if (!isScoring) { // Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts). // Winner only banks provisional T5–T8 floor if they're entering the first // scoring round (guaranteed top-8). For multi-round pre-bracket sequences // like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners // should receive floor points. await upsertParticipantResult(loserId, sportsSeasonId, 0, db); if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) { await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); } } else { const config = getRoundConfig(round, bracketTemplateId); if (config === null) { // Unrecognized scoring round: loser earns 0 pts, winner gets fallback T5–T8 floor. logger.warn( `[ScoringCalculator] processMatchResult: unrecognized round "${round}". Loser receives 0 pts.` ); await upsertParticipantResult(loserId, sportsSeasonId, 0, db); await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); } else if (config.winnerFloor === null) { // Finalization round: winner and loser both get their exact positions. await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db); await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerPosition ?? 1, db); } else { // All other scoring rounds: loser gets final (or provisional) position, winner gets floor. await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial); await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true); } } if (!skipSideEffects) { const sideEffectOptions = (eventName || eventId) ? { eventName, eventId, matchIds: matchId ? [matchId] : undefined } : undefined; await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions); try { await updateProbabilitiesAfterResult(sportsSeasonId, true); } catch (error) { logger.error( `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`, error ); } } } /** * Helper to upsert participant result * * isPartialScore=true means the participant is still alive and this placement * is their guaranteed minimum floor — it will be replaced as they advance or * when they are finally eliminated. */ async function upsertParticipantResult( participantId: string, sportsSeasonId: string, finalPosition: number, db: ReturnType, isPartialScore = false ): Promise { const existing = await db.query.participantResults.findFirst({ where: and( eq(schema.participantResults.participantId, participantId), eq(schema.participantResults.sportsSeasonId, sportsSeasonId) ), }); if (existing) { // Never un-finalize: if the row is already finalized (isPartialScore=false), // a call with isPartialScore=true must not overwrite it (e.g. a re-run of // processPlayoffEvent after a manual finalization). if (!existing.isPartialScore && isPartialScore) return; await db .update(schema.participantResults) .set({ finalPosition, isPartialScore, updatedAt: new Date(), }) .where(eq(schema.participantResults.id, existing.id)); } else { await db.insert(schema.participantResults).values({ participantId, sportsSeasonId, finalPosition, isPartialScore, }); } } /** * Returns the guaranteed minimum final position for winners of a given round. * * This is the worst-case placement a participant can achieve if they lose * every remaining match from this point forward. Returns null when: * - The round is non-scoring (winners haven't reached a points-paying zone) * - The round is the Finals (winner already finalized as 1st place inline) */ export function getGuaranteedMinimumPosition( round: string, bracketTemplateId: string | null | undefined, isScoring: boolean ): number | null { if (!isScoring) return null; const config = getRoundConfig(round, bracketTemplateId); if (config === null) return 5; // Unknown scoring round: fallback to T5–T8 floor return config.winnerFloor; // null for Finals; actual floor for all other rounds } /** * Process a qualifying event completion and update QP totals. * Ties in QP are handled by sharing placements (averaged points). */ export async function processQualifyingEvent( eventId: string, providedDb?: ReturnType ): Promise { const db = providedDb || database(); // Import qualifying points functions const { getEventResults } = await import("./event-result"); const { getQPForPlacement, recalculateParticipantQP } = await import("./qualifying-points"); // Get the event const event = await db.query.scoringEvents.findFirst({ where: eq(schema.scoringEvents.id, eventId), with: { sportsSeason: true, }, }); if (!event) { throw new Error(`Event ${eventId} not found`); } if (!event.isQualifyingEvent) { throw new Error(`Event ${eventId} is not a qualifying event`); } // Get all event results for this qualifying event const results = await getEventResults(eventId, db); // Check if this was already processed (for majorsCompleted counter) const wasAlreadyProcessed = results.some(r => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0); // Group results by placement to handle ties const placementGroups = new Map(); for (const result of results) { if (result.placement) { const group = placementGroups.get(result.placement) || []; group.push(result); placementGroups.set(result.placement, group); } } // Process each placement group and update event_results with QP awarded for (const [placement, group] of placementGroups) { const tieCount = group.length; // Calculate total QP for the positions this group occupies // A tie for Nth place occupies positions N through N + tieCount - 1 let totalQP = 0; for (let pos = placement; pos < placement + tieCount; pos++) { const qpForPos = await getQPForPlacement(event.sportsSeasonId, pos, db); totalQP += qpForPos; } // Divide equally among tied participants const qpPerParticipant = totalQP / tieCount; // Update the event result with the QP awarded for (const result of group) { await db .update(schema.eventResults) .set({ qualifyingPointsAwarded: qpPerParticipant.toString(), updatedAt: new Date(), }) .where(eq(schema.eventResults.id, result.id)); } } // Recalculate totals for all participants from scratch // This is the source of truth - sums all their event_results const participantIds = new Set(results.map(r => r.participantId)); for (const participantId of participantIds) { await recalculateParticipantQP(participantId, event.sportsSeasonId, db); } // Increment majorsCompleted counter (only if this is the first time processing) if (!wasAlreadyProcessed) { const sportsSeason = event.sportsSeason; await db .update(schema.sportsSeasons) .set({ majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1, updatedAt: new Date(), }) .where(eq(schema.sportsSeasons.id, event.sportsSeasonId)); } logger.log( `[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants` ); } /** * Finalize qualifying points and convert to fantasy placements. * Called manually by admin when all events are complete. * Tied QP totals result in shared placements (averaged points). */ export async function finalizeQualifyingPoints( sportsSeasonId: string, providedDb?: ReturnType ): Promise { const db = providedDb || database(); // Import qualifying points functions const { getQPStandings, updateFinalRankings } = await import("./qualifying-points"); // Get all participants ranked by total QP (highest first) const standings = await getQPStandings(sportsSeasonId, db); if (standings.length === 0) { throw new Error(`No qualifying points standings found for sports season ${sportsSeasonId}`); } // Update final rankings in participant_qualifying_totals await updateFinalRankings(sportsSeasonId, db); // Group participants by QP total to handle ties (Q5, Q19) const groupedByQP = new Map(); for (const standing of standings) { const qp = standing.totalQualifyingPoints; if (!groupedByQP.has(qp)) { groupedByQP.set(qp, []); } groupedByQP.get(qp)?.push(standing); } // Sort groups by QP (descending) const sortedGroups = Array.from(groupedByQP.entries()).toSorted((a, b) => { return parseFloat(b[0]) - parseFloat(a[0]); }); // Assign fantasy placements (1-8) based on QP ranking let currentPlacement = 1; for (const [_, group] of sortedGroups) { // Stop if we've gone beyond the top 8 fantasy placements if (currentPlacement > 8) break; const participantsInGroup = group.length; // Calculate how many placements this group shares const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1); const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1; // Assign placements to participants in this group for (let i = 0; i < Math.min(participantsInGroup, actualParticipantsScoring); i++) { const standing = group[i]; await upsertParticipantResult( standing.participantId, sportsSeasonId, currentPlacement, db ); } // If there are more participants than scoring positions, assign 0 to the rest for (let i = actualParticipantsScoring; i < participantsInGroup; i++) { const standing = group[i]; await upsertParticipantResult( standing.participantId, sportsSeasonId, 0, // Beyond top 8 db ); } // Move to next placement group currentPlacement += participantsInGroup; } // Assign 0 points to any remaining participants beyond top 8 if (currentPlacement <= standings.length) { for (let i = currentPlacement - 1; i < standings.length; i++) { const standing = standings[i]; const hasResult = await db.query.participantResults.findFirst({ where: and( eq(schema.participantResults.participantId, standing.participantId), eq(schema.participantResults.sportsSeasonId, sportsSeasonId) ), }); if (!hasResult) { await upsertParticipantResult( standing.participantId, sportsSeasonId, 0, db ); } } } // Mark sports season as finalized await db .update(schema.sportsSeasons) .set({ qualifyingPointsFinalized: true, updatedAt: new Date(), }) .where(eq(schema.sportsSeasons.id, sportsSeasonId)); // Trigger recalculation for all affected leagues await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" }); // Auto-trigger probability recalculation after result try { await updateProbabilitiesAfterResult(sportsSeasonId, true); logger.log( `[ScoringCalculator] Auto-updated probabilities for sports season ${sportsSeasonId}` ); } catch (error) { logger.error( `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`, error ); } logger.log( `[ScoringCalculator] Finalized qualifying points for sports season ${sportsSeasonId}: ${standings.length} participants processed` ); } /** * Process season standings (auto racing: F1, IndyCar, etc.) and assign final placements. * Reads from participant_season_results (championship points) and converts * final standings positions to fantasy placements. Ties share placements. */ export async function processSeasonStandings( sportsSeasonId: string, providedDb?: ReturnType ): Promise { const db = providedDb || database(); // Get all season results for this sports season, sorted by standings const seasonResults = await getSeasonResults(sportsSeasonId, db); // Group participants by position to handle ties const positionGroups: Record = {}; for (const result of seasonResults) { const position = result.currentPosition; if (position === null) continue; // Skip participants without a position if (!positionGroups[position]) { positionGroups[position] = []; } positionGroups[position].push(result.participantId); } // Get sorted positions const positions = Object.keys(positionGroups) .map(Number) .toSorted((a, b) => a - b); // Assign fantasy placements (1-8) based on standings let currentPlacement = 1; for (const position of positions) { const participantIds = positionGroups[position]; // Stop if we've gone beyond the top 8 fantasy placements if (currentPlacement > 8) break; // Calculate how many placements this group shares const participantsInGroup = participantIds.length; // If this group would exceed position 8, only some participants get points const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1); const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1; // Determine if we should assign placements to this group if (currentPlacement <= 8) { // All participants tied at this position get the first placement in the range // (e.g., if 4 people tie for 5th place, they all get placement 5) // The scoring system will handle averaging the points for positions 5, 6, 7, 8 for (let i = 0; i < Math.min(participantIds.length, actualParticipantsScoring); i++) { const participantId = participantIds[i]; await upsertParticipantResult( participantId, sportsSeasonId, currentPlacement, db ); } // If there are more participants than scoring positions, assign 0 to the rest for (let i = actualParticipantsScoring; i < participantIds.length; i++) { const participantId = participantIds[i]; await upsertParticipantResult( participantId, sportsSeasonId, 0, // Beyond top 8 db ); } } else { // Position is beyond top 8, assign 0 points for (const participantId of participantIds) { await upsertParticipantResult( participantId, sportsSeasonId, 0, db ); } } // Move to next placement group currentPlacement += participantsInGroup; } // Trigger recalculation for all affected leagues await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" }); // Auto-trigger probability recalculation after result try { await updateProbabilitiesAfterResult(sportsSeasonId, true); logger.log( `[ScoringCalculator] Auto-updated probabilities for sports season ${sportsSeasonId}` ); } catch (error) { logger.error( `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`, error ); } logger.log( `[ScoringCalculator] Processed season standings for sports season ${sportsSeasonId}` ); } /** * Calculate total fantasy points for a team in a specific season * Sums points from all drafted participants based on their final placements */ export async function calculateTeamScore( teamId: string, seasonId: string, providedDb?: ReturnType ): Promise<{ totalPoints: number; placementCounts: Record; participantsCompleted: number; participantsTotal: number; }> { const db = providedDb || database(); // Get season scoring rules const scoringRules = await getScoringRules(seasonId, db); if (!scoringRules) { throw new Error(`Season ${seasonId} not found`); } // Get all draft picks for this team const picks = await db.query.draftPicks.findMany({ where: and( eq(schema.draftPicks.teamId, teamId), eq(schema.draftPicks.seasonId, seasonId) ), with: { participant: { with: { results: true, sportsSeason: true, }, }, }, }); let totalPoints = 0; const placementCounts: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, }; let participantsCompleted = 0; // Cache bracket template IDs per sports season (one extra query per unique bracket season). // Needed to determine the correct scoring tier structure (e.g. AFL splits 5–8 into two pairs). const bracketTemplateCache = new Map(); async function getBracketTemplate(sportsSeasonId: string): Promise { if (bracketTemplateCache.has(sportsSeasonId)) { return bracketTemplateCache.get(sportsSeasonId) ?? null; } const event = await db.query.scoringEvents.findFirst({ where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), columns: { bracketTemplateId: true }, }); const templateId = event?.bracketTemplateId ?? null; bracketTemplateCache.set(sportsSeasonId, templateId); return templateId; } for (const pick of picks) { // One result per participant per sports season (enforced by upsertParticipantResult). // If duplicates exist due to data corruption, the first row wins — acceptable trade-off. const result = pick.participant.results[0]; if (result && result.finalPosition !== null && result.finalPosition > 0) { const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket"; let points: number; if (isBracket) { const templateId = await getBracketTemplate(pick.participant.sportsSeasonId); points = calculateBracketPoints(result.finalPosition, scoringRules, templateId); } else { points = calculateFantasyPoints(result.finalPosition, scoringRules); } totalPoints += points; if (!result.isPartialScore) { // Only fully finalized participants count toward completed and placement tiebreakers participantsCompleted++; if (result.finalPosition >= 1 && result.finalPosition <= 8) { placementCounts[result.finalPosition]++; } } } else if (result && !result.isPartialScore) { // Finalized with no scoring position (e.g. eliminated in a non-scoring round) — still counts as done participantsCompleted++; } } return { totalPoints, placementCounts, participantsCompleted, participantsTotal: picks.length, }; } /** * Calculate projected total points for a team. * Combines actual points from finished participants with EV projections for the rest. */ export async function calculateTeamProjectedScore( teamId: string, seasonId: string, providedDb?: ReturnType ): Promise<{ actualPoints: number; projectedPoints: number; participantsFinished: number; participantsRemaining: number; }> { const db = providedDb || database(); // Get season scoring rules const scoringRules = await getScoringRules(seasonId, db); if (!scoringRules) { throw new Error(`Season ${seasonId} not found`); } // Get all draft picks for this team with their results and EVs const picks = await db.query.draftPicks.findMany({ where: and( eq(schema.draftPicks.teamId, teamId), eq(schema.draftPicks.seasonId, seasonId) ), with: { participant: { with: { results: true, sportsSeason: true, }, }, }, }); let actualPoints = 0; let participantsFinished = 0; const unfinishedParticipants: Array<{ participantId: string; sportsSeasonId: string; floorPoints: number }> = []; // Cache bracket template IDs per sports season const bracketTemplateCache = new Map(); async function getBracketTemplate(sportsSeasonId: string): Promise { if (bracketTemplateCache.has(sportsSeasonId)) { return bracketTemplateCache.get(sportsSeasonId) ?? null; } const event = await db.query.scoringEvents.findFirst({ where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), columns: { bracketTemplateId: true }, }); const templateId = event?.bracketTemplateId ?? null; bracketTemplateCache.set(sportsSeasonId, templateId); return templateId; } // Separate finished vs unfinished participants for (const pick of picks) { const result = pick.participant.results[0]; const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket"; 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 { points = calculateFantasyPoints(result.finalPosition, scoringRules); } actualPoints += points; 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; const floorPoints = isBracket ? calculateBracketPoints(result.finalPosition, scoringRules, templateId) : calculateFantasyPoints(result.finalPosition, scoringRules); actualPoints += floorPoints; // EV already accounts for their full projected value, so subtract floor to avoid // double-counting when we do actualPoints + evSum below unfinishedParticipants.push({ participantId: pick.participant.id, sportsSeasonId: pick.participant.sportsSeasonId, floorPoints, }); } else { // Participant is unfinished - will need EV unfinishedParticipants.push({ participantId: pick.participant.id, sportsSeasonId: pick.participant.sportsSeasonId, floorPoints: 0, }); } } // Get EVs for unfinished participants let evSum = 0; if (unfinishedParticipants.length > 0) { // Import the participant EV model const { getParticipantEV } = await import("./participant-expected-value"); const { calculateEV } = await import("~/services/ev-calculator"); for (const { participantId, sportsSeasonId, floorPoints } of unfinishedParticipants) { const ev = await getParticipantEV(participantId, sportsSeasonId); if (ev) { // EV is already calculated with default scoring (100/70/50/40/25/25/15/15) // We need to recalculate with THIS league's scoring rules const probabilities = { probFirst: parseFloat(ev.probFirst), probSecond: parseFloat(ev.probSecond), probThird: parseFloat(ev.probThird), probFourth: parseFloat(ev.probFourth), probFifth: parseFloat(ev.probFifth), probSixth: parseFloat(ev.probSixth), probSeventh: parseFloat(ev.probSeventh), probEighth: parseFloat(ev.probEighth), }; const leagueSpecificEV = calculateEV(probabilities, scoringRules); // For partial-score participants, floor is already in actualPoints — add only the // incremental EV above the floor to avoid double-counting in projectedPoints. // For pending participants (floorPoints=0) this is just the full EV. // Math.max guards against the rare case where EV drops below the floor. evSum += Math.max(0, leagueSpecificEV - floorPoints); } // If no EV data exists, assume 0 additional projected points } } const projectedPoints = actualPoints + evSum; return { actualPoints: Math.round(actualPoints * 100) / 100, projectedPoints: Math.round(projectedPoints * 100) / 100, participantsFinished, participantsRemaining: unfinishedParticipants.length, }; } /** * Compare two teams for ranking purposes using tiebreaker logic * Returns: negative if teamA ranks higher, positive if teamB ranks higher, 0 if tied * * Tiebreaker order: * 1. Total points (higher is better) * 2. Number of 1st place finishes (higher is better) * 3. Number of 2nd place finishes (higher is better) * 4. Continue through all placements (3rd, 4th, ..., 8th) */ function compareTeamsForRanking( teamA: { totalPoints: number; placementCounts: Record; }, teamB: { totalPoints: number; placementCounts: Record; } ): number { // First compare by total points (descending), rounded to hundredths to avoid // floating point noise causing spurious non-ties in the display. const roundedA = Math.round(teamA.totalPoints * 100) / 100; const roundedB = Math.round(teamB.totalPoints * 100) / 100; if (roundedA !== roundedB) { return roundedB - roundedA; } // If points are tied, apply tiebreaker cascade for (let placement = 1; placement <= 8; placement++) { const countA = teamA.placementCounts[placement] || 0; const countB = teamB.placementCounts[placement] || 0; if (countA !== countB) { return countB - countA; // More of this placement is better } } // Completely tied return 0; } /** * Assign ranks to teams based on their scores and tiebreakers * Teams with identical scores and placements will share the same rank */ function assignRanks( teams: Array<{ teamId: string; totalPoints: number; placementCounts: Record; }> ): Map { // Sort teams by ranking criteria const sorted = [...teams].toSorted(compareTeamsForRanking); const ranks = new Map(); let currentRank = 1; for (let i = 0; i < sorted.length; i++) { const team = sorted[i]; // Check if this team is tied with the previous team if (i > 0) { const prevTeam = sorted[i - 1]; const comparison = compareTeamsForRanking(team, prevTeam); if (comparison !== 0) { // Not tied, advance rank currentRank = i + 1; } // If comparison === 0, keep the same rank (tie) } ranks.set(team.teamId, currentRank); } return ranks; } /** * Recalculate standings for all teams in a season. * Called after any scoring event completes or participant results change. * Implements tiebreaker logic: total points, then placement counts (1st, 2nd, …). */ export async function recalculateStandings( seasonId: string, providedDb?: ReturnType ): Promise { const db = providedDb || database(); const teams = await db.query.teams.findMany({ where: eq(schema.teams.seasonId, seasonId), }); // Calculate scores for all teams const teamScores = await Promise.all( teams.map(async (team) => { const score = await calculateTeamScore(team.id, seasonId, db); const projected = await calculateTeamProjectedScore(team.id, seasonId, db); return { teamId: team.id, totalPoints: score.totalPoints, placementCounts: score.placementCounts, participantsCompleted: score.participantsCompleted, participantsTotal: score.participantsTotal, actualPoints: projected.actualPoints, projectedPoints: projected.projectedPoints, participantsFinished: projected.participantsFinished, }; }) ); // Assign ranks based on tiebreaker logic const ranks = assignRanks(teamScores); // Update team standings with scores and ranks for (const teamScore of teamScores) { const newRank = ranks.get(teamScore.teamId) || 1; // Get existing standing to preserve previousRank const existing = await db.query.teamStandings.findFirst({ where: and( eq(schema.teamStandings.teamId, teamScore.teamId), eq(schema.teamStandings.seasonId, seasonId) ), }); const standingData = { totalPoints: teamScore.totalPoints.toString(), currentRank: newRank, previousRank: existing ? existing.currentRank : null, firstPlaceCount: teamScore.placementCounts[1], secondPlaceCount: teamScore.placementCounts[2], thirdPlaceCount: teamScore.placementCounts[3], fourthPlaceCount: teamScore.placementCounts[4], fifthPlaceCount: teamScore.placementCounts[5], sixthPlaceCount: teamScore.placementCounts[6], seventhPlaceCount: teamScore.placementCounts[7], eighthPlaceCount: teamScore.placementCounts[8], participantsRemaining: teamScore.participantsTotal - teamScore.participantsCompleted, actualPoints: teamScore.actualPoints?.toString() ?? null, projectedPoints: teamScore.projectedPoints?.toString() ?? null, participantsFinished: teamScore.participantsFinished ?? null, calculatedAt: new Date(), }; if (existing) { await db .update(schema.teamStandings) .set(standingData) .where(eq(schema.teamStandings.id, existing.id)); } else { await db.insert(schema.teamStandings).values({ teamId: teamScore.teamId, seasonId, ...standingData, }); } } logger.log(`[ScoringCalculator] Recalculated standings for season ${seasonId}: ${teams.length} teams ranked`); } /** * Recalculate standings for all leagues that include a specific sports season * Called after a sports season event completes */ export async function recalculateAffectedLeagues( sportsSeasonId: string, providedDb?: ReturnType, options?: { eventName?: string; eventId?: string; matchIds?: string[] } ): Promise { const db = providedDb || database(); // Get all fantasy seasons that include this sports season const seasonSports = await db.query.seasonSports.findMany({ where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId), }); const seasonIds = seasonSports.map((ss) => ss.seasonId); // Look up sport name for the notification header const sportsSeason = await db.query.sportsSeasons.findFirst({ where: eq(schema.sportsSeasons.id, sportsSeasonId), with: { sport: true }, }); const sportName = sportsSeason?.sport?.name; // Look up matches for the Discord notification. // When matchIds is provided, only those specific matches are shown (e.g. current batch). // Falling back to all completed matches for the event would include prior rounds. let allCompletedMatches: Array<{ winnerId: string | null; loserId: string | null; winnerName: string | null; loserName: string | null; }> = []; if (options?.eventId) { const whereClause = options.matchIds?.length ? inArray(schema.playoffMatches.id, options.matchIds) : and( eq(schema.playoffMatches.scoringEventId, options.eventId), eq(schema.playoffMatches.isComplete, true) ); const matches = await db.query.playoffMatches.findMany({ where: whereClause, with: { winner: true, loser: true, }, }); allCompletedMatches = matches .filter((m) => m.winnerId && m.loserId) .map((m) => ({ winnerId: m.winnerId, loserId: m.loserId, winnerName: m.winner?.name ?? null, loserName: m.loser?.name ?? null, })); } // Recalculate each affected season for (const seasonId of seasonIds) { // Capture standings before recalculation for delta calculation const beforeStandings = await db.query.teamStandings.findMany({ where: eq(schema.teamStandings.seasonId, seasonId), with: { team: true }, }); const previousPointsById = new Map( beforeStandings.map((s) => [s.teamId, parseFloat(s.totalPoints)]) ); const previousRanksById = new Map( beforeStandings.map((s) => [s.teamId, s.currentRank]) ); await recalculateStandings(seasonId, db); try { await createDailySnapshot(seasonId, db); } catch (err) { logger.error(`[ScoringCalculator] Snapshot failed for season ${seasonId}:`, err); } // Check if any team's score changed — if so, send Discord notification const afterStandings = await db.query.teamStandings.findMany({ where: eq(schema.teamStandings.seasonId, seasonId), with: { team: true }, orderBy: (ts, { asc }) => [asc(ts.currentRank)], }); const changedTeamIds = new Set( afterStandings .filter((s) => { const prev = previousPointsById.get(s.teamId); return prev === undefined || prev !== parseFloat(s.totalPoints); }) .map((s) => s.teamId) ); const hasChanges = changedTeamIds.size > 0; // Look up the league's Discord webhook URL via the season const season = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId), with: { league: true }, }); const webhookUrl = season?.league?.discordWebhookUrl; if (!webhookUrl) continue; // Build clerkId → username map for all team owners in this season const ownerClerkIds = afterStandings .map((s) => s.team.ownerId) .filter((id): id is string => id !== null && id !== undefined); const teamOwnerUsers = ownerClerkIds.length ? await db.query.users.findMany({ where: inArray(schema.users.clerkId, ownerClerkIds), }) : []; const usernameByClerkId = new Map( teamOwnerUsers .map((u) => [u.clerkId, getUserDisplayName(u)]) .filter((entry): entry is [string, string] => entry[1] !== null) ); // Build teamId → ownerId map from standings data const ownerIdByTeamId = new Map( afterStandings.map((s) => [s.teamId, s.team.ownerId]) ); // Build scored matches for the notification — computed before the gate // because an elimination (0 points, no score change) still warrants a // notification when a drafted participant is knocked out. let scoredMatches: ScoredMatch[] | undefined; if (allCompletedMatches.length > 0) { const draftPicks = await db.query.draftPicks.findMany({ where: eq(schema.draftPicks.seasonId, seasonId), }); const draftedIds = new Set(draftPicks.map((p) => p.participantId)); const relevant = allCompletedMatches.filter( (m) => (m.winnerId && draftedIds.has(m.winnerId)) || (m.loserId && draftedIds.has(m.loserId)) ); if (relevant.length > 0) { const teamIdByParticipantId = new Map( draftPicks.map((p) => [p.participantId, p.teamId]) ); // Look up the fantasy owner's display name for a participant. // Pass requireScoreChange=true for the winner to avoid surfacing wins // that didn't award points this round. const usernameForParticipant = ( participantId: string | null | undefined, requireScoreChange = false ) => { if (!participantId) return undefined; const teamId = teamIdByParticipantId.get(participantId); if (!teamId) return undefined; if (requireScoreChange && !changedTeamIds.has(teamId)) return undefined; const ownerId = ownerIdByTeamId.get(teamId); if (!ownerId) return undefined; return usernameByClerkId.get(ownerId); }; scoredMatches = relevant .filter((m) => m.winnerName && m.loserName) .map((m) => ({ winnerName: m.winnerName ?? "", loserName: m.loserName ?? "", winnerUsername: usernameForParticipant(m.winnerId, true), loserUsername: usernameForParticipant(m.loserId), })); } } // Skip notification if scores didn't change AND no match has a displayable username. const hasScoredMatchesToShow = scoredMatches?.some( (m) => m.winnerUsername !== undefined || m.loserUsername !== undefined ); if (!hasChanges && !hasScoredMatchesToShow) continue; const standings = afterStandings.map((s) => ({ teamId: s.teamId, teamName: s.team.name, username: usernameByClerkId.get(s.team.ownerId ?? ""), totalPoints: parseFloat(s.totalPoints), rank: s.currentRank, })); try { await sendStandingsUpdateNotification({ webhookUrl, seasonName: `${season.league.name} ${season.year}`, standings, previousStandings: previousPointsById, previousRanks: previousRanksById, sportName, eventName: options?.eventName, scoredMatches, }); } catch (err) { // Log but don't fail the scoring pipeline if Discord is unreachable logger.error(`[ScoringCalculator] Discord notification failed for season ${seasonId}:`, err); } } logger.log( `[ScoringCalculator] Recalculated ${seasonIds.length} affected season(s) for sports season ${sportsSeasonId}` ); }