Four related bugs in the Qualifying Points subsystem surfaced on Wimbledon: 1. Some leagues stored 2 QP instead of 1.5 for Round-of-16 losers. Sibling/ mirror windows scored via the placement-group path in processQualifyingEvent, which took the tie span from the players present on that window's roster (a draftable subset) instead of the full field. A window holding fewer than the 8 tied R16 losers split the 9-16 points too few ways and over-awarded. The tie span is now derived from the canonical tournament_results (the whole field) for tournament-linked events, falling back to the live count only for standalone events. Every league now scores identically. 2. Discord notifications rounded QP with Math.round, turning 1.5 into "2". Both the "Points Awarded" and "QP Standings" values now use a 2-decimal formatter mirroring the web UI's formatQP, so Discord and the site agree. 3. The Discord "QP Standings" block ranked players only among that event's scorers, showing two R16 losers as T1 instead of T9. Rank is now computed over the full season field and passed through to the notifier. 4. "N of M majors completed" reached "11 of 4": majorsCompleted was a stored counter incremented on every fan-out sync with a guard that misfired. It is now derived on read (getMajorsCompleted = count of completed qualifying events), which is self-correcting; the increment/decrement writes are removed along with the now-unused hasProcessedQualifyingPlacement helper. Adds scripts/backfill-qp-resplit.ts to silently re-score existing majors so already-corrupted seasons are corrected (2 -> 1.5), and threads a skipNotifications option through processQualifyingEvent / syncTournamentResults so the backfill does not re-ping every league. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
445 lines
13 KiB
TypeScript
445 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);
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|