brackt/app/models/scoring-calculator.ts
Chris Parsons 09b9b9bdad
Optimize user data fetching with batch queries and centralize display name logic (#176)
* Fall back to displayName when username is null for Discord webhook

Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").

https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH

* Extract getUserDisplayName helper and use consistently throughout

Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.

No behaviour change — all existing logic preserved, just centralised.

https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH

* Fix N+1 user queries in league loader and settings loader

Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.

https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH

* Fix N+1 user queries in buildOwnerMap

Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.

https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00

1400 lines
50 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, calculateAveragedPoints, 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 { getUserDisplayName } from "~/models/user";
/**
* 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 Finals round: winner is finalized as 1st place (handled inline).
*/
winnerFloor: number | null;
}
/**
* 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 },
},
};
/**
* 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: (matches, { asc }) => [asc(matches.matchNumber)],
});
// Process based on round
const round = event.playoffRound;
// Check if this is a scoring round
// First try to get isScoring from the match (template-based)
const isScoring = matches[0]?.isScoring ?? true; // Default to true for legacy brackets
// PHASE 5.3: Handle teams that didn't make the playoffs
// Get all participants in the sports season
const allParticipants = await db.query.participants.findMany({
where: eq(schema.participants.sportsSeasonId, event.sportsSeasonId),
});
// Get all participant IDs that are in ANY playoff match (winners or losers)
const allMatches = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId),
});
const participantsInBracket = new Set<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.participantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, participant.id),
eq(schema.participantResults.sportsSeasonId, event.sportsSeasonId)
),
});
if (!existingResult) {
await upsertParticipantResult(
participant.id,
event.sportsSeasonId,
0, // 0 = didn't make playoffs, eliminated
db
);
}
}
}
// Get season scoring rules for the first affected season
// (all seasons should have their own rules, but we use the event's sports season to find one)
const seasonSport = event.sportsSeason.seasonSports[0];
if (!seasonSport) {
throw new Error(`No fantasy seasons found for sports season ${event.sportsSeasonId}`);
}
const scoringRules = await getScoringRules(seasonSport.seasonId, db);
if (!scoringRules) {
throw new Error(`Scoring rules not found for season ${seasonSport.seasonId}`);
}
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) {
if (match.loserId) {
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).
console.warn(
`[ScoringCalculator] Unrecognized scoring round "${round}" for event ${eventId}. Losers receive 0 pts.`
);
for (const match of matches) {
if (match.loserId) {
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
}
}
} else if (config.winnerFloor === null) {
// Finals: finalize both participants — winner gets 1st, loser gets 2nd.
const finalMatch = matches[0];
if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) {
throw new Error("Finals match is not complete");
}
await upsertParticipantResult(finalMatch.winnerId, event.sportsSeasonId, 1, db);
await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db);
} else {
// All other scoring rounds: assign loser's final (or provisional) position.
for (const match of matches) {
if (match.loserId) {
await upsertParticipantResult(
match.loserId,
event.sportsSeasonId,
config.loserPosition,
db,
config.loserIsPartial
);
}
}
}
}
// 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);
console.log(
`[ScoringCalculator] Auto-updated probabilities for sports season ${event.sportsSeasonId}`
);
} catch (error) {
console.error(
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${event.sportsSeasonId}:`,
error
);
}
}
/**
* 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;
},
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects } = params;
if (!isScoring) {
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts).
// 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.
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
}
} else {
const config = getRoundConfig(round, bracketTemplateId);
if (config === null) {
// Unrecognized scoring round: loser earns 0 pts, winner gets fallback T5T8 floor.
console.warn(
`[ScoringCalculator] processMatchResult: unrecognized round "${round}". Loser receives 0 pts.`
);
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
} else if (config.winnerFloor === null) {
// Finals: finalize both participants.
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
await upsertParticipantResult(winnerId, sportsSeasonId, 1, db);
} else {
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true);
}
}
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) {
console.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.
*/
async function upsertParticipantResult(
participantId: string,
sportsSeasonId: string,
finalPosition: number,
db: ReturnType<typeof database>,
isPartialScore = false
): Promise<void> {
const existing = await db.query.participantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, participantId),
eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
),
});
if (existing) {
// Never un-finalize: if the row is already finalized (isPartialScore=false),
// a call with isPartialScore=true must not overwrite it (e.g. a re-run of
// processPlayoffEvent after a manual finalization).
if (!existing.isPartialScore && isPartialScore) return;
await db
.update(schema.participantResults)
.set({
finalPosition,
isPartialScore,
updatedAt: new Date(),
})
.where(eq(schema.participantResults.id, existing.id));
} else {
await db.insert(schema.participantResults).values({
participantId,
sportsSeasonId,
finalPosition,
isPartialScore,
});
}
}
/**
* Returns the guaranteed minimum final position for winners of a given round.
*
* This is the worst-case placement a participant can achieve if they lose
* every remaining match from this point forward. Returns null when:
* - The round is non-scoring (winners haven't reached a points-paying zone)
* - The round is the Finals (winner already finalized as 1st place inline)
*/
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();
// Import qualifying points functions
const { getEventResults } = await import("./event-result");
const { getQPForPlacement, recalculateParticipantQP } = await import("./qualifying-points");
// Get the event
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
with: {
sportsSeason: true,
},
});
if (!event) {
throw new Error(`Event ${eventId} not found`);
}
if (!event.isQualifyingEvent) {
throw new Error(`Event ${eventId} is not a qualifying event`);
}
// Get all event results for this qualifying event
const results = await getEventResults(eventId, db);
// Check if this was already processed (for majorsCompleted counter)
const wasAlreadyProcessed = results.some(r => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0);
// Group results by placement to handle ties
const placementGroups = new Map<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.participantId));
for (const participantId of participantIds) {
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
}
// Increment majorsCompleted counter (only if this is the first time processing)
if (!wasAlreadyProcessed) {
const sportsSeason = event.sportsSeason;
await db
.update(schema.sportsSeasons)
.set({
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
updatedAt: new Date(),
})
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
}
console.log(
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
);
}
/**
* Finalize qualifying points and convert to fantasy placements.
* Called manually by admin when all events are complete.
* Tied QP totals result in shared placements (averaged points).
*/
export async function finalizeQualifyingPoints(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
// Import qualifying points functions
const { getQPStandings, updateFinalRankings } = await import("./qualifying-points");
// Get all participants ranked by total QP (highest first)
const standings = await getQPStandings(sportsSeasonId, db);
if (standings.length === 0) {
throw new Error(`No qualifying points standings found for sports season ${sportsSeasonId}`);
}
// Update final rankings in participant_qualifying_totals
await updateFinalRankings(sportsSeasonId, db);
// Group participants by QP total to handle ties (Q5, Q19)
const groupedByQP = new Map<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()).sort((a, b) => {
return parseFloat(b[0]) - parseFloat(a[0]);
});
// Assign fantasy placements (1-8) based on QP ranking
let currentPlacement = 1;
for (const [_, group] of sortedGroups) {
// Stop if we've gone beyond the top 8 fantasy placements
if (currentPlacement > 8) break;
const participantsInGroup = group.length;
// Calculate how many placements this group shares
const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1);
const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1;
// Assign placements to participants in this group
for (let i = 0; i < Math.min(participantsInGroup, actualParticipantsScoring); i++) {
const standing = group[i];
await upsertParticipantResult(
standing.participantId,
sportsSeasonId,
currentPlacement,
db
);
}
// If there are more participants than scoring positions, assign 0 to the rest
for (let i = actualParticipantsScoring; i < participantsInGroup; i++) {
const standing = group[i];
await upsertParticipantResult(
standing.participantId,
sportsSeasonId,
0, // Beyond top 8
db
);
}
// Move to next placement group
currentPlacement += participantsInGroup;
}
// Assign 0 points to any remaining participants beyond top 8
if (currentPlacement <= standings.length) {
for (let i = currentPlacement - 1; i < standings.length; i++) {
const standing = standings[i];
const hasResult = await db.query.participantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, standing.participantId),
eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
),
});
if (!hasResult) {
await upsertParticipantResult(
standing.participantId,
sportsSeasonId,
0,
db
);
}
}
}
// Mark sports season as finalized
await db
.update(schema.sportsSeasons)
.set({
qualifyingPointsFinalized: true,
updatedAt: new Date(),
})
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
// Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" });
// Auto-trigger probability recalculation after result
try {
await updateProbabilitiesAfterResult(sportsSeasonId, true);
console.log(
`[ScoringCalculator] Auto-updated probabilities for sports season ${sportsSeasonId}`
);
} catch (error) {
console.error(
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
error
);
}
console.log(
`[ScoringCalculator] Finalized qualifying points for sports season ${sportsSeasonId}: ${standings.length} participants processed`
);
}
/**
* Process season standings (auto racing: F1, IndyCar, etc.) and assign final placements.
* Reads from participant_season_results (championship points) and converts
* final standings positions to fantasy placements. Ties share placements.
*/
export async function processSeasonStandings(
sportsSeasonId: string,
providedDb?: ReturnType<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[]> = {};
for (const result of seasonResults) {
const position = result.currentPosition;
if (position === null) continue; // Skip participants without a position
if (!positionGroups[position]) {
positionGroups[position] = [];
}
positionGroups[position].push(result.participantId);
}
// Get sorted positions
const positions = Object.keys(positionGroups)
.map(Number)
.sort((a, b) => a - b);
// Assign fantasy placements (1-8) based on standings
let currentPlacement = 1;
for (const position of positions) {
const participantIds = positionGroups[position];
// Stop if we've gone beyond the top 8 fantasy placements
if (currentPlacement > 8) break;
// Calculate how many placements this group shares
const participantsInGroup = participantIds.length;
// If this group would exceed position 8, only some participants get points
const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1);
const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1;
// Determine if we should assign placements to this group
if (currentPlacement <= 8) {
// All participants tied at this position get the first placement in the range
// (e.g., if 4 people tie for 5th place, they all get placement 5)
// The scoring system will handle averaging the points for positions 5, 6, 7, 8
for (let i = 0; i < Math.min(participantIds.length, actualParticipantsScoring); i++) {
const participantId = participantIds[i];
await upsertParticipantResult(
participantId,
sportsSeasonId,
currentPlacement,
db
);
}
// If there are more participants than scoring positions, assign 0 to the rest
for (let i = actualParticipantsScoring; i < participantIds.length; i++) {
const participantId = participantIds[i];
await upsertParticipantResult(
participantId,
sportsSeasonId,
0, // Beyond top 8
db
);
}
} else {
// Position is beyond top 8, assign 0 points
for (const participantId of participantIds) {
await upsertParticipantResult(
participantId,
sportsSeasonId,
0,
db
);
}
}
// Move to next placement group
currentPlacement += participantsInGroup;
}
// Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });
// Auto-trigger probability recalculation after result
try {
await updateProbabilitiesAfterResult(sportsSeasonId, true);
console.log(
`[ScoringCalculator] Auto-updated probabilities for sports season ${sportsSeasonId}`
);
} catch (error) {
console.error(
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
error
);
}
console.log(
`[ScoringCalculator] Processed season standings for sports season ${sportsSeasonId}`
);
}
/**
* Calculate total fantasy points for a team in a specific season
* Sums points from all drafted participants based on their final placements
*/
export async function calculateTeamScore(
teamId: string,
seasonId: string,
providedDb?: ReturnType<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)!;
}
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;
if (!result.isPartialScore) {
// Only fully finalized participants count toward completed and placement tiebreakers
participantsCompleted++;
if (result.finalPosition >= 1 && result.finalPosition <= 8) {
placementCounts[result.finalPosition]++;
}
}
} 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)!;
}
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) {
// Import the participant EV model
const { getParticipantEV } = await import("./participant-expected-value");
const { calculateEV } = await import("~/services/ev-calculator");
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)
if (teamA.totalPoints !== teamB.totalPoints) {
return teamB.totalPoints - teamA.totalPoints;
}
// If points are tied, apply tiebreaker cascade
for (let placement = 1; placement <= 8; placement++) {
const countA = teamA.placementCounts[placement] || 0;
const countB = teamB.placementCounts[placement] || 0;
if (countA !== countB) {
return countB - countA; // More of this placement is better
}
}
// Completely tied
return 0;
}
/**
* Assign ranks to teams based on their scores and tiebreakers
* Teams with identical scores and placements will share the same rank
*/
function assignRanks(
teams: Array<{
teamId: string;
totalPoints: number;
placementCounts: Record<number, number>;
}>
): Map<string, number> {
// Sort teams by ranking criteria
const sorted = [...teams].sort(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,
});
}
}
console.log(`[ScoringCalculator] Recalculated standings for season ${seasonId}: ${teams.length} teams ranked`);
}
/**
* Recalculate standings for all leagues that include a specific sports season
* Called after a sports season event completes
*/
export async function recalculateAffectedLeagues(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>,
options?: { eventName?: string; eventId?: string; matchIds?: string[] }
): 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,
}));
}
// 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)])
);
await recalculateStandings(seasonId, db);
// 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 hasChanges = afterStandings.some((s) => {
const prev = previousPointsById.get(s.teamId);
return prev === undefined || prev !== parseFloat(s.totalPoints);
});
// 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) continue;
// Build clerkId → username map for all team owners in this season
const ownerClerkIds = afterStandings
.map((s) => s.team.ownerId)
.filter((id): id is string => id !== null && id !== undefined);
const teamOwnerUsers = ownerClerkIds.length
? await db.query.users.findMany({
where: inArray(schema.users.clerkId, ownerClerkIds),
})
: [];
const usernameByClerkId = new Map(
teamOwnerUsers
.map((u) => [u.clerkId, 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])
);
// Filter matches to only those involving participants drafted in this season.
// We compute this BEFORE the hasChanges gate because 0-point eliminations
// (e.g. R64 losers whose finalPosition=0 doesn't affect team scores) still
// warrant a Discord notification when a drafted team is eliminated.
let scoredMatches: ScoredMatch[] | undefined;
let hasDraftedParticipantMatches = false;
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))
);
hasDraftedParticipantMatches = relevant.length > 0;
if (relevant.length > 0) {
const teamIdByParticipantId = new Map(
draftPicks.map((p) => [p.participantId, p.teamId])
);
const usernameForParticipant = (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 usernameByClerkId.get(ownerId);
};
scoredMatches = relevant
.filter((m) => m.winnerName && m.loserName)
.map((m) => ({
winnerName: m.winnerName!,
loserName: m.loserName!,
winnerUsername: usernameForParticipant(m.winnerId),
loserUsername: usernameForParticipant(m.loserId),
}));
}
}
// Skip notification if scores didn't change AND no drafted participants were scored.
if (!hasChanges && !hasDraftedParticipantMatches) continue;
const standings = afterStandings.map((s) => ({
teamId: s.teamId,
teamName: s.team.name,
username: usernameByClerkId.get(s.team.ownerId ?? ""),
totalPoints: parseFloat(s.totalPoints),
rank: s.currentRank,
}));
try {
await sendStandingsUpdateNotification({
webhookUrl,
seasonName: `${season.league.name} ${season.year}`,
standings,
previousStandings: previousPointsById,
sportName,
eventName: options?.eventName,
scoredMatches,
});
} catch (err) {
// Log but don't fail the scoring pipeline if Discord is unreachable
console.error(`[ScoringCalculator] Discord notification failed for season ${seasonId}:`, err);
}
}
console.log(
`[ScoringCalculator] Recalculated ${seasonIds.length} affected season(s) for sports season ${sportsSeasonId}`
);
}