2025-10-28 23:50:50 -07:00
|
|
|
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";
|
2025-11-08 22:35:07 -08:00
|
|
|
import { getSeasonResults } from "./participant-season-result";
|
2025-11-21 22:05:50 -08:00
|
|
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Core scoring calculation engine
|
|
|
|
|
* This file contains the logic for calculating fantasy points based on participant results
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export type ScoringPattern =
|
2026-03-07 21:59:29 -08:00
|
|
|
| "playoff_bracket"
|
2025-10-28 23:50:50 -07:00
|
|
|
| "season_standings"
|
|
|
|
|
| "qualifying_points";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Process a playoff event completion and assign final placements
|
feat: Implement bracket expansion plan to support various tournament structures
- 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.
2025-11-03 09:36:16 -08:00
|
|
|
*
|
|
|
|
|
* Q19: Admin enters the same placement for all tied participants,
|
|
|
|
|
* system automatically shares positions
|
|
|
|
|
*
|
|
|
|
|
* Q20: Participants eliminated before Elite Eight get 0 points
|
2025-11-04 22:09:44 -08:00
|
|
|
*
|
|
|
|
|
* Phase 2.7: Updated to support template-based brackets with non-scoring rounds
|
2025-10-28 23:50:50 -07:00
|
|
|
*/
|
|
|
|
|
export async function processPlayoffEvent(
|
|
|
|
|
eventId: string,
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
feat: Implement bracket expansion plan to support various tournament structures
- 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.
2025-11-03 09:36:16 -08:00
|
|
|
// Get the event
|
|
|
|
|
const event = await db.query.scoringEvents.findFirst({
|
|
|
|
|
where: eq(schema.scoringEvents.id, eventId),
|
|
|
|
|
with: {
|
|
|
|
|
sportsSeason: {
|
|
|
|
|
with: {
|
|
|
|
|
seasonSports: {
|
|
|
|
|
with: {
|
|
|
|
|
season: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
2025-10-28 23:50:50 -07:00
|
|
|
|
feat: Implement bracket expansion plan to support various tournament structures
- 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.
2025-11-03 09:36:16 -08:00
|
|
|
if (!event) {
|
|
|
|
|
throw new Error(`Event ${eventId} not found`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!event.playoffRound) {
|
|
|
|
|
throw new Error(`Event ${eventId} is not a playoff event`);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 22:09:44 -08:00
|
|
|
// Get all matches for this event in the specified round
|
feat: Implement bracket expansion plan to support various tournament structures
- 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.
2025-11-03 09:36:16 -08:00
|
|
|
const matches = await db.query.playoffMatches.findMany({
|
2025-11-04 22:09:44 -08:00
|
|
|
where: and(
|
|
|
|
|
eq(schema.playoffMatches.scoringEventId, eventId),
|
|
|
|
|
eq(schema.playoffMatches.round, event.playoffRound)
|
|
|
|
|
),
|
feat: Implement bracket expansion plan to support various tournament structures
- 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.
2025-11-03 09:36:16 -08:00
|
|
|
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Process based on round
|
|
|
|
|
const round = event.playoffRound;
|
|
|
|
|
|
2025-11-04 22:09:44 -08:00
|
|
|
// 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
|
|
|
|
|
|
2025-11-21 22:05:50 -08:00
|
|
|
// 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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat: Implement bracket expansion plan to support various tournament structures
- 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.
2025-11-03 09:36:16 -08:00
|
|
|
// 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}`);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 22:09:44 -08:00
|
|
|
// 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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-12 23:21:22 -08:00
|
|
|
// AFL-specific rounds (Phase 3.3)
|
|
|
|
|
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") {
|
feat: Implement bracket expansion plan to support various tournament structures
- 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.
2025-11-03 09:36:16 -08:00
|
|
|
// 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
|
|
|
|
|
);
|
2025-11-12 23:21:22 -08:00
|
|
|
} else if (round === "Semifinals" || round === "Final Four" || round === "Conference Finals" || round === "Conference Championship" || round === "Preliminary Finals") {
|
feat: Implement bracket expansion plan to support various tournament structures
- 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.
2025-11-03 09:36:16 -08:00
|
|
|
// 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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-04 22:09:44 -08:00
|
|
|
} else if (round === "Quarterfinals" || round === "Elite Eight" || round === "Divisional" || round === "Conference Semifinals") {
|
feat: Implement bracket expansion plan to support various tournament structures
- 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.
2025-11-03 09:36:16 -08:00
|
|
|
// 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);
|
2025-11-21 22:05:50 -08:00
|
|
|
|
|
|
|
|
// Auto-trigger probability recalculation (Phase 5.3)
|
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
}
|
feat: Implement bracket expansion plan to support various tournament structures
- 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.
2025-11-03 09:36:16 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-28 23:50:50 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Process a qualifying event completion and update QP totals
|
2025-11-11 10:08:25 -08:00
|
|
|
* Phase 3.2 Implementation
|
|
|
|
|
*
|
|
|
|
|
* Q4: Manual finalization after all majors complete
|
|
|
|
|
* Q5: Ties in QP handled by sharing placements
|
2025-10-28 23:50:50 -07:00
|
|
|
*/
|
|
|
|
|
export async function processQualifyingEvent(
|
|
|
|
|
eventId: string,
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
2025-11-11 10:08:25 -08:00
|
|
|
// 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));
|
|
|
|
|
}
|
2025-10-28 23:50:50 -07:00
|
|
|
|
2025-11-11 10:08:25 -08:00
|
|
|
console.log(
|
|
|
|
|
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
|
|
|
|
|
);
|
2025-10-28 23:50:50 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Finalize qualifying points and convert to fantasy placements
|
|
|
|
|
* Called manually by admin when all majors are complete
|
2025-11-11 10:08:25 -08:00
|
|
|
* Phase 3.2 Implementation
|
|
|
|
|
*
|
|
|
|
|
* Q4: Manual finalization (admin button)
|
|
|
|
|
* Q5: QP ties handled by sharing placements
|
|
|
|
|
* Q19: Tie entry uses same placement, system auto-calculates shared positions
|
2025-10-28 23:50:50 -07:00
|
|
|
*/
|
|
|
|
|
export async function finalizeQualifyingPoints(
|
|
|
|
|
sportsSeasonId: string,
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
2025-11-11 10:08:25 -08:00
|
|
|
// 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);
|
2025-10-28 23:50:50 -07:00
|
|
|
|
2025-11-21 22:05:50 -08:00
|
|
|
// Auto-trigger probability recalculation (Phase 5.3)
|
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
console.log(
|
2025-11-11 10:08:25 -08:00
|
|
|
`[ScoringCalculator] Finalized qualifying points for sports season ${sportsSeasonId}: ${standings.length} participants processed`
|
2025-10-28 23:50:50 -07:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Process season standings (F1) and assign final placements
|
2025-11-08 22:35:07 -08:00
|
|
|
*
|
|
|
|
|
* 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
|
2025-10-28 23:50:50 -07:00
|
|
|
*/
|
|
|
|
|
export async function processSeasonStandings(
|
|
|
|
|
sportsSeasonId: string,
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
2025-11-08 22:35:07 -08:00
|
|
|
// 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);
|
2025-10-28 23:50:50 -07:00
|
|
|
|
2025-11-21 22:05:50 -08:00
|
|
|
// Auto-trigger probability recalculation (Phase 5.3)
|
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
console.log(
|
2025-11-08 22:35:07 -08:00
|
|
|
`[ScoringCalculator] Processed season standings for sports season ${sportsSeasonId}`
|
2025-10-28 23:50:50 -07:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 22:30:12 -08:00
|
|
|
/**
|
|
|
|
|
* Calculate projected total points for a team
|
|
|
|
|
* Phase 5.4: Includes actual points from finished participants + EVs from unfinished
|
|
|
|
|
*/
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
/**
|
|
|
|
|
* Recalculate standings for all teams in a season
|
|
|
|
|
* Called after any scoring event completes or participant results change
|
2025-11-13 13:24:03 -08:00
|
|
|
*
|
|
|
|
|
* Phase 4.1: Implements tiebreaker logic and ranking
|
2025-10-28 23:50:50 -07:00
|
|
|
*/
|
|
|
|
|
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),
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
// Calculate scores for all teams
|
|
|
|
|
const teamScores = await Promise.all(
|
|
|
|
|
teams.map(async (team) => {
|
|
|
|
|
const score = await calculateTeamScore(team.id, seasonId, db);
|
2026-02-14 22:30:12 -08:00
|
|
|
const projected = await calculateTeamProjectedScore(team.id, seasonId, db);
|
2025-11-13 13:24:03 -08:00
|
|
|
return {
|
|
|
|
|
teamId: team.id,
|
|
|
|
|
totalPoints: score.totalPoints,
|
|
|
|
|
placementCounts: score.placementCounts,
|
|
|
|
|
participantsCompleted: score.participantsCompleted,
|
|
|
|
|
participantsTotal: score.participantsTotal,
|
2026-02-14 22:30:12 -08:00
|
|
|
actualPoints: projected.actualPoints,
|
|
|
|
|
projectedPoints: projected.projectedPoints,
|
|
|
|
|
participantsFinished: projected.participantsFinished,
|
2025-11-13 13:24:03 -08:00
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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;
|
2025-10-28 23:50:50 -07:00
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
// Get existing standing to preserve previousRank
|
2025-10-28 23:50:50 -07:00
|
|
|
const existing = await db.query.teamStandings.findFirst({
|
|
|
|
|
where: and(
|
2025-11-13 13:24:03 -08:00
|
|
|
eq(schema.teamStandings.teamId, teamScore.teamId),
|
2025-10-28 23:50:50 -07:00
|
|
|
eq(schema.teamStandings.seasonId, seasonId)
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
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,
|
2026-02-14 22:30:12 -08:00
|
|
|
// Phase 5.4: Projected points tracking
|
|
|
|
|
actualPoints: teamScore.actualPoints?.toString() ?? null,
|
|
|
|
|
projectedPoints: teamScore.projectedPoints?.toString() ?? null,
|
|
|
|
|
participantsFinished: teamScore.participantsFinished ?? null,
|
2025-11-13 13:24:03 -08:00
|
|
|
calculatedAt: new Date(),
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
if (existing) {
|
|
|
|
|
await db
|
|
|
|
|
.update(schema.teamStandings)
|
2025-11-13 13:24:03 -08:00
|
|
|
.set(standingData)
|
2025-10-28 23:50:50 -07:00
|
|
|
.where(eq(schema.teamStandings.id, existing.id));
|
|
|
|
|
} else {
|
|
|
|
|
await db.insert(schema.teamStandings).values({
|
2025-11-13 13:24:03 -08:00
|
|
|
teamId: teamScore.teamId,
|
2025-10-28 23:50:50 -07:00
|
|
|
seasonId,
|
2025-11-13 13:24:03 -08:00
|
|
|
...standingData,
|
2025-10-28 23:50:50 -07:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
console.log(`[ScoringCalculator] Recalculated standings for season ${seasonId}: ${teams.length} teams ranked`);
|
2025-10-28 23:50:50 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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}`
|
|
|
|
|
);
|
|
|
|
|
}
|