brackt/app/models/scoring-calculator.ts

1116 lines
35 KiB
TypeScript
Raw Normal View History

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm";
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
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";
/**
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
* Process a playoff event completion and assign final placements.
*
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
* 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
*
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
* 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}`);
}
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
// 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 (T5T8 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,
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
0, // 0 = pre-bracket elimination, earns no points
db
);
}
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
if (match.winnerId) {
await upsertParticipantResult(
match.winnerId,
event.sportsSeasonId,
5, // provisional T5T8 floor: guaranteed top-8 finish
db,
true
);
}
}
}
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
// 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");
}
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
// Update or create participant results for winner (finalized, isPartialScore=false)
await upsertParticipantResult(
finalMatch.winnerId,
event.sportsSeasonId,
1,
db
);
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
// 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
);
}
}
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
} 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);
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
// 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
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
*
* 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,
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
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) {
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
// 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,
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
isPartialScore,
updatedAt: new Date(),
})
.where(eq(schema.participantResults.id, existing.id));
} else {
await db.insert(schema.participantResults).values({
participantId,
sportsSeasonId,
finalPosition,
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
isPartialScore,
});
}
}
/**
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
* Returns the guaranteed minimum final position for winners of a given round.
*
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
* 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`
);
}
/**
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
* 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);
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
// 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.
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
* 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);
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
// 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,
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
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) {
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
// 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) {
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
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,
};
}
/**
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
* 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;
}
/**
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
* 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}`
);
}