brackt/app/models/scoring-calculator.ts

497 lines
15 KiB
TypeScript
Raw Normal View History

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm";
import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints } from "./scoring-rules";
import { getSeasonResults } from "./participant-season-result";
/**
* Core scoring calculation engine
* This file contains the logic for calculating fantasy points based on participant results
*/
export type ScoringPattern =
| "single_elimination_playoff"
| "page_playoff"
| "season_standings"
| "qualifying_points";
/**
* Process a playoff event completion and assign final placements
*
* Q19: Admin enters the same placement for all tied participants,
* system automatically shares positions
*
* Q20: Participants eliminated before Elite Eight get 0 points
*
* Phase 2.7: Updated to support template-based brackets with non-scoring rounds
*/
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
// Get season scoring rules for the first affected season
// (all seasons should have their own rules, but we use the event's sports season to find one)
const seasonSport = event.sportsSeason.seasonSports[0];
if (!seasonSport) {
throw new Error(`No fantasy seasons found for sports season ${event.sportsSeasonId}`);
}
const scoringRules = await getScoringRules(seasonSport.seasonId, db);
if (!scoringRules) {
throw new Error(`Scoring rules not found for season ${seasonSport.seasonId}`);
}
// If this is a non-scoring round, award 0 points (Q20)
if (!isScoring) {
for (const match of matches) {
if (match.loserId) {
await upsertParticipantResult(
match.loserId,
event.sportsSeasonId,
0, // 0 placement = 0 points for early elimination
db
);
}
}
}
// Otherwise, process scoring rounds based on standard playoff logic
else if (round === "Finals" || round === "Championship" || round === "Super Bowl" || round === "NBA Finals") {
// 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
await upsertParticipantResult(
finalMatch.winnerId,
event.sportsSeasonId,
1,
db
);
// Update or create participant results for loser
await upsertParticipantResult(
finalMatch.loserId,
event.sportsSeasonId,
2,
db
);
} else if (round === "Semifinals" || round === "Final Four" || round === "Conference Finals" || round === "Conference Championship") {
// 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
);
}
}
}
// Recalculate standings for all affected leagues
await recalculateAffectedLeagues(event.sportsSeasonId, db);
}
/**
* Helper to upsert participant result
*/
async function upsertParticipantResult(
participantId: string,
sportsSeasonId: string,
finalPosition: number,
db: ReturnType<typeof database>
): Promise<void> {
const existing = await db.query.participantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, participantId),
eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
),
});
if (existing) {
await db
.update(schema.participantResults)
.set({
finalPosition,
updatedAt: new Date(),
})
.where(eq(schema.participantResults.id, existing.id));
} else {
await db.insert(schema.participantResults).values({
participantId,
sportsSeasonId,
finalPosition,
});
}
}
/**
* Process a qualifying event completion and update QP totals
* Implementation will be added in Phase 3
*/
export async function processQualifyingEvent(
eventId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
// TODO: Implement in Phase 3
// 1. Get event results
// 2. Award qualifying points based on placement
// 3. Update participant_qualifying_totals
// 4. Check if all majors complete
// 5. Update QP standings displays
console.log(`[ScoringCalculator] processQualifyingEvent not yet implemented: ${eventId}`);
}
/**
* Finalize qualifying points and convert to fantasy placements
* Called manually by admin when all majors are complete
* Implementation will be added in Phase 3
*/
export async function finalizeQualifyingPoints(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
// TODO: Implement in Phase 3
// 1. Get all participants ranked by total QP
// 2. Handle ties in QP (share placements)
// 3. Assign final placements (1-8) based on QP ranking
// 4. Update participantResults with final placements
// 5. Mark sports season as finalized
// 6. Trigger recalculation for all affected leagues
console.log(
`[ScoringCalculator] finalizeQualifyingPoints not yet implemented: ${sportsSeasonId}`
);
}
/**
* Process season standings (F1) and assign final placements
*
* Q7: For F1, we show current F1 points during the season, then assign fantasy points at the end
* Q8: Just final standings, not individual races
* Q14: Store current running total, manual update with API future
* Q13/Q19: Ties handled by admin entering same position, system auto-shares 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);
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,
},
},
},
});
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) {
const result = pick.participant.results[0]; // Should only be one result per participant
if (result && result.finalPosition) {
const points = 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,
};
}
/**
* Recalculate standings for all teams in a season
* Called after any scoring event completes or participant results change
* Implementation will be completed in Phase 4
*/
export async function recalculateStandings(
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
// TODO: Complete implementation in Phase 4
// For now, calculate basic scores without rankings
const teams = await db.query.teams.findMany({
where: eq(schema.teams.seasonId, seasonId),
});
for (const team of teams) {
const teamScore = await calculateTeamScore(team.id, seasonId, db);
// Upsert team standings
const existing = await db.query.teamStandings.findFirst({
where: and(
eq(schema.teamStandings.teamId, team.id),
eq(schema.teamStandings.seasonId, seasonId)
),
});
if (existing) {
await db
.update(schema.teamStandings)
.set({
totalPoints: teamScore.totalPoints.toString(),
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,
calculatedAt: new Date(),
})
.where(eq(schema.teamStandings.id, existing.id));
} else {
await db.insert(schema.teamStandings).values({
teamId: team.id,
seasonId,
totalPoints: teamScore.totalPoints.toString(),
currentRank: 1, // Will be updated when we add ranking logic
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,
});
}
}
console.log(`[ScoringCalculator] Recalculated standings for season ${seasonId}`);
}
/**
* 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}`
);
}