brackt/app/models/scoring-calculator.ts
Chris Parsons 5b03b22267
Add Brackt as a league-private meta-sport (#200) (#377)
Commissioners can add Brackt to any league. Each team drafts one manager
from their own league; at season end, that manager's final overall fantasy
ranking earns placement points. Resolution fires automatically when all
other sports finalize.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 20:31:44 -07:00

1549 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm";
import { getScoringRules, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules";
import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import { doesLoserAdvance } from "~/models/playoff-match";
import { getUserDisplayName } from "~/models/user";
import { createDailySnapshot } from "~/models/standings";
import { recordMatchScoreEvents } from "~/models/team-score-events";
import { logger } from "~/lib/logger";
import { getEventResults } from "./event-result";
import { getQPForPlacement, recalculateParticipantQP, getQPStandings, updateFinalRankings } from "./qualifying-points";
import { getParticipantEV } 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<string, RoundScoringConfig> = {
// ── 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 (7th8th).
"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 (5th6th), 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<string, Record<string, RoundScoringConfig>> = {
afl_10: {
"Semi-Finals": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
},
fifa_48: {
// QF winner is guaranteed 4th at worst (lose SF → lose 3PG).
Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 },
// SF loser still has the 3rd place game — provisional 4th until it's played.
Semifinals: { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 },
// 3rd place game finalizes both positions distinctly.
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
},
};
/**
* Returns true if a non-scoring round's winners are entering the first scoring round
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5T8 floor).
*
* For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 →
* Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring
* bracket — Round of 64 and Round of 32 winners should not receive any floor yet.
*
* Falls back to true when template/round info is unavailable to preserve legacy behavior.
*/
function doesNonScoringRoundFeedIntoScoringRound(
round: string,
bracketTemplateId: string | null | undefined
): boolean {
if (!bracketTemplateId) return true; // Legacy: preserve old behavior
const template = BRACKET_TEMPLATES[bracketTemplateId];
if (!template) return true; // Unknown template: preserve old behavior
const currentRound = template.rounds.find((r) => r.name === round);
if (!currentRound) return true; // Unknown round: preserve old behavior
const nextRoundName = currentRound.feedsInto;
if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring)
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
return nextRound?.isScoring === true;
}
/**
* Look up the scoring config for a given round name, applying any template-specific
* overrides before falling back to the standard ROUND_CONFIG.
* Returns null for unrecognized rounds.
*/
function getRoundConfig(
round: string,
bracketTemplateId?: string | null
): RoundScoringConfig | null {
if (bracketTemplateId && TEMPLATE_ROUND_CONFIG[bracketTemplateId]?.[round]) {
return TEMPLATE_ROUND_CONFIG[bracketTemplateId][round];
}
return ROUND_CONFIG[round] ?? null;
}
/**
* Process a playoff event completion and assign final placements.
*
* Loser placement rules per round type:
* Non-scoring rounds: losers get 0 pts (pre-bracket elimination)
* Quarterfinals / equivalent: losers share T5T8
* Semifinals / equivalent: losers share T3T4
* 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<typeof database>,
options?: { skipRecalculate?: boolean }
): Promise<void> {
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<string>();
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 T5T8 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
);
}
}
}
// Recalculate standings for all affected leagues (skipped when caller handles it)
if (!options?.skipRecalculate) {
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name, eventId });
}
// Auto-trigger probability recalculation after result
try {
await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
logger.log(
`[ScoringCalculator] Auto-updated probabilities for sports season ${event.sportsSeasonId}`
);
} catch (error) {
logger.error(
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${event.sportsSeasonId}:`,
error
);
}
}
/**
* Process a single completed match and immediately award points.
*
* Called when a match winner is set, before the full round is complete.
* Loser placement is final (isPartialScore=false); winner placement is
* provisional (isPartialScore=true) reflecting their guaranteed minimum finish.
*
* @param skipSideEffects - When true, skips standings recalc and probability update.
* Use this when processing multiple matches in a batch (call side effects once after
* the loop instead of once per match).
*/
export async function processMatchResult(
params: {
round: string;
winnerId: string;
loserId: string;
/** isScoring ?? true: default to true for legacy rows that pre-date the isScoring column. */
isScoring: boolean;
sportsSeasonId: string;
bracketTemplateId?: string | null;
eventId?: string;
eventName?: string;
/** When set, Discord notification only shows this match (not all completed matches for the event). */
matchId?: string;
skipSideEffects?: boolean;
/**
* 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<typeof database>
): Promise<void> {
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 T5T8 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 T5T8 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;
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
try {
await updateProbabilitiesAfterResult(sportsSeasonId, true);
} catch (error) {
logger.error(
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
error
);
}
}
}
/**
* Helper to upsert participant result.
*
* isPartialScore=true means the participant is still alive and this placement
* is their guaranteed minimum floor — it will be replaced as they advance or
* when they are finally eliminated.
*
* 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<typeof database>,
isPartialScore = false
): Promise<number | null> {
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;
}
}
/**
* 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<string>,
finalizedLoserIds: Set<string>
): 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 T5T8 floor
return config.winnerFloor; // null for Finals; actual floor for all other rounds
}
/**
* Process a qualifying event completion and update QP totals.
* Ties in QP are handled by sharing placements (averaged points).
*/
export async function processQualifyingEvent(
eventId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
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);
// 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<number, typeof results>();
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.seasonParticipantId));
for (const participantId of participantIds) {
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
}
// Increment majorsCompleted counter (only if this is the first time processing)
if (!wasAlreadyProcessed) {
const sportsSeason = event.sportsSeason;
await db
.update(schema.sportsSeasons)
.set({
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
updatedAt: new Date(),
})
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
}
logger.log(
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
);
}
/**
* Finalize qualifying points and convert to fantasy placements.
* Called manually by admin when all events are complete.
* Tied QP totals result in shared placements (averaged points).
*/
export async function finalizeQualifyingPoints(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
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<string, typeof standings>();
for (const standing of standings) {
const qp = standing.totalQualifyingPoints;
if (!groupedByQP.has(qp)) {
groupedByQP.set(qp, []);
}
groupedByQP.get(qp)?.push(standing);
}
// Sort groups by QP (descending)
const sortedGroups = Array.from(groupedByQP.entries()).toSorted((a, b) => {
return parseFloat(b[0]) - parseFloat(a[0]);
});
// Assign fantasy placements (1-8) based on QP ranking
let currentPlacement = 1;
for (const [_, group] of sortedGroups) {
// Stop if we've gone beyond the top 8 fantasy placements
if (currentPlacement > 8) break;
const participantsInGroup = group.length;
// Calculate how many placements this group shares
const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1);
const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1;
// Assign placements to participants in this group
for (let i = 0; i < Math.min(participantsInGroup, actualParticipantsScoring); i++) {
const standing = group[i];
await upsertParticipantResult(
standing.participantId,
sportsSeasonId,
currentPlacement,
db
);
}
// If there are more participants than scoring positions, assign 0 to the rest
for (let i = actualParticipantsScoring; i < participantsInGroup; i++) {
const standing = group[i];
await upsertParticipantResult(
standing.participantId,
sportsSeasonId,
0, // Beyond top 8
db
);
}
// Move to next placement group
currentPlacement += participantsInGroup;
}
// Assign 0 points to any remaining participants beyond top 8
if (currentPlacement <= standings.length) {
for (let i = currentPlacement - 1; i < standings.length; i++) {
const standing = standings[i];
const hasResult = await db.query.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<typeof database>
): Promise<void> {
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<number, string[]> = {};
const processedParticipantIds = new Set<string>();
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<typeof database>
): Promise<{
totalPoints: number;
placementCounts: Record<number, number>;
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<number, number> = {
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 58 into two pairs).
const bracketTemplateCache = new Map<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId) ?? null;
}
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId;
}
for (const pick of picks) {
// One result per participant per sports season (enforced by upsertParticipantResult).
// If duplicates exist due to data corruption, the first row wins — acceptable trade-off.
const result = pick.participant.results[0];
if (result && result.finalPosition !== null && result.finalPosition > 0) {
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
let points: number;
if (isBracket) {
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
totalPoints += points;
// 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<typeof database>
): 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<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId) ?? null;
}
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId;
}
// Separate finished vs unfinished participants
for (const pick of picks) {
const result = pick.participant.results[0];
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
if (result && result.finalPosition !== null && !result.isPartialScore) {
// Participant is fully finalized — use bracket-averaged points
let points: number;
if (isBracket) {
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
actualPoints += points;
participantsFinished++;
} else if (result && result.finalPosition !== null && result.isPartialScore) {
// Still alive with a provisional floor — count floor as actual, EV for projection.
// Note: NOT incremented in participantsFinished; these participants are still competing.
const templateId = isBracket ? await getBracketTemplate(pick.participant.sportsSeasonId) : null;
const floorPoints = isBracket
? calculateBracketPoints(result.finalPosition, scoringRules, templateId)
: calculateFantasyPoints(result.finalPosition, scoringRules);
actualPoints += floorPoints;
// EV already accounts for their full projected value, so subtract floor to avoid
// double-counting when we do actualPoints + evSum below
unfinishedParticipants.push({
participantId: pick.participant.id,
sportsSeasonId: pick.participant.sportsSeasonId,
floorPoints,
});
} else {
// Participant is unfinished - will need EV
unfinishedParticipants.push({
participantId: pick.participant.id,
sportsSeasonId: pick.participant.sportsSeasonId,
floorPoints: 0,
});
}
}
// Get EVs for unfinished participants
let evSum = 0;
if (unfinishedParticipants.length > 0) {
for (const { participantId, sportsSeasonId, floorPoints } of unfinishedParticipants) {
const ev = await getParticipantEV(participantId, sportsSeasonId);
if (ev) {
// EV is already calculated with default scoring (100/70/50/40/25/25/15/15)
// We need to recalculate with THIS league's scoring rules
const probabilities = {
probFirst: parseFloat(ev.probFirst),
probSecond: parseFloat(ev.probSecond),
probThird: parseFloat(ev.probThird),
probFourth: parseFloat(ev.probFourth),
probFifth: parseFloat(ev.probFifth),
probSixth: parseFloat(ev.probSixth),
probSeventh: parseFloat(ev.probSeventh),
probEighth: parseFloat(ev.probEighth),
};
const leagueSpecificEV = calculateEV(probabilities, scoringRules);
// For partial-score participants, floor is already in actualPoints — add only the
// incremental EV above the floor to avoid double-counting in projectedPoints.
// For pending participants (floorPoints=0) this is just the full EV.
// Math.max guards against the rare case where EV drops below the floor.
evSum += Math.max(0, leagueSpecificEV - floorPoints);
}
// If no EV data exists, assume 0 additional projected points
}
}
const projectedPoints = actualPoints + evSum;
return {
actualPoints: Math.round(actualPoints * 100) / 100,
projectedPoints: Math.round(projectedPoints * 100) / 100,
participantsFinished,
participantsRemaining: unfinishedParticipants.length,
};
}
/**
* Compare two teams for ranking purposes using tiebreaker logic
* Returns: negative if teamA ranks higher, positive if teamB ranks higher, 0 if tied
*
* Tiebreaker order:
* 1. Total points (higher is better)
* 2. Number of 1st place finishes (higher is better)
* 3. Number of 2nd place finishes (higher is better)
* 4. Continue through all placements (3rd, 4th, ..., 8th)
*/
function compareTeamsForRanking(
teamA: {
totalPoints: number;
placementCounts: Record<number, number>;
},
teamB: {
totalPoints: number;
placementCounts: Record<number, number>;
}
): 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<number, number>;
}>
): Map<string, number> {
// Sort teams by ranking criteria
const sorted = [...teams].toSorted(compareTeamsForRanking);
const ranks = new Map<string, number>();
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<typeof database>
): Promise<void> {
const db = providedDb || database();
const teams = await db.query.teams.findMany({
where: eq(schema.teams.seasonId, seasonId),
});
// Calculate scores for all teams
const teamScores = await Promise.all(
teams.map(async (team) => {
const score = await calculateTeamScore(team.id, seasonId, db);
const projected = await calculateTeamProjectedScore(team.id, seasonId, db);
return {
teamId: team.id,
totalPoints: score.totalPoints,
placementCounts: score.placementCounts,
participantsCompleted: score.participantsCompleted,
participantsTotal: score.participantsTotal,
actualPoints: projected.actualPoints,
projectedPoints: projected.projectedPoints,
participantsFinished: projected.participantsFinished,
};
})
);
// Assign ranks based on tiebreaker logic
const ranks = assignRanks(teamScores);
// Update team standings with scores and ranks
for (const teamScore of teamScores) {
const newRank = ranks.get(teamScore.teamId) || 1;
// Get existing standing to preserve previousRank
const existing = await db.query.teamStandings.findFirst({
where: and(
eq(schema.teamStandings.teamId, teamScore.teamId),
eq(schema.teamStandings.seasonId, seasonId)
),
});
const standingData = {
totalPoints: teamScore.totalPoints.toString(),
currentRank: newRank,
previousRank: existing ? existing.currentRank : null,
firstPlaceCount: teamScore.placementCounts[1],
secondPlaceCount: teamScore.placementCounts[2],
thirdPlaceCount: teamScore.placementCounts[3],
fourthPlaceCount: teamScore.placementCounts[4],
fifthPlaceCount: teamScore.placementCounts[5],
sixthPlaceCount: teamScore.placementCounts[6],
seventhPlaceCount: teamScore.placementCounts[7],
eighthPlaceCount: teamScore.placementCounts[8],
participantsRemaining:
teamScore.participantsTotal - teamScore.participantsCompleted,
actualPoints: teamScore.actualPoints?.toString() ?? null,
projectedPoints: teamScore.projectedPoints?.toString() ?? null,
participantsFinished: teamScore.participantsFinished ?? null,
calculatedAt: new Date(),
};
if (existing) {
await db
.update(schema.teamStandings)
.set(standingData)
.where(eq(schema.teamStandings.id, existing.id));
} else {
await db.insert(schema.teamStandings).values({
teamId: teamScore.teamId,
seasonId,
...standingData,
});
}
}
logger.log(`[ScoringCalculator] Recalculated standings for season ${seasonId}: ${teams.length} teams ranked`);
}
/**
* Recalculate standings for all leagues that include a specific sports season
* Called after a sports season event completes
*/
export async function recalculateAffectedLeagues(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>,
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean }
): Promise<void> {
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<string>();
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);
}
// 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 teamId → ownerId map from standings data
const ownerIdByTeamId = new Map(
afterStandings.map((s) => [s.teamId, s.team.ownerId])
);
// Build scored matches for the notification.
// Winners only appear if their team's score changed (they earned points this round).
// Losers appear if their team's score changed OR they were definitively eliminated
// (finalPosition set, non-partial) — the latter catches 0-pt eliminations that
// produce no score delta. Losers who advance to another match (loserAdvances=true,
// e.g. NBA 7v8 → PIR2) have no finalized result and no score change, so they're
// correctly suppressed.
let scoredMatches: ScoredMatch[] | undefined;
if (allCompletedMatches.length > 0) {
const draftPicks = await db.query.draftPicks.findMany({
where: eq(schema.draftPicks.seasonId, seasonId),
});
const draftedIds = new Set(draftPicks.map((p) => p.participantId));
const relevant = allCompletedMatches.filter(
(m) =>
(m.winnerId && draftedIds.has(m.winnerId)) ||
(m.loserId && draftedIds.has(m.loserId))
);
if (relevant.length > 0) {
const teamIdByParticipantId = new Map(
draftPicks.map((p) => [p.participantId, p.teamId])
);
const lookupUsername = (participantId: string | null | undefined) => {
if (!participantId) return undefined;
const teamId = teamIdByParticipantId.get(participantId);
if (!teamId) return undefined;
const ownerId = ownerIdByTeamId.get(teamId);
if (!ownerId) return undefined;
return usernameByUserId.get(ownerId);
};
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;
return {
winnerName: m.winnerName ?? "",
loserName: m.loserName ?? "",
winnerUsername: winnerScoreChanged ? lookupUsername(m.winnerId) : undefined,
loserUsername: isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds)
? lookupUsername(m.loserId)
: undefined,
};
});
}
}
// Skip notification if scores didn't change AND no match has a displayable username.
const hasScoredMatchesToShow = scoredMatches?.some(
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
);
if (!hasChanges && !hasScoredMatchesToShow) continue;
const standings = afterStandings.map((s) => ({
teamId: s.teamId,
teamName: s.team.name,
username: usernameByUserId.get(s.team.ownerId ?? ""),
totalPoints: parseFloat(s.totalPoints),
rank: s.currentRank,
}));
try {
await sendStandingsUpdateNotification({
webhookUrl,
seasonName: `${season.league.name} ${season.year}`,
standings,
previousStandings: previousPointsById,
previousRanks: previousRanksById,
sportName,
eventName: options?.eventName,
scoredMatches,
});
} catch (err) {
// Log but don't fail the scoring pipeline if Discord is unreachable
logger.error(`[ScoringCalculator] Discord notification failed for season ${seasonId}:`, err);
}
}
logger.log(
`[ScoringCalculator] Recalculated ${seasonIds.length} affected season(s) for sports season ${sportsSeasonId}`
);
}