- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats. - Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets. - Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly. - Enhanced database schema to accommodate new scoring rules and bracket templates. - Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly. - Documented implementation phases for gradual rollout of new features. - Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
393 lines
11 KiB
TypeScript
393 lines
11 KiB
TypeScript
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";
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
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
|
|
const matches = await db.query.playoffMatches.findMany({
|
|
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
|
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
|
|
});
|
|
|
|
// Process based on round
|
|
const round = event.playoffRound;
|
|
|
|
// 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 (round === "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") {
|
|
// 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") {
|
|
// 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
|
|
* Implementation will be added in Phase 3
|
|
*/
|
|
export async function processSeasonStandings(
|
|
sportsSeasonId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
): Promise<void> {
|
|
const db = providedDb || database();
|
|
|
|
// TODO: Implement in Phase 3
|
|
// 1. Get final season standings from participant_season_results
|
|
// 2. Assign final placements (1-8) based on standings
|
|
// 3. Update participantResults with final placements
|
|
// 4. Trigger recalculation for all affected leagues
|
|
|
|
console.log(
|
|
`[ScoringCalculator] processSeasonStandings not yet implemented: ${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}`
|
|
);
|
|
}
|