brackt/app/models/scoring-calculator.ts
Chris Parsons 9480501932
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m27s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m25s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 14s
Unify majors: score once, fan out across windows + tennis bracket EV
Make a "major" (golf/tennis/CS2) scored once on its canonical tournament
and fan out to every linked sports_season window and league.

Fan-out & completion (app/services/sync-tournament-results.ts):
- syncTournamentResults now marks each synced window's event complete
  (gated by markComplete), recalculates affected leagues, and counts
  recalc failures so a stale league can't hide behind a "completed" badge
- syncMajorFromPrimaryEvent promotes a primary window's derived results to
  canonical tournament_results (deleting rows for dropped placements) and
  fans out to siblings; fanOutMajorIfPrimary guards on the primary
- placement removals now propagate (stale rows reset to filler)

Primary-event model (scoring_events.is_primary, migration 0122):
- getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent /
  setPrimaryEvent; event creation auto-seeds a primary for bracket majors;
  "Make primary" button on the tournament page
- per-window event/bracket/cs2 pages are read-only for non-primary linked
  events (not-participating stays editable)

Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG):
- bracket-scored qualifying major via the existing bracket pipeline
- simulator conditions in-progress EV on the real bracket (honoring
  completed matches, walkover for withdrawals), QP derived from config,
  round structure read from the template; CS2 + tennis share resolveStructureSource

Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile
of existing majors (link orphans, designate primary, promote canonical, sync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00

1888 lines
72 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,
calculateSharedPlacementPoints,
} 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, 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,
getQPConfig,
hasProcessedQualifyingPlacement,
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<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 },
},
tennis_128: {
// R16 losers share 9th16th; winner advances to QF (floor 5th8th).
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
// QF losers share 5th8th; winner advances to SF (floor 3rd4th).
Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
// SF losers share 3rd4th; 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 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.
*/
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 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; skipProbabilities?: 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
);
}
}
}
// 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<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;
// 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<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;
}
}
async function getSharedPlacementCount(
sportsSeasonId: string,
finalPosition: number,
db: ReturnType<typeof database>,
cache: Map<string, Map<number, number>>
): Promise<number> {
if (!cache.has(sportsSeasonId)) {
const results = await db.query.seasonParticipantResults.findMany({
where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
columns: { finalPosition: true },
});
const counts = new Map<number, number>();
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<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
}
// ── 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 T58, 2 for
* T34, 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/T58, SF loss → 3/T34, 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/T34 = 9 QP, SF win → 2/finalist = 14 QP)
* - Alive but no match resolved yet → entry floor of the first scoring round
* (QF → 5/T58 = 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<string, BracketQualifyingState> {
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<string>();
const wonRounds = new Map<string, Set<string>>();
const lostRound = new Map<string, string>();
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<string, BracketQualifyingState>();
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 → entry floor.
const cfg = firstScoringRound ? getConfig(firstScoringRound.name) : null;
if (!cfg) continue;
result.set(id, { placement: cfg.loserPosition, tieCount: firstScoringRound.matchCount });
continue;
}
const cfg = getConfig(highest);
if (!cfg) 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<typeof database>
): Promise<void> {
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<number, number>(
qpConfigArray.map((c) => [c.placement, parseFloat(c.points)])
);
const entries = new Map<string, { qp: number; placement: number }>(
[...states.entries()].map(([id, { placement, tieCount }]) => [
id,
{ qp: calculateSplitQualifyingPoints(placement, tieCount, qpMap), placement },
])
);
await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db);
}
/**
* 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 = hasProcessedQualifyingPlacement(results);
if (event.bracketTemplateId) {
// 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)])
);
// 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;
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);
}
// 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) {
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<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>();
const sharedPlacementCountCache = new Map<string, Map<number, number>>();
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";
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<typeof database>,
evByParticipantId?: Map<string, ParticipantEV>
): 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>();
const sharedPlacementCountCache = new Map<string, Map<number, number>>();
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";
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<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),
});
// 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<string, ParticipantEV>();
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<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 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])
);
// 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 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 {
winnerName: m.winnerName ?? "",
loserName: m.loserName ?? "",
winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined,
loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined,
winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined,
loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : 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 ?? ""),
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,
});
} 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<typeof database>
): Promise<void> {
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}`);
}