import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, inArray } from "drizzle-orm"; import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints } 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 = | "playoff_bracket" | "season_standings" | "qualifying_points"; /** * 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 ): 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}`); } // Non-scoring rounds: losers are pre-bracket eliminated (0 pts). // Winners have cleared the qualifier and are guaranteed a top-8 finish, // so we bank position 5 (T5–T8 floor) as their provisional score immediately. // Using a fixed value of 5 is correct for standard brackets (R16 winners → QF → T5 worst case) // and conservatively safe for non-standard cases (never over-promises points). if (!isScoring) { for (const match of matches) { if (match.loserId) { await upsertParticipantResult( match.loserId, event.sportsSeasonId, 0, // 0 = pre-bracket elimination, earns no points db ); } if (match.winnerId) { await upsertParticipantResult( match.winnerId, event.sportsSeasonId, 5, // provisional T5–T8 floor: guaranteed top-8 finish db, true ); } } } // AFL-specific rounds (afl_10 bracket template) else if (round === "Qualifying Finals") { // AFL: Qualifying Finals losers get a second chance via Semi-Finals. // They are not eliminated here, but earn a guaranteed minimum of 5th-6th. // Winners are handled by the general guaranteed-minimum loop below (3rd-4th). for (const match of matches) { if (match.loserId) { await upsertParticipantResult( match.loserId, event.sportsSeasonId, 5, // Guaranteed minimum: at worst 5th-6th via Semi-Finals db, true // isPartialScore: still competing ); } } } 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 (finalized, isPartialScore=false) await upsertParticipantResult( finalMatch.winnerId, event.sportsSeasonId, 1, db ); // Update or create participant results for loser (finalized, isPartialScore=false) 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 ); } } } else { // Unrecognized scoring round: losers are outside the top-8 and earn 0 pts. // This handles rounds like "Round of 16", "Round of 32" when isScoring=true, // or any custom round name not explicitly mapped above. console.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 ); } } } // Progressive floor scoring: assign guaranteed minimum points to winners. // Winners of any scoring round (except Finals, which already finalize both // participants above) earn provisional points reflecting the worst-case // placement they can achieve from here. These update as they advance. 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, score will increase as they advance ); } } } // Recalculate standings for all affected leagues await recalculateAffectedLeagues(event.sportsSeasonId, db); // Auto-trigger probability recalculation after result 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 * * 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 (winners are already being finalized as 1st place) */ export function getGuaranteedMinimumPosition( round: string, bracketTemplateId: string | null | undefined, isScoring: boolean ): number | null { // Non-scoring rounds: winning doesn't guarantee any points yet if (!isScoring) return null; // Finals: winner is finalized as 1st — handled inline, not via this helper if ( round === "Finals" || round === "Championship" || round === "Super Bowl" || round === "NBA Finals" || round === "Grand Final" ) { return null; } // AFL-specific routing (afl_10 bracket template) if (bracketTemplateId === "afl_10") { // Qualifying Finals: winners → Prelim Finals (losers there get 3rd-4th) if (round === "Qualifying Finals") return 3; // Elimination Finals: winners → Semi-Finals (losers there get 5th-6th) if (round === "Elimination Finals") return 5; // Semi-Finals: winners → Prelim Finals (losers there get 3rd-4th) if (round === "Semi-Finals") return 3; } // Standard bracket rounds // Semi-final winners advance to the Final — guaranteed at worst 2nd place if ( round === "Semifinals" || round === "Final Four" || round === "Conference Finals" || round === "Conference Championship" || round === "Preliminary Finals" ) { return 2; } // Quarterfinal winners advance to Semis — guaranteed at worst 3rd-4th if ( round === "Quarterfinals" || round === "Elite Eight" || round === "Divisional" || round === "Conference Semifinals" ) { return 3; } // All other scoring rounds (e.g. Round of 16, Round of 32, etc.) — // winners are now guaranteed a top-8 finish at worst (position 5) return 5; } /** * 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)); } 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 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()).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 after result 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 (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) .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 after result 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, 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; 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) { const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket"; const points = isBracket ? calculateBracketPoints(result.finalPosition, scoringRules) : 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. * 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 }> = []; // 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. * 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, }); } } 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}` ); }