import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, inArray } from "drizzle-orm"; import { getScoringRules, calculateFantasyPoints, calculateBracketPoints, calculateSharedPlacementPoints, } from "./scoring-rules"; import { getSeasonResults } from "./participant-season-result"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord"; import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server"; import { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates"; import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match"; import { getUserDisplayName } from "~/models/user"; import { findDiscordIdsByUserIds } from "~/models/account"; import { createDailySnapshot } from "~/models/standings"; import { recordMatchScoreEvents } from "~/models/team-score-events"; import { logger } from "~/lib/logger"; import { getEventResults } from "./event-result"; import { calculateSplitQualifyingPoints, diffChangedQualifyingPoints, getQPConfig, recalculateParticipantQP, writeEventResultsQP, getQPStandings, updateFinalRankings, } from "./qualifying-points"; import { getParticipantEV, type ParticipantEV, } from "./participant-expected-value"; import { calculateEV } from "~/services/ev-calculator"; /** * 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 }, }, tennis_128: { // R16 losers share 9th–16th; winner advances to QF (floor 5th–8th). "Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 }, // QF losers share 5th–8th; winner advances to SF (floor 3rd–4th). Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 }, // SF losers share 3rd–4th; winner advances to Final (floor 2nd). Semifinals: { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 }, // Final finalizes both: winner 1st, loser 2nd. Final: { loserPosition: 2, loserIsPartial: false, winnerFloor: null }, }, }; /** * 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. */ export 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; skipProbabilities?: 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; // Prefer template-defined isScoring over the DB field: the DB column defaults to // true, so legacy play-in rows that predate the column are incorrectly marked as // scoring and would assign wrong placements to losers who still have a second game. const bracketTemplate = event.bracketTemplateId ? BRACKET_TEMPLATES[event.bracketTemplateId] : undefined; const templateRoundDef = bracketTemplate?.rounds.find((r) => r.name === round); const isScoring = templateRoundDef !== undefined ? templateRoundDef.isScoring : (matches[0]?.isScoring ?? true); // PHASE 5.3: Handle teams that didn't make the playoffs // Get all participants in the sports season const allParticipants = await db.query.seasonParticipants.findMany({ where: eq(schema.seasonParticipants.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.seasonParticipantResults.findFirst({ where: and( eq(schema.seasonParticipantResults.participantId, participant.id), eq(schema.seasonParticipantResults.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) { const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? ""); if (match.loserId && !loserAdvances) { 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); const loserOldFloor = await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db); if (loserOldFloor !== null) { await recordMatchScoreEvents( { participantId: finalMatch.loserId, sportsSeasonId: event.sportsSeasonId, oldFloor: loserOldFloor, newFloor: config.loserPosition, bracketTemplateId: event.bracketTemplateId ?? null, matchId: finalMatch.id, eventId, eventName: event.name }, db ); } } else { // All other scoring rounds: assign loser's final (or provisional) position. for (const match of matches) { if (match.loserId) { const loserOldFloor = await upsertParticipantResult( match.loserId, event.sportsSeasonId, config.loserPosition, db, config.loserIsPartial ); if (loserOldFloor !== null) { await recordMatchScoreEvents( { participantId: match.loserId, sportsSeasonId: event.sportsSeasonId, oldFloor: loserOldFloor, newFloor: config.loserPosition, bracketTemplateId: event.bracketTemplateId ?? null, matchId: match.id, eventId, eventName: event.name }, db ); } } } } } // 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 ); } } } // Update probabilities first so the standings recalc reads fresh EVs. if (!options?.skipProbabilities) { 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 ); } } // Recalculate standings for all affected leagues (skipped when caller handles it) if (!options?.skipRecalculate) { await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name, eventId }); } } /** * 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; /** * When true, the loser of this non-scoring round advances to another match * (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be * marked as eliminated yet. Has no effect when isScoring=true. */ loserAdvances?: boolean; }, providedDb?: ReturnType ): Promise { const db = providedDb || database(); const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params; if (!isScoring) { // Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts), // unless loserAdvances=true (e.g. NBA Play-In 7v8 loser goes to Round 2). // 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. if (!loserAdvances) { await upsertParticipantResult(loserId, sportsSeasonId, 0, db); } if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) { await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); } // Non-scoring round wins are not surfaced in the Recent Scores feed. } 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); const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); if (oldFloor !== null && matchId && eventId) { await recordMatchScoreEvents( { participantId: winnerId, sportsSeasonId, oldFloor, newFloor: 5, bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null }, db ); } } else if (config.winnerFloor === null) { // Finalization round: winner and loser both get their exact positions. const loserOldFloor = await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db); if (loserOldFloor !== null && matchId && eventId) { await recordMatchScoreEvents( { participantId: loserId, sportsSeasonId, oldFloor: loserOldFloor, newFloor: config.loserPosition, bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null }, db ); } const winnerPosition = config.winnerPosition ?? 1; const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, winnerPosition, db); if (oldFloor !== null && matchId && eventId) { await recordMatchScoreEvents( { participantId: winnerId, sportsSeasonId, oldFloor, newFloor: winnerPosition, bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null }, db ); } } else { // All other scoring rounds: loser gets final (or provisional) position, winner gets floor. const loserOldFloor = await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial); if (loserOldFloor !== null && matchId && eventId) { await recordMatchScoreEvents( { participantId: loserId, sportsSeasonId, oldFloor: loserOldFloor, newFloor: config.loserPosition, bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null }, db ); } const oldFloor = await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true); if (oldFloor !== null && matchId && eventId) { await recordMatchScoreEvents( { participantId: winnerId, sportsSeasonId, oldFloor, newFloor: config.winnerFloor, bracketTemplateId: bracketTemplateId ?? null, matchId, eventId, eventName: eventName ?? null }, db ); } } } if (!skipSideEffects) { const sideEffectOptions = (eventName || eventId) ? { eventName, eventId, matchIds: matchId ? [matchId] : undefined } : undefined; // Update probabilities first so the standings recalc reads fresh EVs and // projected points reflect the new result. try { await updateProbabilitiesAfterResult(sportsSeasonId, true); } catch (error) { logger.error( `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`, error ); } await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions); } } /** * 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. * * Returns the previous finalPosition (or 0 if this is a new row), so callers * can compute exact point deltas. Returns null when the update was skipped * (i.e. trying to set a provisional floor on an already-finalized participant). */ async function upsertParticipantResult( participantId: string, sportsSeasonId: string, finalPosition: number, db: ReturnType, isPartialScore = false ): Promise { const existing = await db.query.seasonParticipantResults.findFirst({ where: and( eq(schema.seasonParticipantResults.participantId, participantId), eq(schema.seasonParticipantResults.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 null; await db .update(schema.seasonParticipantResults) .set({ finalPosition, isPartialScore, updatedAt: new Date(), }) .where(eq(schema.seasonParticipantResults.id, existing.id)); return existing.finalPosition ?? 0; } else { await db.insert(schema.seasonParticipantResults).values({ participantId, sportsSeasonId, finalPosition, isPartialScore, }); return 0; } } async function getSharedPlacementCount( sportsSeasonId: string, finalPosition: number, db: ReturnType, cache: Map> ): Promise { if (!cache.has(sportsSeasonId)) { const results = await db.query.seasonParticipantResults.findMany({ where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId), columns: { finalPosition: true }, }); const counts = new Map(); for (const result of results) { if (result.finalPosition !== null && result.finalPosition > 0) { counts.set(result.finalPosition, (counts.get(result.finalPosition) ?? 0) + 1); } } cache.set(sportsSeasonId, counts); } return cache.get(sportsSeasonId)?.get(finalPosition) ?? 1; } /** * 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) */ /** * Returns true if a loser should appear in a Discord standings notification. * * A loser is shown when: * - Their team's score changed this round (e.g. they received a floor or position), OR * - They were definitively eliminated (non-partial result exists), which covers * 0-pt eliminations that produce no score delta. * * Losers who advance to another match (loserAdvances=true) have neither a score * change nor a finalized result, so they are correctly suppressed. */ export function isLoserNotifiable( loserId: string | null | undefined, loserTeamId: string | undefined, changedTeamIds: Set, finalizedLoserIds: Set ): boolean { if (!loserId) return false; const scoreChanged = loserTeamId ? changedTeamIds.has(loserTeamId) : false; return scoreChanged || finalizedLoserIds.has(loserId); } 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 } // ── Qualifying-points bracket scoring ──────────────────────────────────────── /** Minimal shape of a playoff match needed to derive qualifying-bracket states. */ export interface BracketMatchInput { round: string; winnerId: string | null; loserId: string | null; participant1Id: string | null; participant2Id: string | null; } /** The guaranteed-minimum placement (and its structural tie span) for one team. */ export interface BracketQualifyingState { /** Placement tier the team has locked in (1, 2, 3, 5, …). */ placement: number; /** * Number of bracket slots this placement tier spans (e.g. 4 for T5–8, 2 for * T3–4, 1 for 1st/2nd). Used to tie-split QP across the tier. This is the * *structural* span from the template — not the live count of teams currently * sitting at this placement — so the floor value is stable and idempotent and * agrees with processQualifyingEvent's finalization re-grouping. */ tieCount: number; } /** * Derive each bracket team's guaranteed-minimum (placement, tieCount) from the * current set of playoff matches, reusing the standard ROUND_CONFIG floors. * * Rules per team (single-elimination ⇒ a team loses at most once): * - Lost in round R → final placement = loserPosition(R), tier span = matchCount(R). * (QF loss → 5/T5–8, SF loss → 3/T3–4, Finals loss → 2/finalist) * - Still alive, highest round won = R: * · R is the finalization round (winnerFloor null) → champion: winnerPosition ?? 1. * · otherwise → floor = winnerFloor(R), tier span = matchCount(R.feedsInto). * (QF win → 3/T3–4 = 9 QP, SF win → 2/finalist = 14 QP) * - Alive but no match resolved yet → entry floor of the first scoring round * (QF → 5/T5–8 = 4 QP), mirroring assignCs2EliminationQP's provisional floor. * * Pure and exported for unit testing. Teams eliminated in a non-scoring round * (getConfig returns null) are omitted — they earn no QP from the bracket. */ export function deriveBracketQualifyingStates( matches: BracketMatchInput[], rounds: BracketRound[], getConfig: (round: string) => RoundScoringConfig | null ): Map { const roundIndex = new Map(rounds.map((r, i) => [r.name, i])); const matchCountByRound = new Map(rounds.map((r) => [r.name, r.matchCount])); const feedsIntoByRound = new Map(rounds.map((r) => [r.name, r.feedsInto])); const firstScoringRound = rounds.find((r) => r.isScoring) ?? rounds[0]; const participants = new Set(); const wonRounds = new Map>(); const lostRound = new Map(); for (const m of matches) { if (m.participant1Id) participants.add(m.participant1Id); if (m.participant2Id) participants.add(m.participant2Id); if (m.winnerId) { participants.add(m.winnerId); if (!wonRounds.has(m.winnerId)) wonRounds.set(m.winnerId, new Set()); wonRounds.get(m.winnerId)?.add(m.round); } if (m.loserId) { participants.add(m.loserId); lostRound.set(m.loserId, m.round); } } const result = new Map(); for (const id of participants) { const lost = lostRound.get(id); if (lost) { const cfg = getConfig(lost); if (!cfg) continue; // non-scoring elimination: no bracket QP result.set(id, { placement: cfg.loserPosition, tieCount: matchCountByRound.get(lost) ?? 1 }); continue; } // Still alive: floor from the highest round they have won. const won = wonRounds.get(id); let highest: string | null = null; if (won) { for (const r of won) { if (highest === null || (roundIndex.get(r) ?? -1) > (roundIndex.get(highest) ?? -1)) { highest = r; } } } if (!highest) { // In the bracket but no match resolved yet → sitting in the first round. // Only grant an entry floor if that first round is itself a scoring round // (e.g. simple_8 / CS2 Champions Stage, where every entrant is already in // the scoring stage). For deep brackets whose scoring starts later // (tennis_128: Round of 128 → … → Round of 16), merely being drawn earns // nothing — no QP until the player reaches the first scoring round. if (!rounds[0]?.isScoring || !firstScoringRound) continue; const cfg = getConfig(firstScoringRound.name); if (!cfg) continue; result.set(id, { placement: cfg.loserPosition, tieCount: firstScoringRound.matchCount }); continue; } const cfg = getConfig(highest); if (!cfg) { // Won a non-scoring round. If that win advanced them INTO a scoring round, // they've guaranteed that round's loser floor (e.g. a tennis R32 win → in // the Round of 16 → guaranteed T9–16). Otherwise (won an earlier // non-scoring round) they've earned no QP yet. const feedsInto = feedsIntoByRound.get(highest) ?? null; const nextRound = feedsInto ? rounds.find((r) => r.name === feedsInto) : null; if (nextRound?.isScoring) { const floorCfg = getConfig(nextRound.name); if (floorCfg) { result.set(id, { placement: floorCfg.loserPosition, tieCount: nextRound.matchCount, }); } } continue; } if (cfg.winnerFloor === null) { // Won the finalization round → champion (or 3rd-place-game winner). result.set(id, { placement: cfg.winnerPosition ?? 1, tieCount: 1 }); } else { const next = feedsIntoByRound.get(highest) ?? null; const tieCount = next ? (matchCountByRound.get(next) ?? 1) : 1; result.set(id, { placement: cfg.winnerFloor, tieCount }); } } return result; } /** * Score a qualifying event whose Champions-Stage / knockout bracket lives in * playoffMatches (e.g. a CS2 Major). Derives each bracket team's guaranteed * minimum QP from the matches and upserts it into event_results, then refreshes * each participant's QP total. * * Unlike processPlayoffEvent (which writes the fantasy-points table * seasonParticipantResults), this writes ONLY the QP path. For a * qualifying_points sport the per-major fantasy placement is meaningless — final * fantasy placements come solely from finalizeQualifyingPoints across all majors. * * QP is written directly with the placement tier's STRUCTURAL tie span (QF tier * spans 4 slots, SF tier 2, finalist/champion 1) — not the live count of teams * currently at a placement. This is what makes a QF-winner floor 9 QP (avg of * 3rd+4th) even while all four QF winners transiently share placement 3. * processQualifyingEvent delegates to this function for bracket events precisely * so it does NOT re-group those rows by live count (which would average four * placement-3 rows down to 7). * * Idempotent: re-running on the same match state recomputes identical values. */ export async function processQualifyingBracketEvent( eventId: string, providedDb?: ReturnType ): Promise { const db = providedDb || database(); const event = await db.query.scoringEvents.findFirst({ where: eq(schema.scoringEvents.id, eventId), }); if (!event) throw new Error(`Event ${eventId} not found`); if (!event.isQualifyingEvent) { throw new Error(`Event ${eventId} is not a qualifying event`); } const template = event.bracketTemplateId ? BRACKET_TEMPLATES[event.bracketTemplateId] : undefined; if (!template) { throw new Error(`Event ${eventId} has no bracket template; cannot derive qualifying bracket QP`); } const matches = await db.query.playoffMatches.findMany({ where: eq(schema.playoffMatches.scoringEventId, eventId), }); const states = deriveBracketQualifyingStates( matches.map((m) => ({ round: m.round, winnerId: m.winnerId, loserId: m.loserId, participant1Id: m.participant1Id, participant2Id: m.participant2Id, })), template.rounds, (round) => getRoundConfig(round, event.bracketTemplateId) ); if (states.size === 0) return; const qpConfigArray = await getQPConfig(event.sportsSeasonId, db); const qpMap = new Map( qpConfigArray.map((c) => [c.placement, parseFloat(c.points)]) ); const entries = new Map( [...states.entries()].map(([id, { placement, tieCount }]) => [ id, { qp: calculateSplitQualifyingPoints(placement, tieCount, qpMap), placement }, ]) ); await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db); } /** * Count how many results share each placement — the structural tie span used to * split QP across a tied group. Callers pass the FULL canonical field * (tournament_results) so the span reflects the whole tournament, not one window's * roster subset. Null placements (filler / not-participating) are ignored. */ export function buildTieCountByPlacement( results: Array<{ placement: number | null }> ): Map { const map = new Map(); for (const r of results) { if (r.placement === null) continue; map.set(r.placement, (map.get(r.placement) ?? 0) + 1); } return map; } /** * 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, options: { skipNotifications?: boolean; /** * Pre-computed full-field tie span (placement → count) from the canonical * tournament_results. When the fan-out already loaded the canonical results it * passes this in so we don't re-query per window. Omitted for direct callers, * which fall back to querying it here. */ canonicalTieCountByPlacement?: Map; /** * This window's season_participant ids that were knocked out this sync in a * non-scoring round. They earn no QP (so they never surface via changed QP), * but a manager who drafted them should still be told. Threaded down from the * primary bracket by the fan-out (syncTournamentResults), already translated * to THIS window's season_participant ids. See the primary path in * app/services/match-sync/index.ts (newlyEliminatedIds). */ newlyEliminatedParticipantIds?: Set; } = {} ): Promise { const db = providedDb || database(); // 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); // Snapshot awarded QP before reprocessing so the Discord notification below can // announce only the participants whose QP actually changed. Without this, a // sibling window re-scored on every fan-out sync (syncTournamentResults) would // re-ping the full QP standings each run even when nothing changed. const beforeQP = results.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded, })); // Route to the bracket writer only when this window actually OWNS a bracket (has // playoff matches). A window can carry a bracketTemplateId with no matches — e.g. a // league window cloned from a bracket season copies the template id but not the // matches (cloneSportsSeason) — and processQualifyingBracketEvent would derive zero // states and write NO QP. Those windows must be scored via the placement/canonical // path below, exactly like a no-template sibling. let hasBracketMatches = false; if (event.bracketTemplateId) { const existing = await db .select({ id: schema.playoffMatches.id }) .from(schema.playoffMatches) .where(eq(schema.playoffMatches.scoringEventId, eventId)) .limit(1); hasBracketMatches = existing.length > 0; } if (hasBracketMatches) { // Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the // bracket/stage writers, which assign each placement its STRUCTURAL tie span. // Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows // (written by assignCs2EliminationQP) untouched. This deliberately skips the // placement-regroup below — re-grouping provisional floors by their LIVE count // would average four placement-3 QF winners down to 7 QP instead of their 9 floor. await processQualifyingBracketEvent(eventId, db); } else { // Clear stale QP before reprocessing so removed/null placements do not keep old awards. for (const result of results) { await db .update(schema.eventResults) .set({ qualifyingPointsAwarded: "0", updatedAt: new Date(), }) .where(eq(schema.eventResults.id, result.id)); } const qpConfig = await getQPConfig(event.sportsSeasonId, db); const pointsByPlacement = new Map( qpConfig.map((config) => [config.placement, parseFloat(config.points)]) ); // Full-field tie span. The number of players tied at a placement is a property // of the whole tournament field (canonical tournament_results), NOT of who // happens to be on THIS window's roster. Sibling/mirror windows only hold the // draftable subset of the field, so counting the placements present locally // (group.length) under-counts a tied group and over-awards it: tennis R16 losers // all sit at placement 9 with a structural span of 8 → (2+2+2+2+1+1+1+1)/8 = 1.5 // QP; a window holding only 4 of them would wrongly split 4 ways → 2 QP. Deriving // the span from canonical results keeps every window/league identical. The fan-out // passes this map in (already loaded once per tournament); direct callers with a // tournament link query it here. Standalone events (no tournamentId, no map) have // no canonical field, so fall back to the live count. const canonicalTieCountByPlacement: Map | null = options.canonicalTieCountByPlacement ?? (event.tournamentId ? buildTieCountByPlacement( await db .select({ placement: schema.tournamentResults.placement }) .from(schema.tournamentResults) .where(eq(schema.tournamentResults.tournamentId, event.tournamentId)) ) : null); // 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 = canonicalTieCountByPlacement?.get(placement) ?? group.length; const qpPerParticipant = calculateSplitQualifyingPoints( placement, tieCount, pointsByPlacement ); // Update the event result with the QP awarded for (const result of group) { await db .update(schema.eventResults) .set({ qualifyingPointsAwarded: qpPerParticipant.toFixed(2), 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.seasonParticipantId)); for (const participantId of participantIds) { await recalculateParticipantQP(participantId, event.sportsSeasonId, db); } // NOTE: majorsCompleted is NOT a stored counter. It is derived on read via // getMajorsCompleted() (count of completed qualifying events). Incrementing here // per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was // removed. See app/models/scoring-event.ts:getMajorsCompleted. logger.log( `[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants` ); // Announce only participants whose awarded QP changed vs. the pre-reprocess // snapshot (a differing value, or a first-time score: null → value). This keeps // repeated fan-out syncs of an unchanged window from re-pinging the standings. const afterRows = await db.query.eventResults.findMany({ where: eq(schema.eventResults.scoringEventId, eventId), }); const changedParticipantIds = diffChangedQualifyingPoints( beforeQP, afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded })) ); // Players knocked out this sync in a non-scoring round earn no QP, so they never // appear in changedParticipantIds. Announce them too (mirroring the primary path // in syncTennisDraw), so a mirror window's "Knocked Out" section isn't dropped. const eliminatedIds = options.newlyEliminatedParticipantIds ?? new Set(); if ( (changedParticipantIds.size > 0 || eliminatedIds.size > 0) && !options.skipNotifications ) { try { await notifyQualifyingPointsUpdate( event.sportsSeasonId, eventId, db, changedParticipantIds, eliminatedIds, ); } catch (error) { logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error); } } } /** * 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(); // 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) { const participantsInGroup = group.length; // Assign placements to participants in this group for (const standing of group) { await upsertParticipantResult( standing.participantId, sportsSeasonId, currentPlacement <= 8 ? currentPlacement : 0, 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.seasonParticipantResults.findFirst({ where: and( eq(schema.seasonParticipantResults.participantId, standing.participantId), eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId) ), }); if (!hasResult) { await upsertParticipantResult( standing.participantId, sportsSeasonId, 0, db ); } } } // Mark sports season as finalized await db .update(schema.sportsSeasons) .set({ qualifyingPointsFinalized: true, status: "completed", 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 = {}; const processedParticipantIds = new Set(); for (const result of seasonResults) { const position = result.currentPosition; if (position === null) continue; // handled after the loop 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]; if (currentPlacement > 8) { // Beyond top 8 — assign 0 to all remaining positioned participants for (const participantId of participantIds) { processedParticipantIds.add(participantId); await upsertParticipantResult(participantId, sportsSeasonId, 0, db); } continue; } // 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; // 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]; processedParticipantIds.add(participantId); 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]; processedParticipantIds.add(participantId); await upsertParticipantResult(participantId, sportsSeasonId, 0, db); } // Move to next placement group currentPlacement += participantsInGroup; } // Assign 0 to participants with no championship position recorded for (const result of seasonResults) { if (!processedParticipantIds.has(result.participantId)) { await upsertParticipantResult(result.participantId, sportsSeasonId, 0, db); } } // 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(); const sharedPlacementCountCache = 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"; const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points"; let points: number; if (isBracket) { const templateId = await getBracketTemplate(pick.participant.sportsSeasonId); points = calculateBracketPoints(result.finalPosition, scoringRules, templateId); } else if (isQualifyingPoints) { const tiedParticipants = await getSharedPlacementCount( pick.participant.sportsSeasonId, result.finalPosition, db, sharedPlacementCountCache ); points = calculateSharedPlacementPoints( result.finalPosition, tiedParticipants, scoringRules ); } else { points = calculateFantasyPoints(result.finalPosition, scoringRules); } totalPoints += points; // All participants with a valid position count toward the placement tiebreaker, // including those still in progress (isPartialScore). Their current position // is the best available signal, and if it changes recalculateStandings will run again. if (result.finalPosition <= 8) { placementCounts[result.finalPosition]++; } if (!result.isPartialScore) { // Only fully finalized participants count toward "completed" progress tracking participantsCompleted++; } } 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, evByParticipantId?: Map ): 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(); const sharedPlacementCountCache = 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"; const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points"; 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 if (isQualifyingPoints) { const tiedParticipants = await getSharedPlacementCount( pick.participant.sportsSeasonId, result.finalPosition, db, sharedPlacementCountCache ); points = calculateSharedPlacementPoints( result.finalPosition, tiedParticipants, scoringRules ); } 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; let floorPoints: number; if (isBracket) { floorPoints = calculateBracketPoints(result.finalPosition, scoringRules, templateId); } else if (isQualifyingPoints) { const tiedParticipants = await getSharedPlacementCount( pick.participant.sportsSeasonId, result.finalPosition, db, sharedPlacementCountCache ); floorPoints = calculateSharedPlacementPoints( result.finalPosition, tiedParticipants, scoringRules ); } else { floorPoints = 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) { for (const { participantId, sportsSeasonId, floorPoints } of unfinishedParticipants) { const ev = evByParticipantId ? evByParticipantId.get(participantId) ?? null : 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), }); // Pre-fetch every EV referenced by this season's draft picks in one query, then // pass the map into calculateTeamProjectedScore to avoid an N+1 per team. const evByParticipantId = new Map(); const allPicks = await db.query.draftPicks.findMany({ where: eq(schema.draftPicks.seasonId, seasonId), columns: { participantId: true }, with: { participant: { columns: { sportsSeasonId: true } } }, }); if (allPicks.length > 0) { const sportsSeasonIds = Array.from( new Set(allPicks.map((p) => p.participant.sportsSeasonId)) ); const evRows = await db .select() .from(schema.seasonParticipantExpectedValues) .where(inArray(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonIds)); for (const ev of evRows) { evByParticipantId.set(ev.participantId, ev); } } // 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, evByParticipantId); 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[]; skipDiscord?: boolean; eliminatedParticipantIds?: 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, })); } // Build the set of loser participant IDs that have a finalized (non-partial) result. // Used in Discord notifications to surface 0-pt eliminations that produce no score delta. const loserParticipantIds = allCompletedMatches .map((m) => m.loserId) .filter((id): id is string => id !== null); const finalizedLoserIds = new Set(); if (loserParticipantIds.length > 0) { const loserResults = await db.query.seasonParticipantResults.findMany({ where: and( inArray(schema.seasonParticipantResults.participantId, loserParticipantIds), eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId), eq(schema.seasonParticipantResults.isPartialScore, false) ), }); for (const r of loserResults) finalizedLoserIds.add(r.participantId); } // Look up names for participants eliminated by this action (e.g. bracket/knockout // generation). These produce no score delta, so they're surfaced via a dedicated // "Eliminated" section rather than the match/standings-change sections. const eliminatedIds = options?.eliminatedParticipantIds ?? []; const eliminatedNameById = new Map(); if (eliminatedIds.length > 0) { const eliminatedParticipants = await db.query.seasonParticipants.findMany({ where: inArray(schema.seasonParticipants.id, eliminatedIds), }); for (const p of eliminatedParticipants) eliminatedNameById.set(p.id, p.name); } // 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 || options?.skipDiscord) continue; // Build userId → username map for all team owners in this season const ownerUserIds = afterStandings .map((s) => s.team.ownerId) .filter((id): id is string => id !== null && id !== undefined); const teamOwnerUsers = ownerUserIds.length ? await db.query.users.findMany({ where: inArray(schema.users.id, ownerUserIds), }) : []; const usernameByUserId = new Map( teamOwnerUsers .map((u) => [u.id, getUserDisplayName(u)]) .filter((entry): entry is [string, string] => entry[1] !== null) ); // Build userId → Discord ID map for opted-in users const optedInUserIds = teamOwnerUsers .filter((u) => u.discordPingEnabled) .map((u) => u.id); const discordIdByUserId = await findDiscordIdsByUserIds(optedInUserIds); // Build teamId → ownerId map from standings data const ownerIdByTeamId = new Map( afterStandings.map((s) => [s.teamId, s.team.ownerId]) ); // Shared draft-pick lookup for both the scored-match and elimination sections, // loaded once per season only when at least one section needs it. const needDraftPicks = allCompletedMatches.length > 0 || eliminatedIds.length > 0; const draftPicks = needDraftPicks ? await db.query.draftPicks.findMany({ where: eq(schema.draftPicks.seasonId, seasonId), }) : []; const draftedIds = new Set(draftPicks.map((p) => p.participantId)); const teamIdByParticipantId = new Map( draftPicks.map((p) => [p.participantId, p.teamId]) ); // Build scored matches for the notification. // A match is worth announcing when: // • the winner is owned by a manager AND earned points this round, OR // • the loser is owned by a manager (eliminated or scored). // A winning team that merely advanced through a non-scoring round (e.g. R32 → R16 // in the World Cup) does not qualify on its own — their owner hasn't earned anything yet. // When a match qualifies because the loser is owned, the winner's manager tag is still // shown for context (who beat them), but the winner is not Discord-pinged. // Both managers' tags are always shown for context when their teams are drafted; the // showLoser flag (isLoserNotifiable) only gates whether the loser is @-pinged — a loser // who advanced rather than being eliminated (loserAdvances=true, e.g. NBA 7v8 → PIR2, or // a World Cup semifinal loser) is named but not pinged. let scoredMatches: ScoredMatch[] | undefined; if (allCompletedMatches.length > 0) { const relevant = allCompletedMatches.filter( (m) => (m.winnerId && draftedIds.has(m.winnerId)) || (m.loserId && draftedIds.has(m.loserId)) ); if (relevant.length > 0) { const lookupOwner = (participantId: string | null | undefined) => { if (!participantId) return undefined; const teamId = teamIdByParticipantId.get(participantId); if (!teamId) return undefined; return ownerIdByTeamId.get(teamId); }; scoredMatches = relevant .filter((m) => m.winnerName && m.loserName) .map((m) => { const winnerTeamId = m.winnerId ? teamIdByParticipantId.get(m.winnerId) : undefined; const loserTeamId = m.loserId ? teamIdByParticipantId.get(m.loserId) : undefined; const winnerScoreChanged = winnerTeamId ? changedTeamIds.has(winnerTeamId) : false; const winnerOwnerId = lookupOwner(m.winnerId); const loserOwnerId = lookupOwner(m.loserId); const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds); return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId }; }) .filter((x) => (x.winnerScoreChanged && !!x.winnerOwnerId) || (x.showLoser && !!x.loserOwnerId)) .map((x) => ({ winnerName: x.m.winnerName ?? "", loserName: x.m.loserName ?? "", winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined, // Show the loser's manager tag whenever their team is drafted, mirroring the // winner above — even when the loser advances rather than being eliminated // (World Cup semifinal → 3rd-place playoff, AFL Qualifying Final → Semi Final). // The @-ping stays gated by showLoser (loserDiscordUserId below): a still-alive // loser who neither scored nor was eliminated is named for context but not pinged. loserUsername: x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined, winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined, loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined, })) .filter((m) => m.winnerUsername !== undefined || m.loserUsername !== undefined); } } // Build the eliminated-teams section: drafted participants in this league that // were knocked out by this action (bracket/knockout generation). These have a // 0-pt result and no match, so they only surface here. let eliminatedTeams: EliminatedTeam[] | undefined; if (eliminatedIds.length > 0) { const teams = eliminatedIds .filter((id) => teamIdByParticipantId.has(id)) .map((id) => { const teamId = teamIdByParticipantId.get(id); const ownerId = teamId ? ownerIdByTeamId.get(teamId) : undefined; return { participantName: eliminatedNameById.get(id) ?? "Unknown", username: ownerId ? usernameByUserId.get(ownerId) : undefined, discordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined, }; }); if (teams.length > 0) eliminatedTeams = teams; } // Skip notification if scores didn't change AND no match has a displayable // username AND there's nothing eliminated to announce. const hasScoredMatchesToShow = scoredMatches?.some( (m) => m.winnerUsername !== undefined || m.loserUsername !== undefined ); const hasEliminatedToShow = (eliminatedTeams?.length ?? 0) > 0; if (!hasChanges && !hasScoredMatchesToShow && !hasEliminatedToShow) continue; const standings = afterStandings.map((s) => ({ teamId: s.teamId, teamName: s.team.name, username: usernameByUserId.get(s.team.ownerId ?? ""), discordUserId: s.team.ownerId ? discordIdByUserId.get(s.team.ownerId) : undefined, 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, eliminatedTeams, }); } 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}` ); } export async function autoCompleteRoundIfDone( eventId: string, round: string, sportsSeasonId: string, db: ReturnType ): Promise { const allMatches = await findPlayoffMatchesByEventId(eventId); const roundMatches = allMatches.filter((m) => m.round === round); if (roundMatches.length === 0) return; if (!roundMatches.every((m) => m.isComplete)) return; await db .update(schema.scoringEvents) .set({ playoffRound: round, updatedAt: new Date() }) .where(eq(schema.scoringEvents.id, eventId)); await processPlayoffEvent(eventId, db, { skipRecalculate: true, skipProbabilities: true }); logger.log(`[AutoComplete] Round "${round}" auto-completed for event ${eventId}`); }