import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, inArray } from "drizzle-orm"; import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints } from "./scoring-rules"; import { getSeasonResults } from "./participant-season-result"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; /** * Core scoring calculation engine * This file contains the logic for calculating fantasy points based on participant results */ export type ScoringPattern = | "single_elimination_playoff" | "page_playoff" | "season_standings" | "qualifying_points"; /** * Process a playoff event completion and assign final placements * * Q19: Admin enters the same placement for all tied participants, * system automatically shares positions * * Q20: Participants eliminated before Elite Eight get 0 points * * Phase 2.7: Updated to support template-based brackets with non-scoring rounds */ export async function processPlayoffEvent( eventId: string, providedDb?: ReturnType ): 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: (matches, { asc }) => [asc(matches.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 this is a non-scoring round, award 0 points (Q20) if (!isScoring) { for (const match of matches) { if (match.loserId) { await upsertParticipantResult( match.loserId, event.sportsSeasonId, 0, // 0 placement = 0 points for early elimination db ); } } } // AFL-specific rounds (Phase 3.3) else if (round === "Elimination Finals") { // AFL: Elimination Finals losers share 7th-8th for (const match of matches) { if (match.loserId) { await upsertParticipantResult( match.loserId, event.sportsSeasonId, 7, // Store as placement 7 (first shared placement for 7-8) db ); } } } else if (round === "Semi-Finals" && event.bracketTemplateId === "afl_10") { // AFL: Semi-Finals losers share 5th-6th for (const match of matches) { if (match.loserId) { await upsertParticipantResult( match.loserId, event.sportsSeasonId, 5, // Store as placement 5 (first shared placement for 5-6) db ); } } } // Standard playoff rounds else if (round === "Finals" || round === "Championship" || round === "Super Bowl" || round === "NBA Finals" || round === "Grand Final") { // Winner gets 1st, loser gets 2nd const finalMatch = matches[0]; if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) { throw new Error("Finals match is not complete"); } // Update or create participant results for winner await upsertParticipantResult( finalMatch.winnerId, event.sportsSeasonId, 1, db ); // Update or create participant results for loser await upsertParticipantResult( finalMatch.loserId, event.sportsSeasonId, 2, db ); } else if (round === "Semifinals" || round === "Final Four" || round === "Conference Finals" || round === "Conference Championship" || round === "Preliminary Finals") { // Losers share 3rd/4th for (const match of matches) { if (match.loserId) { await upsertParticipantResult( match.loserId, event.sportsSeasonId, 3, // Store as placement 3 (the first shared placement) db ); } } } else if (round === "Quarterfinals" || round === "Elite Eight" || round === "Divisional" || round === "Conference Semifinals") { // Losers share 5th-8th for (const match of matches) { if (match.loserId) { await upsertParticipantResult( match.loserId, event.sportsSeasonId, 5, // Store as placement 5 (the first shared placement) db ); } } } // Recalculate standings for all affected leagues await recalculateAffectedLeagues(event.sportsSeasonId, db); // Auto-trigger probability recalculation (Phase 5.3) try { await updateProbabilitiesAfterResult(event.sportsSeasonId, true); console.log( `[ScoringCalculator] Auto-updated probabilities for sports season ${event.sportsSeasonId}` ); } catch (error) { console.error( `[ScoringCalculator] Failed to auto-update probabilities for sports season ${event.sportsSeasonId}:`, error ); } } /** * Helper to upsert participant result */ async function upsertParticipantResult( participantId: string, sportsSeasonId: string, finalPosition: number, db: ReturnType ): Promise { const existing = await db.query.participantResults.findFirst({ where: and( eq(schema.participantResults.participantId, participantId), eq(schema.participantResults.sportsSeasonId, sportsSeasonId) ), }); if (existing) { await db .update(schema.participantResults) .set({ finalPosition, updatedAt: new Date(), }) .where(eq(schema.participantResults.id, existing.id)); } else { await db.insert(schema.participantResults).values({ participantId, sportsSeasonId, finalPosition, }); } } /** * Process a qualifying event completion and update QP totals * Phase 3.2 Implementation * * Q4: Manual finalization after all majors complete * Q5: Ties in QP handled by sharing placements */ 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)); } console.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 majors are complete * Phase 3.2 Implementation * * Q4: Manual finalization (admin button) * Q5: QP ties handled by sharing placements * Q19: Tie entry uses same placement, system auto-calculates shared positions */ 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()).sort((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); // Auto-trigger probability recalculation (Phase 5.3) try { await updateProbabilitiesAfterResult(sportsSeasonId, true); console.log( `[ScoringCalculator] Auto-updated probabilities for sports season ${sportsSeasonId}` ); } catch (error) { console.error( `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`, error ); } console.log( `[ScoringCalculator] Finalized qualifying points for sports season ${sportsSeasonId}: ${standings.length} participants processed` ); } /** * Process season standings (F1) and assign final placements * * Q7: For F1, we show current F1 points during the season, then assign fantasy points at the end * Q8: Just final standings, not individual races * Q14: Store current running total, manual update with API future * Q13/Q19: Ties handled by admin entering same position, system auto-shares 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) .sort((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); // Auto-trigger probability recalculation (Phase 5.3) try { await updateProbabilitiesAfterResult(sportsSeasonId, true); console.log( `[ScoringCalculator] Auto-updated probabilities for sports season ${sportsSeasonId}` ); } catch (error) { console.error( `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`, error ); } console.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, }, }, }, }); 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; for (const pick of picks) { const result = pick.participant.results[0]; // Should only be one result per participant if (result && result.finalPosition) { const points = calculateFantasyPoints(result.finalPosition, scoringRules); totalPoints += points; participantsCompleted++; // Track placement for tiebreakers if (result.finalPosition >= 1 && result.finalPosition <= 8) { placementCounts[result.finalPosition]++; } } } return { totalPoints, placementCounts, participantsCompleted, participantsTotal: picks.length, }; } /** * Calculate projected total points for a team * Phase 5.4: Includes actual points from finished participants + EVs from unfinished */ 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 }> = []; // Separate finished vs unfinished participants for (const pick of picks) { const result = pick.participant.results[0]; if (result && result.finalPosition !== null) { // Participant has finished - use actual points const points = calculateFantasyPoints(result.finalPosition, scoringRules); actualPoints += points; participantsFinished++; } else { // Participant is unfinished - will need EV unfinishedParticipants.push({ participantId: pick.participant.id, sportsSeasonId: pick.participant.sportsSeasonId, }); } } // 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 } 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); evSum += leagueSpecificEV; } // If no EV exists, assume 0 (participant hasn't been evaluated yet) } } 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) if (teamA.totalPoints !== teamB.totalPoints) { return teamB.totalPoints - teamA.totalPoints; } // 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].sort(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 * * Phase 4.1: Implements tiebreaker logic and ranking */ 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, // Phase 5.4: Projected points tracking 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, }); } } console.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 ): 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); // Recalculate each affected season for (const seasonId of seasonIds) { await recalculateStandings(seasonId, db); } console.log( `[ScoringCalculator] Recalculated ${seasonIds.length} affected season(s) for sports season ${sportsSeasonId}` ); }