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"; /** * 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 // 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 ); } } } // Otherwise, process scoring rounds based on standard playoff logic else if (round === "Finals" || round === "Championship" || round === "Super Bowl" || round === "NBA Finals") { // 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") { // 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); } /** * 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 * Implementation will be added in Phase 3 */ export async function processQualifyingEvent( eventId: string, providedDb?: ReturnType ): Promise { const db = providedDb || database(); // TODO: Implement in Phase 3 // 1. Get event results // 2. Award qualifying points based on placement // 3. Update participant_qualifying_totals // 4. Check if all majors complete // 5. Update QP standings displays console.log(`[ScoringCalculator] processQualifyingEvent not yet implemented: ${eventId}`); } /** * Finalize qualifying points and convert to fantasy placements * Called manually by admin when all majors are complete * Implementation will be added in Phase 3 */ export async function finalizeQualifyingPoints( sportsSeasonId: string, providedDb?: ReturnType ): Promise { const db = providedDb || database(); // TODO: Implement in Phase 3 // 1. Get all participants ranked by total QP // 2. Handle ties in QP (share placements) // 3. Assign final placements (1-8) based on QP ranking // 4. Update participantResults with final placements // 5. Mark sports season as finalized // 6. Trigger recalculation for all affected leagues console.log( `[ScoringCalculator] finalizeQualifyingPoints not yet implemented: ${sportsSeasonId}` ); } /** * 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); 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, }; } /** * Recalculate standings for all teams in a season * Called after any scoring event completes or participant results change * Implementation will be completed in Phase 4 */ export async function recalculateStandings( seasonId: string, providedDb?: ReturnType ): Promise { const db = providedDb || database(); // TODO: Complete implementation in Phase 4 // For now, calculate basic scores without rankings const teams = await db.query.teams.findMany({ where: eq(schema.teams.seasonId, seasonId), }); for (const team of teams) { const teamScore = await calculateTeamScore(team.id, seasonId, db); // Upsert team standings const existing = await db.query.teamStandings.findFirst({ where: and( eq(schema.teamStandings.teamId, team.id), eq(schema.teamStandings.seasonId, seasonId) ), }); if (existing) { await db .update(schema.teamStandings) .set({ totalPoints: teamScore.totalPoints.toString(), 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, calculatedAt: new Date(), }) .where(eq(schema.teamStandings.id, existing.id)); } else { await db.insert(schema.teamStandings).values({ teamId: team.id, seasonId, totalPoints: teamScore.totalPoints.toString(), currentRank: 1, // Will be updated when we add ranking logic 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, }); } } console.log(`[ScoringCalculator] Recalculated standings for season ${seasonId}`); } /** * 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}` ); }