When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1115 lines
35 KiB
TypeScript
1115 lines
35 KiB
TypeScript
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";
|
||
|
||
/**
|
||
* 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";
|
||
|
||
/**
|
||
* Process a playoff event completion and assign final placements.
|
||
*
|
||
* Loser placement rules per round type:
|
||
* Non-scoring rounds: losers get 0 pts (pre-bracket elimination)
|
||
* Quarterfinals / equivalent: losers share T5–T8
|
||
* Semifinals / equivalent: losers share T3–T4
|
||
* Finals / equivalent: loser gets 2nd, winner gets 1st
|
||
*
|
||
* Progressive floor scoring: winners immediately earn a provisional placement
|
||
* reflecting their worst-case finish from this point forward.
|
||
*/
|
||
export async function processPlayoffEvent(
|
||
eventId: string,
|
||
providedDb?: ReturnType<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: {
|
||
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}`);
|
||
}
|
||
|
||
// Non-scoring rounds: losers are pre-bracket eliminated (0 pts).
|
||
// Winners have cleared the qualifier and are guaranteed a top-8 finish,
|
||
// so we bank position 5 (T5–T8 floor) as their provisional score immediately.
|
||
// Using a fixed value of 5 is correct for standard brackets (R16 winners → QF → T5 worst case)
|
||
// and conservatively safe for non-standard cases (never over-promises points).
|
||
if (!isScoring) {
|
||
for (const match of matches) {
|
||
if (match.loserId) {
|
||
await upsertParticipantResult(
|
||
match.loserId,
|
||
event.sportsSeasonId,
|
||
0, // 0 = pre-bracket elimination, earns no points
|
||
db
|
||
);
|
||
}
|
||
if (match.winnerId) {
|
||
await upsertParticipantResult(
|
||
match.winnerId,
|
||
event.sportsSeasonId,
|
||
5, // provisional T5–T8 floor: guaranteed top-8 finish
|
||
db,
|
||
true
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// AFL-specific rounds (afl_10 bracket template)
|
||
else if (round === "Qualifying Finals") {
|
||
// AFL: Qualifying Finals losers get a second chance via Semi-Finals.
|
||
// They are not eliminated here, but earn a guaranteed minimum of 5th-6th.
|
||
// Winners are handled by the general guaranteed-minimum loop below (3rd-4th).
|
||
for (const match of matches) {
|
||
if (match.loserId) {
|
||
await upsertParticipantResult(
|
||
match.loserId,
|
||
event.sportsSeasonId,
|
||
5, // Guaranteed minimum: at worst 5th-6th via Semi-Finals
|
||
db,
|
||
true // isPartialScore: still competing
|
||
);
|
||
}
|
||
}
|
||
} else if (round === "Elimination Finals") {
|
||
// AFL: Elimination Finals losers share 7th-8th
|
||
for (const match of matches) {
|
||
if (match.loserId) {
|
||
await upsertParticipantResult(
|
||
match.loserId,
|
||
event.sportsSeasonId,
|
||
7, // Store as placement 7 (first shared placement for 7-8)
|
||
db
|
||
);
|
||
}
|
||
}
|
||
} else if (round === "Semi-Finals" && event.bracketTemplateId === "afl_10") {
|
||
// AFL: Semi-Finals losers share 5th-6th
|
||
for (const match of matches) {
|
||
if (match.loserId) {
|
||
await upsertParticipantResult(
|
||
match.loserId,
|
||
event.sportsSeasonId,
|
||
5, // Store as placement 5 (first shared placement for 5-6)
|
||
db
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// Standard playoff rounds
|
||
else if (round === "Finals" || round === "Championship" || round === "Super Bowl" || round === "NBA Finals" || round === "Grand Final") {
|
||
// Winner gets 1st, loser gets 2nd
|
||
const finalMatch = matches[0];
|
||
if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) {
|
||
throw new Error("Finals match is not complete");
|
||
}
|
||
|
||
// Update or create participant results for winner (finalized, isPartialScore=false)
|
||
await upsertParticipantResult(
|
||
finalMatch.winnerId,
|
||
event.sportsSeasonId,
|
||
1,
|
||
db
|
||
);
|
||
|
||
// Update or create participant results for loser (finalized, isPartialScore=false)
|
||
await upsertParticipantResult(
|
||
finalMatch.loserId,
|
||
event.sportsSeasonId,
|
||
2,
|
||
db
|
||
);
|
||
} else if (round === "Semifinals" || round === "Final Four" || round === "Conference Finals" || round === "Conference Championship" || round === "Preliminary Finals") {
|
||
// Losers share 3rd/4th
|
||
for (const match of matches) {
|
||
if (match.loserId) {
|
||
await upsertParticipantResult(
|
||
match.loserId,
|
||
event.sportsSeasonId,
|
||
3, // Store as placement 3 (the first shared placement)
|
||
db
|
||
);
|
||
}
|
||
}
|
||
} else if (round === "Quarterfinals" || round === "Elite Eight" || round === "Divisional" || round === "Conference Semifinals") {
|
||
// Losers share 5th-8th
|
||
for (const match of matches) {
|
||
if (match.loserId) {
|
||
await upsertParticipantResult(
|
||
match.loserId,
|
||
event.sportsSeasonId,
|
||
5, // Store as placement 5 (the first shared placement)
|
||
db
|
||
);
|
||
}
|
||
}
|
||
} else {
|
||
// Unrecognized scoring round: losers are outside the top-8 and earn 0 pts.
|
||
// This handles rounds like "Round of 16", "Round of 32" when isScoring=true,
|
||
// or any custom round name not explicitly mapped above.
|
||
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
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Progressive floor scoring: assign guaranteed minimum points to winners.
|
||
// Winners of any scoring round (except Finals, which already finalize both
|
||
// participants above) earn provisional points reflecting the worst-case
|
||
// placement they can achieve from here. These update as they advance.
|
||
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, score will increase as they advance
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Recalculate standings for all affected leagues
|
||
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
||
|
||
// 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
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 (winners are already being finalized as 1st place)
|
||
*/
|
||
export function getGuaranteedMinimumPosition(
|
||
round: string,
|
||
bracketTemplateId: string | null | undefined,
|
||
isScoring: boolean
|
||
): number | null {
|
||
// Non-scoring rounds: winning doesn't guarantee any points yet
|
||
if (!isScoring) return null;
|
||
|
||
// Finals: winner is finalized as 1st — handled inline, not via this helper
|
||
if (
|
||
round === "Finals" ||
|
||
round === "Championship" ||
|
||
round === "Super Bowl" ||
|
||
round === "NBA Finals" ||
|
||
round === "Grand Final"
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
// AFL-specific routing (afl_10 bracket template)
|
||
if (bracketTemplateId === "afl_10") {
|
||
// Qualifying Finals: winners → Prelim Finals (losers there get 3rd-4th)
|
||
if (round === "Qualifying Finals") return 3;
|
||
// Elimination Finals: winners → Semi-Finals (losers there get 5th-6th)
|
||
if (round === "Elimination Finals") return 5;
|
||
// Semi-Finals: winners → Prelim Finals (losers there get 3rd-4th)
|
||
if (round === "Semi-Finals") return 3;
|
||
}
|
||
|
||
// Standard bracket rounds
|
||
// Semi-final winners advance to the Final — guaranteed at worst 2nd place
|
||
if (
|
||
round === "Semifinals" ||
|
||
round === "Final Four" ||
|
||
round === "Conference Finals" ||
|
||
round === "Conference Championship" ||
|
||
round === "Preliminary Finals"
|
||
) {
|
||
return 2;
|
||
}
|
||
|
||
// Quarterfinal winners advance to Semis — guaranteed at worst 3rd-4th
|
||
if (
|
||
round === "Quarterfinals" ||
|
||
round === "Elite Eight" ||
|
||
round === "Divisional" ||
|
||
round === "Conference Semifinals"
|
||
) {
|
||
return 3;
|
||
}
|
||
|
||
// All other scoring rounds (e.g. Round of 16, Round of 32, etc.) —
|
||
// winners are now guaranteed a top-8 finish at worst (position 5)
|
||
return 5;
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
|
||
// 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 (F1/IndyCar) 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);
|
||
|
||
// 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;
|
||
|
||
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) {
|
||
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
|
||
const points = isBracket
|
||
? calculateBracketPoints(result.finalPosition, scoringRules)
|
||
: calculateFantasyPoints(result.finalPosition, scoringRules);
|
||
totalPoints += points;
|
||
participantsCompleted++;
|
||
|
||
// Track placement for tiebreakers
|
||
if (result.finalPosition >= 1 && result.finalPosition <= 8) {
|
||
placementCounts[result.finalPosition]++;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
totalPoints,
|
||
placementCounts,
|
||
participantsCompleted,
|
||
participantsTotal: picks.length,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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 }> = [];
|
||
|
||
// Separate finished vs unfinished participants
|
||
for (const pick of picks) {
|
||
const result = pick.participant.results[0];
|
||
|
||
if (result && result.finalPosition !== null) {
|
||
// Participant has finished - use actual points
|
||
const points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
||
actualPoints += points;
|
||
participantsFinished++;
|
||
} else {
|
||
// Participant is unfinished - will need EV
|
||
unfinishedParticipants.push({
|
||
participantId: pick.participant.id,
|
||
sportsSeasonId: pick.participant.sportsSeasonId,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 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 } 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);
|
||
evSum += leagueSpecificEV;
|
||
}
|
||
// If no EV exists, assume 0 (participant hasn't been evaluated yet)
|
||
}
|
||
}
|
||
|
||
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>
|
||
): 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);
|
||
|
||
// Recalculate each affected season
|
||
for (const seasonId of seasonIds) {
|
||
await recalculateStandings(seasonId, db);
|
||
}
|
||
|
||
console.log(
|
||
`[ScoringCalculator] Recalculated ${seasonIds.length} affected season(s) for sports season ${sportsSeasonId}`
|
||
);
|
||
}
|