brackt/app/models/qualifying-points.ts
chrisp 6b0e32c3d5
Some checks failed
🚀 Deploy / 🧪 Test (push) Successful in 3m6s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m20s
🚀 Deploy / 🐳 Build (push) Failing after 40s
🚀 Deploy / 🚀 Deploy (push) Has been skipped
claude/discord-qp-notifications-75wa7k (#122)
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #122
2026-07-01 22:14:51 +00:00

460 lines
13 KiB
TypeScript

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, desc, sql } from "drizzle-orm";
/**
* Default qualifying point values (as specified in scoring-system.md)
*/
export const DEFAULT_QP_VALUES = [
{ placement: 1, points: 20 },
{ placement: 2, points: 14 },
{ placement: 3, points: 10 },
{ placement: 4, points: 8 },
{ placement: 5, points: 5 },
{ placement: 6, points: 5 },
{ placement: 7, points: 3 },
{ placement: 8, points: 3 },
{ placement: 9, points: 2 },
{ placement: 10, points: 2 },
{ placement: 11, points: 2 },
{ placement: 12, points: 2 },
{ placement: 13, points: 1 },
{ placement: 14, points: 1 },
{ placement: 15, points: 1 },
{ placement: 16, points: 1 },
];
export interface QualifyingPointConfigData {
placement: number;
points: number;
}
export function roundQualifyingPoints(points: number): number {
return Math.round((points + Number.EPSILON) * 100) / 100;
}
/**
* Given a participant's awarded QP before and after a re-score, return the ids
* whose QP changed. A row's `qp` is the raw decimal string (or null when no QP
* was awarded). A participant counts as changed when their new value differs from
* the old, including a first-time score (null → value). Participants absent from
* `after` are not reported (their removal is handled by the caller's recalc).
*
* Shared by the two QP re-score paths — processQualifyingEvent (sibling windows,
* CS2, manual admin) and rescoreTennisBracketAndDetectChanges (tennis primary) —
* so the "only announce real changes" semantics live in one place.
*/
export function diffChangedQualifyingPoints(
before: Iterable<{ id: string; qp: string | null }>,
after: Iterable<{ id: string; qp: string | null }>
): Set<string> {
const beforeQP = new Map<string, number>();
for (const r of before) {
if (r.qp !== null) beforeQP.set(r.id, parseFloat(r.qp));
}
const changed = new Set<string>();
for (const r of after) {
if (r.qp === null) continue;
if (beforeQP.get(r.id) !== parseFloat(r.qp)) changed.add(r.id);
}
return changed;
}
export function calculateSplitQualifyingPoints(
placement: number,
tieCount: number,
pointsByPlacement: Map<number, number>
): number {
if (placement <= 0 || tieCount <= 0) return 0;
let totalQP = 0;
for (let pos = placement; pos < placement + tieCount; pos++) {
totalQP += pointsByPlacement.get(pos) ?? 0;
}
return roundQualifyingPoints(totalQP / tieCount);
}
export function hasProcessedQualifyingPlacement(
results: Array<{
placement?: number | null;
qualifyingPointsAwarded?: string | null;
}>
): boolean {
return results.some(
(result) =>
result.placement !== null &&
result.placement !== undefined &&
result.qualifyingPointsAwarded !== null &&
result.qualifyingPointsAwarded !== undefined
);
}
/**
* Initialize default qualifying point configuration for a sports season
*/
export async function initializeQPConfig(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
// Check if config already exists
const existing = await db.query.qualifyingPointConfig.findMany({
where: eq(schema.qualifyingPointConfig.sportsSeasonId, sportsSeasonId),
});
if (existing.length > 0) {
return existing;
}
// Insert default config
const configs = await db
.insert(schema.qualifyingPointConfig)
.values(
DEFAULT_QP_VALUES.map((qp) => ({
sportsSeasonId,
placement: qp.placement,
points: qp.points.toString(),
}))
)
.returning();
return configs;
}
/**
* Get qualifying point configuration for a sports season
*/
export async function getQPConfig(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const configs = await db.query.qualifyingPointConfig.findMany({
where: eq(schema.qualifyingPointConfig.sportsSeasonId, sportsSeasonId),
orderBy: [schema.qualifyingPointConfig.placement],
});
// If no config exists, initialize with defaults
if (configs.length === 0) {
return await initializeQPConfig(sportsSeasonId, db);
}
return configs;
}
/**
* Update qualifying point configuration for a sports season
*/
export async function updateQPConfig(
sportsSeasonId: string,
configData: QualifyingPointConfigData[],
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
// Delete existing config
await db
.delete(schema.qualifyingPointConfig)
.where(eq(schema.qualifyingPointConfig.sportsSeasonId, sportsSeasonId));
// Insert new config
const configs = await db
.insert(schema.qualifyingPointConfig)
.values(
configData.map((qp) => ({
sportsSeasonId,
placement: qp.placement,
points: qp.points.toString(),
}))
)
.returning();
return configs;
}
/**
* Get qualifying points awarded for a specific placement
*/
export async function getQPForPlacement(
sportsSeasonId: string,
placement: number,
providedDb?: ReturnType<typeof database>
): Promise<number> {
const db = providedDb || database();
const config = await db.query.qualifyingPointConfig.findFirst({
where: and(
eq(schema.qualifyingPointConfig.sportsSeasonId, sportsSeasonId),
eq(schema.qualifyingPointConfig.placement, placement)
),
});
return config ? parseFloat(config.points) : 0;
}
/**
* Get or create participant qualifying total record
*/
export async function getOrCreateParticipantQPTotal(
participantId: string,
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
let total = await db.query.seasonParticipantQualifyingTotals.findFirst({
where: and(
eq(schema.seasonParticipantQualifyingTotals.participantId, participantId),
eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId)
),
});
if (!total) {
const [created] = await db
.insert(schema.seasonParticipantQualifyingTotals)
.values({
participantId,
sportsSeasonId,
totalQualifyingPoints: "0",
eventsScored: 0,
})
.returning();
total = created;
}
return total;
}
/**
* Add qualifying points to a participant's total
* NOTE: eventsScored is NOT updated here - it should be recalculated after processing events
*/
export async function addQualifyingPoints(
participantId: string,
sportsSeasonId: string,
pointsToAdd: number,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
// Get or create the total record
const total = await getOrCreateParticipantQPTotal(participantId, sportsSeasonId, db);
// Update with new points (do NOT increment eventsScored here)
const [updated] = await db
.update(schema.seasonParticipantQualifyingTotals)
.set({
totalQualifyingPoints: (parseFloat(total.totalQualifyingPoints) + pointsToAdd).toString(),
updatedAt: new Date(),
})
.where(eq(schema.seasonParticipantQualifyingTotals.id, total.id))
.returning();
return updated;
}
/**
* Recalculate total QP and eventsScored for a participant from scratch
* This is the source of truth - sums all event_results with QP awarded
*/
export async function recalculateParticipantQP(
participantId: string,
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
// Get all event results for this participant in this sports season
const eventResults = await db.query.eventResults.findMany({
where: eq(schema.eventResults.seasonParticipantId, participantId),
with: {
scoringEvent: true,
},
});
// Calculate totals from event_results
let totalQP = 0;
const uniqueEvents = new Set<string>();
for (const result of eventResults) {
if (
result.scoringEvent.sportsSeasonId === sportsSeasonId &&
result.qualifyingPointsAwarded
) {
const qp = parseFloat(result.qualifyingPointsAwarded);
if (qp > 0) {
totalQP += qp;
uniqueEvents.add(result.scoringEvent.id);
}
}
}
const eventsScored = uniqueEvents.size;
// Get or create the participant's QP total record
const total = await getOrCreateParticipantQPTotal(participantId, sportsSeasonId, db);
// Update with recalculated values
await db
.update(schema.seasonParticipantQualifyingTotals)
.set({
totalQualifyingPoints: totalQP.toString(),
eventsScored,
updatedAt: new Date(),
})
.where(eq(schema.seasonParticipantQualifyingTotals.id, total.id));
return { totalQP, eventsScored };
}
/**
* Upsert qualifying points + placement into event_results for a set of participants,
* then recalculate each participant's QP total.
*
* Shared by the QP writers (assignCs2EliminationQP for Swiss-stage exits,
* processQualifyingBracketEvent for Champions-Stage / knockout brackets) so the
* upsert + recalc loop lives in one place. Upserts on
* (scoringEventId, seasonParticipantId), so repeated calls overwrite cleanly.
*
* @param entries Map from seasonParticipantId → { qp, placement } to write.
*/
export async function writeEventResultsQP(
scoringEventId: string,
sportsSeasonId: string,
entries: Map<string, { qp: number; placement: number }>,
providedDb?: ReturnType<typeof database>
): Promise<void> {
if (entries.size === 0) return;
const db = providedDb || database();
const now = new Date();
await Promise.all(
[...entries.entries()].map(([seasonParticipantId, { qp, placement }]) =>
db
.insert(schema.eventResults)
.values({
scoringEventId,
seasonParticipantId,
qualifyingPointsAwarded: qp.toFixed(2),
placement,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [schema.eventResults.scoringEventId, schema.eventResults.seasonParticipantId],
set: {
qualifyingPointsAwarded: qp.toFixed(2),
placement,
updatedAt: now,
},
})
)
);
await Promise.all(
[...entries.keys()].map((seasonParticipantId) =>
recalculateParticipantQP(seasonParticipantId, sportsSeasonId, db)
)
);
}
/**
* Recalculate eventsScored for a participant by counting unique events with QP awarded
* @deprecated Use recalculateParticipantQP instead for more accurate recalculation
*/
export async function recalculateEventsScored(
participantId: string,
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const result = await recalculateParticipantQP(participantId, sportsSeasonId, providedDb);
return result.eventsScored;
}
/**
* Get all qualifying totals for a sports season, ordered by points (highest first)
*/
export async function getQPStandings(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return await db.query.seasonParticipantQualifyingTotals.findMany({
where: eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId),
orderBy: [desc(sql`CAST(${schema.seasonParticipantQualifyingTotals.totalQualifyingPoints} AS DECIMAL)`)],
with: {
participant: true,
},
});
}
/**
* Update final rankings for qualifying point standings (after finalization)
*/
export async function updateFinalRankings(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const standings = await getQPStandings(sportsSeasonId, db);
// Group participants by QP total to handle ties
const groupedByPoints = new Map<string, typeof standings>();
for (const standing of standings) {
const points = standing.totalQualifyingPoints;
if (!groupedByPoints.has(points)) {
groupedByPoints.set(points, []);
}
groupedByPoints.get(points)?.push(standing);
}
// Assign rankings, handling ties
let currentRank = 1;
const updates = [];
// Sort by points descending
const sortedGroups = Array.from(groupedByPoints.entries()).toSorted((a, b) => {
return parseFloat(b[0]) - parseFloat(a[0]);
});
for (const [_, group] of sortedGroups) {
// All participants in this group get the same rank
for (const standing of group) {
updates.push({
id: standing.id,
finalRanking: currentRank,
});
}
// Increment rank by the size of the group
currentRank += group.length;
}
// Update all rankings
for (const update of updates) {
await db
.update(schema.seasonParticipantQualifyingTotals)
.set({ finalRanking: update.finalRanking, updatedAt: new Date() })
.where(eq(schema.seasonParticipantQualifyingTotals.id, update.id));
}
return updates;
}
/**
* Reset qualifying points for a sports season (useful for testing or corrections)
*/
export async function resetQualifyingPoints(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
await db
.delete(schema.seasonParticipantQualifyingTotals)
.where(eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId));
}