From efec504c0804f37250ebb500e07d576d3d664e99 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 20:40:47 +0000 Subject: [PATCH] Fix qualifying points scoring, Discord rounding, and majors count 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 Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4 --- .../__tests__/qualifying-points.test.ts | 47 ------ .../scoring-calculator-qualifying.test.ts | 92 +++++++++++- .../__tests__/scoring-event-delete.test.ts | 15 +- app/models/qualifying-points.ts | 15 -- app/models/scoring-calculator.ts | 51 ++++--- app/models/scoring-event.ts | 46 ++++-- ...sons.$id.events.$eventId.bracket.server.ts | 8 +- .../admin.sports-seasons.$id.events.server.ts | 9 +- ...d.sports-seasons.$sportsSeasonId.server.ts | 7 +- app/services/__tests__/discord.test.ts | 121 +++++++++++++--- .../__tests__/sync-tournament-results.test.ts | 8 +- app/services/discord.ts | 43 +++--- .../qualifying-points-discord.server.ts | 26 ++++ app/services/sync-tournament-results.ts | 21 ++- scripts/backfill-qp-resplit.ts | 136 ++++++++++++++++++ 15 files changed, 491 insertions(+), 154 deletions(-) create mode 100644 scripts/backfill-qp-resplit.ts diff --git a/app/models/__tests__/qualifying-points.test.ts b/app/models/__tests__/qualifying-points.test.ts index b74498a..dd262aa 100644 --- a/app/models/__tests__/qualifying-points.test.ts +++ b/app/models/__tests__/qualifying-points.test.ts @@ -3,7 +3,6 @@ import { calculateSplitQualifyingPoints, DEFAULT_QP_VALUES, diffChangedQualifyingPoints, - hasProcessedQualifyingPlacement, } from "../qualifying-points"; describe("diffChangedQualifyingPoints", () => { @@ -310,52 +309,6 @@ describe("Qualifying Points Configuration", () => { expect(totalQP2).toBe(20); // Should have 20 QP (1st place) }); - it("should not increment majorsCompleted when reprocessing", () => { - // First processing - let majorsCompleted = 0; - const wasAlreadyProcessed = false; - - if (!wasAlreadyProcessed) { - majorsCompleted += 1; - } - - expect(majorsCompleted).toBe(1); - - // Reprocessing (wasAlreadyProcessed = true) - const reprocessing = true; - - if (!reprocessing) { - majorsCompleted += 1; - } - - expect(majorsCompleted).toBe(1); // Should still be 1, not 2 - }); - }); - - describe("Processed event detection", () => { - it("treats a placed zero-QP result as already processed", () => { - expect( - hasProcessedQualifyingPlacement([ - { placement: 20, qualifyingPointsAwarded: "0.00" }, - ]) - ).toBe(true); - }); - - it("does not treat filler zero-QP rows without placements as processed", () => { - expect( - hasProcessedQualifyingPlacement([ - { placement: null, qualifyingPointsAwarded: "0" }, - ]) - ).toBe(false); - }); - - it("does not treat unprocessed placed rows as processed", () => { - expect( - hasProcessedQualifyingPlacement([ - { placement: 15, qualifyingPointsAwarded: null }, - ]) - ).toBe(false); - }); }); describe("Scoring Workflow", () => { diff --git a/app/models/__tests__/scoring-calculator-qualifying.test.ts b/app/models/__tests__/scoring-calculator-qualifying.test.ts index f2ef65f..2bd14cf 100644 --- a/app/models/__tests__/scoring-calculator-qualifying.test.ts +++ b/app/models/__tests__/scoring-calculator-qualifying.test.ts @@ -200,7 +200,14 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { }); describe("processQualifyingEvent", () => { - function makeProcessQPMockDb(eventResults: Array>) { + function makeProcessQPMockDb( + eventResults: Array>, + options: { + tournamentId?: string | null; + canonicalPlacements?: number[]; + } = {} + ) { + const { tournamentId = null, canonicalPlacements = [] } = options; const setCalls: unknown[] = []; const updateChain = { set: (values: unknown) => { @@ -217,6 +224,7 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { id: "event-1", sportsSeasonId: "sports-season-1", isQualifyingEvent: true, + tournamentId, sportsSeason: { majorsCompleted: 1 }, }), }, @@ -238,6 +246,13 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { }), }, }, + // Canonical tournament_results lookup for the structural tie span: + // db.select({placement}).from(tournamentResults).where(...) → rows. + select: () => ({ + from: () => ({ + where: async () => canonicalPlacements.map((placement) => ({ placement })), + }), + }), update: () => updateChain, } as any; @@ -273,6 +288,81 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { ); }); + it("splits a tied placement by the FULL canonical field, not the window's roster subset", async () => { + // Regression for the reported 2-vs-1.5 QP bug. Tennis Round-of-16 losers all + // land at placement 9 with a structural tie span of 8 (positions 9–16 → + // (2+2+2+2+1+1+1+1)/8 = 1.5). A sibling/mirror window only holds the drafted + // subset — here just 2 of the 8 tied players — but the split must still use 8, + // not the 2 present locally (which would wrongly give (2+2)/2 = 2.00). + const windowRows = [ + { + id: "result-1", + seasonParticipantId: "participant-1", + placement: 9, + qualifyingPointsAwarded: null, + scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" }, + }, + { + id: "result-2", + seasonParticipantId: "participant-2", + placement: 9, + qualifyingPointsAwarded: null, + scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" }, + }, + ]; + const { db, setCalls } = makeProcessQPMockDb(windowRows, { + tournamentId: "tournament-1", + canonicalPlacements: Array.from({ length: 8 }, () => 9), // full field: 8 at 9th + }); + + await processQualifyingEvent("event-1", db, { skipNotifications: true }); + + // Both present players earn the correct split of 1.50, not 2.00. + expect(setCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ qualifyingPointsAwarded: "1.50" }), + ]) + ); + expect(setCalls).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ qualifyingPointsAwarded: "2.00" }), + ]) + ); + }); + + it("falls back to the live roster count for standalone events with no tournament", async () => { + // No canonical field exists for a manual/standalone qualifying event, so the + // tie span is the players actually present: 2 players tied at 9th → + // (2+2)/2 = 2.00. This preserves existing behavior where there is no full field. + const windowRows = [ + { + id: "result-1", + seasonParticipantId: "participant-1", + placement: 9, + qualifyingPointsAwarded: null, + scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" }, + }, + { + id: "result-2", + seasonParticipantId: "participant-2", + placement: 9, + qualifyingPointsAwarded: null, + scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" }, + }, + ]; + const { db, setCalls } = makeProcessQPMockDb(windowRows, { + tournamentId: null, + }); + + await processQualifyingEvent("event-1", db, { skipNotifications: true }); + + expect(setCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ qualifyingPointsAwarded: "2.00" }), + ]) + ); + }); + it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => { const { db, setCalls } = makeProcessQPMockDb([ { diff --git a/app/models/__tests__/scoring-event-delete.test.ts b/app/models/__tests__/scoring-event-delete.test.ts index 81e98f7..51804c1 100644 --- a/app/models/__tests__/scoring-event-delete.test.ts +++ b/app/models/__tests__/scoring-event-delete.test.ts @@ -15,7 +15,10 @@ vi.mock("../qualifying-points", async (importOriginal) => { import { deleteScoringEvent } from "../scoring-event"; describe("deleteScoringEvent", () => { - it("decrements majorsCompleted for a processed zero-QP qualifying event", async () => { + it("does not write majorsCompleted on delete (it is derived on read)", async () => { + // majorsCompleted is no longer a stored counter — it is computed via + // getMajorsCompleted from completed qualifying events. Deleting a qualifying + // event must therefore never issue a sportsSeasons.majorsCompleted update. const setCalls: unknown[] = []; const deleteChain = { where: vi.fn().mockResolvedValue(undefined) }; const updateChain = { @@ -43,12 +46,6 @@ describe("deleteScoringEvent", () => { }, ]), }, - sportsSeasons: { - findFirst: vi.fn().mockResolvedValue({ - id: "sports-season-1", - majorsCompleted: 1, - }), - }, }, transaction: vi.fn(async (callback) => callback(db)), delete: vi.fn(() => deleteChain), @@ -57,9 +54,9 @@ describe("deleteScoringEvent", () => { await deleteScoringEvent("event-1", db); - expect(setCalls).toEqual( + expect(setCalls).not.toEqual( expect.arrayContaining([ - expect.objectContaining({ majorsCompleted: 0 }), + expect.objectContaining({ majorsCompleted: expect.anything() }), ]) ); }); diff --git a/app/models/qualifying-points.ts b/app/models/qualifying-points.ts index 5812f33..722ffbd 100644 --- a/app/models/qualifying-points.ts +++ b/app/models/qualifying-points.ts @@ -75,21 +75,6 @@ export function calculateSplitQualifyingPoints( 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 */ diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 190f1eb..59cd114 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -23,7 +23,6 @@ import { calculateSplitQualifyingPoints, diffChangedQualifyingPoints, getQPConfig, - hasProcessedQualifyingPlacement, recalculateParticipantQP, writeEventResultsQP, getQPStandings, @@ -854,7 +853,8 @@ export async function processQualifyingBracketEvent( */ export async function processQualifyingEvent( eventId: string, - providedDb?: ReturnType + providedDb?: ReturnType, + options: { skipNotifications?: boolean } = {} ): Promise { const db = providedDb || database(); @@ -877,9 +877,6 @@ export async function processQualifyingEvent( // 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 = hasProcessedQualifyingPlacement(results); - // Snapshot awarded QP before reprocessing so the Discord notification below can // announce only the participants whose QP actually changed. Without this, a // sibling window re-scored on every fan-out sync (syncTournamentResults) would @@ -914,6 +911,31 @@ export async function processQualifyingEvent( qpConfig.map((config) => [config.placement, parseFloat(config.points)]) ); + // Full-field tie span. The number of players tied at a placement is a property + // of the whole tournament field (canonical tournament_results), NOT of who + // happens to be on THIS window's roster. Sibling/mirror windows only hold the + // draftable subset of the field, so counting the placements present locally + // (group.length) under-counts a tied group and over-awards it: tennis R16 losers + // all sit at placement 9 with a structural span of 8 → (2+2+2+2+1+1+1+1)/8 = 1.5 + // QP; a window holding only 4 of them would wrongly split 4 ways → 2 QP. Deriving + // the span from canonical results keeps every window/league identical. Standalone + // events (no tournamentId) have no canonical field, so fall back to the live count. + let canonicalTieCountByPlacement: Map | null = null; + if (event.tournamentId) { + const canonicalResults = await db + .select({ placement: schema.tournamentResults.placement }) + .from(schema.tournamentResults) + .where(eq(schema.tournamentResults.tournamentId, event.tournamentId)); + canonicalTieCountByPlacement = new Map(); + for (const cr of canonicalResults) { + if (cr.placement === null) continue; + canonicalTieCountByPlacement.set( + cr.placement, + (canonicalTieCountByPlacement.get(cr.placement) ?? 0) + 1 + ); + } + } + // Group results by placement to handle ties const placementGroups = new Map(); for (const result of results) { @@ -926,7 +948,7 @@ export async function processQualifyingEvent( // Process each placement group and update event_results with QP awarded for (const [placement, group] of placementGroups) { - const tieCount = group.length; + const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length; const qpPerParticipant = calculateSplitQualifyingPoints( placement, @@ -954,17 +976,10 @@ export async function processQualifyingEvent( 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)); - } + // NOTE: majorsCompleted is NOT a stored counter. It is derived on read via + // getMajorsCompleted() (count of completed qualifying events). Incrementing here + // per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was + // removed. See app/models/scoring-event.ts:getMajorsCompleted. logger.log( `[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants` @@ -981,7 +996,7 @@ export async function processQualifyingEvent( afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded })) ); - if (changedParticipantIds.size > 0) { + if (changedParticipantIds.size > 0 && !options.skipNotifications) { try { await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds); } catch (error) { diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index fd6de2d..38dbcd9 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -3,7 +3,7 @@ import * as schema from "~/database/schema"; import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm"; import type { BracketRegion } from "~/lib/bracket-templates"; import { recalculateAffectedLeagues } from "./scoring-calculator"; -import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points"; +import { recalculateParticipantQP } from "./qualifying-points"; import { findParticipantNamesByIds } from "./season-participant"; import { deleteTournament } from "./tournament"; import type { EventType } from "./scoring-event-types"; @@ -144,6 +144,33 @@ export async function getQualifyingEvents( }); } +/** + * Number of "majors completed" for a sports season, derived on read as the count of + * qualifying events that have been marked complete. Replaces the old stored + * sportsSeasons.majorsCompleted counter, which was incremented per fan-out sync and + * over-counted past totalMajors ("11 of 4"). Computing it makes the value + * self-correcting and immune to double-counting. + */ +export async function getMajorsCompleted( + sportsSeasonId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + const rows = await db + .select({ count: sql`count(*)::int` }) + .from(schema.scoringEvents) + .where( + and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.isQualifyingEvent, true), + eq(schema.scoringEvents.isComplete, true) + ) + ); + + return rows[0]?.count ?? 0; +} + /** * Update a scoring event */ @@ -246,13 +273,11 @@ export async function deleteScoringEvent( // For qualifying events: capture affected participant IDs before the cascade deletes // eventResults (which is how we know who had QP awarded from this event). let affectedParticipantIds: string[] = []; - let wasQPProcessed = false; if (event.isQualifyingEvent) { const results = await db.query.eventResults.findMany({ where: eq(schema.eventResults.scoringEventId, eventId), }); affectedParticipantIds = results.map((r) => r.seasonParticipantId); - wasQPProcessed = hasProcessedQualifyingPlacement(results); } await db.transaction(async (tx) => { @@ -275,18 +300,9 @@ export async function deleteScoringEvent( await recalculateParticipantQP(participantId, event.sportsSeasonId, db); } - // Decrement majorsCompleted if this event had already been processed - if (wasQPProcessed) { - const sportsSeason = await db.query.sportsSeasons.findFirst({ - where: eq(schema.sportsSeasons.id, event.sportsSeasonId), - }); - if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) { - await db - .update(schema.sportsSeasons) - .set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() }) - .where(eq(schema.sportsSeasons.id, event.sportsSeasonId)); - } - } + // majorsCompleted is derived on read (getMajorsCompleted), so deleting the event + // — which removes it from the completed-qualifying-event count — self-corrects the + // number. No stored counter to decrement. await recalculateAffectedLeagues(event.sportsSeasonId, db); } diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index cb7aebe..dba5c6a 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -882,9 +882,11 @@ export async function action({ request, params }: Route.ActionArgs) { // fantasy placements come from finalizeQualifyingPoints across all of them. if (event.isQualifyingEvent) { const db = database(); - // processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent), - // increments majorsCompleted, and recalcs participant QP totals. Season-wide - // fantasy finalization stays with finalizeQualifyingPoints across all majors. + // processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent) + // and recalcs participant QP totals. majorsCompleted is derived on read from + // completed qualifying events (see getMajorsCompleted) — marking this event + // complete below is what advances it. Season-wide fantasy finalization stays with + // finalizeQualifyingPoints across all majors. await processQualifyingEvent(params.eventId, db); await db .update(schema.scoringEvents) diff --git a/app/routes/admin.sports-seasons.$id.events.server.ts b/app/routes/admin.sports-seasons.$id.events.server.ts index 90b4968..3b7f7a8 100644 --- a/app/routes/admin.sports-seasons.$id.events.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.server.ts @@ -9,6 +9,7 @@ import { bulkCreateScoringEvents, ensurePrimaryEvent, countWindowsByTournament, + getMajorsCompleted, type CreateScoringEventData, } from "~/models/scoring-event"; import { isBracketMajor } from "~/lib/event-utils"; @@ -28,10 +29,14 @@ export async function loader({ params }: Route.LoaderArgs) { const events = await getScoringEventsForSportsSeason(params.id); - // For qualifying sports seasons, get QP standings with global ranks attached + // For qualifying sports seasons, get QP standings with global ranks attached. + // majorsCompleted is derived on read (count of completed qualifying events), not a + // stored counter — see getMajorsCompleted. let qpStandings = null; const scoringRules = null; + let majorsCompleted = 0; if (sportsSeason.scoringPattern === "qualifying_points") { + majorsCompleted = await getMajorsCompleted(params.id); const standings = await getQPStandings(params.id); let prevQP = -1; let prevRankStart = 1; @@ -88,7 +93,7 @@ export async function loader({ params }: Route.LoaderArgs) { }); return { - sportsSeason: sportsSeason as typeof sportsSeason & { + sportsSeason: { ...sportsSeason, majorsCompleted } as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string }; }, events: eventsWithSharing, diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts index 4eeb5b8..2075586 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts @@ -14,6 +14,7 @@ import { getQPStandings } from "~/models/qualifying-points"; import { getUpcomingScoringEvents, getRecentCompletedEvents, + getMajorsCompleted, } from "~/models/scoring-event"; import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value"; @@ -157,6 +158,7 @@ export async function loader(args: Route.LoaderArgs) { let seasonStandings: SeasonStanding[] = []; type QPStanding = Awaited>[number] & { globalRank: number }; let qpStandings: QPStanding[] = []; + let majorsCompleted = 0; // Group standings for group-stage events (e.g. FIFA World Cup) type RawGroupMatch = Awaited> extends Map> ? T : never; @@ -293,6 +295,9 @@ export async function loader(args: Route.LoaderArgs) { }, })); } else if (scoringPattern === "qualifying_points") { + // majorsCompleted is derived on read (count of completed qualifying events), not a + // stored counter — see getMajorsCompleted. + majorsCompleted = await getMajorsCompleted(sportsSeasonId); const standings = await getQPStandings(sportsSeasonId); // Compute global ranks with tie handling across the full field before filtering, // so the displayed rank numbers remain correct after undrafted/zero-QP rows are removed. @@ -341,7 +346,7 @@ export async function loader(args: Route.LoaderArgs) { return { league, season, - sportsSeason, + sportsSeason: { ...sportsSeason, majorsCompleted }, scoringPattern, playoffMatches, playoffRounds, diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index 72e2a50..60fb1c5 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -680,11 +680,35 @@ describe("sendQualifyingPointsUpdateNotification", () => { vi.stubGlobal("fetch", mockFetch(204)); }); - const BASE_ENTRIES = [ + // The caller now supplies each entry's rank in the FULL season field. This helper + // mirrors the production ranking (qualifying-points-discord.server.ts): sort by + // qpTotal desc, competition ranking with ties sharing the lower rank, so existing + // tests keep asserting ranks derived from qpTotal. + function withRanks(entries: T[]) { + const sorted = [...entries].sort((a, b) => b.qpTotal - a.qpTotal); + const rankByTotal = new Map(); + let prevTotal = Number.NaN; + let prevRank = 0; + sorted.forEach((e, i) => { + const rank = i > 0 && Math.abs(e.qpTotal - prevTotal) < 0.001 ? prevRank : i + 1; + rankByTotal.set(e.qpTotal, rank); + prevTotal = e.qpTotal; + prevRank = rank; + }); + const countByTotal = new Map(); + for (const e of entries) countByTotal.set(e.qpTotal, (countByTotal.get(e.qpTotal) ?? 0) + 1); + return entries.map((e) => ({ + ...e, + globalRank: rankByTotal.get(e.qpTotal) ?? 0, + globalRankTied: (countByTotal.get(e.qpTotal) ?? 0) > 1, + })); + } + + const BASE_ENTRIES = withRanks([ { participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" }, { participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" }, { participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" }, - ]; + ]); it("sends an embed with gold color and QP title", async () => { await sendQualifyingPointsUpdateNotification({ @@ -759,11 +783,11 @@ describe("sendQualifyingPointsUpdateNotification", () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", - entries: [ + entries: withRanks([ { participantName: "Player A", qpEarned: 10, qpTotal: 25 }, { participantName: "Player B", qpEarned: 5, qpTotal: 25 }, { participantName: "Player C", qpEarned: 2, qpTotal: 10 }, - ], + ]), }); const desc = getDescription(); @@ -772,11 +796,74 @@ describe("sendQualifyingPointsUpdateNotification", () => { expect(desc).toContain("3\\. Player C"); }); + it("does not round fractional QP — a 1.5 QP award shows as 1.5, not 2", async () => { + // Regression for the reported bug: a tennis Round-of-16 loser earns 1.5 QP + // (positions 9–16 split) but Discord rounded it to 2 via Math.round. + await sendQualifyingPointsUpdateNotification({ + webhookUrl: WEBHOOK_URL, + seasonName: "Rumble League 2026", + sportName: "Tennis - Men", + eventName: "Wimbledon", + entries: [ + { + participantName: "Novak Djokovic", + qpEarned: 1.5, + qpTotal: 1.5, + globalRank: 9, + globalRankTied: true, + ownerUsername: "snarkymcgee", + }, + ], + }); + + const desc = getDescription(); + // Mirrors the web UI's formatQP: fractional QP renders with 2 decimals ("1.50"), + // never rounded to an integer. + expect(desc).toContain("+1.50 QP"); + expect(desc).not.toContain("+2 QP"); + expect(desc).toContain("1.50 QP"); + expect(desc).not.toContain("— 2 QP"); + }); + + it("ranks the standings by caller-provided full-field globalRank, not position among entries", async () => { + // Screenshot scenario: only two players scored this event, but they sit at T9 in + // the full season field (8 players ahead). They must show T9, not T1. + await sendQualifyingPointsUpdateNotification({ + webhookUrl: WEBHOOK_URL, + seasonName: "Rumble League 2026", + sportName: "Tennis - Men", + eventName: "Wimbledon", + entries: [ + { + participantName: "Novak Djokovic", + qpEarned: 1.5, + qpTotal: 1.5, + globalRank: 9, + globalRankTied: true, + ownerUsername: "snarkymcgee", + }, + { + participantName: "Jannik Sinner", + qpEarned: 1.5, + qpTotal: 1.5, + globalRank: 9, + globalRankTied: true, + ownerUsername: "tanay002", + }, + ], + }); + + const desc = getDescription(); + expect(desc).toContain("T9\\. Novak Djokovic (snarkymcgee) — 1.50 QP"); + expect(desc).toContain("T9\\. Jannik Sinner (tanay002) — 1.50 QP"); + expect(desc).not.toContain("T1\\."); + }); + it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", - entries: [ + entries: withRanks([ { participantName: "Novak Djokovic", qpEarned: 20, @@ -784,7 +871,7 @@ describe("sendQualifyingPointsUpdateNotification", () => { ownerUsername: "alex", ownerDiscordUserId: "111222333", }, - ], + ]), }); const desc = getDescription(); @@ -796,10 +883,10 @@ describe("sendQualifyingPointsUpdateNotification", () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", - entries: [ + entries: withRanks([ { participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" }, { participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" }, - ], + ]), }); const payload = getPayload(); @@ -864,9 +951,9 @@ describe("sendQualifyingPointsUpdateNotification", () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", - entries: [ + entries: withRanks([ { participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" }, - ], + ]), }); const desc = getDescription(); @@ -875,12 +962,14 @@ describe("sendQualifyingPointsUpdateNotification", () => { }); it("truncates description at 4096 characters", async () => { - const longEntries = Array.from({ length: 200 }, (_, i) => ({ - participantName: `Very Long Participant Name Number ${i}`, - qpEarned: i % 2 === 0 ? 5 : 0, - qpTotal: 200 - i, - ownerUsername: `owner_with_long_username_${i}`, - })); + const longEntries = withRanks( + Array.from({ length: 200 }, (_, i) => ({ + participantName: `Very Long Participant Name Number ${i}`, + qpEarned: i % 2 === 0 ? 5 : 0, + qpTotal: 200 - i, + ownerUsername: `owner_with_long_username_${i}`, + })) + ); await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, diff --git a/app/services/__tests__/sync-tournament-results.test.ts b/app/services/__tests__/sync-tournament-results.test.ts index c0a6cdc..8f40303 100644 --- a/app/services/__tests__/sync-tournament-results.test.ts +++ b/app/services/__tests__/sync-tournament-results.test.ts @@ -360,7 +360,7 @@ describe("syncTournamentResults", () => { expect(c?.qualifyingPointsAwarded).toBe("0"); expect(processQualifyingEvent).toHaveBeenCalledTimes(1); - expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db); + expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db, { skipNotifications: false }); }); // ------------------------------------------------------------------------- @@ -412,8 +412,8 @@ describe("syncTournamentResults", () => { expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3); expect(processQualifyingEvent).toHaveBeenCalledTimes(2); - expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db); - expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db); + expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, { skipNotifications: false }); + expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, { skipNotifications: false }); }); // ------------------------------------------------------------------------- @@ -723,7 +723,7 @@ describe("syncTournamentResults", () => { expect(report.windowsSynced).toBe(1); expect(processQualifyingEvent).toHaveBeenCalledTimes(1); - expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db); + expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db, { skipNotifications: false }); // No event_results written for the skipped primary window. expect( state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY") diff --git a/app/services/discord.ts b/app/services/discord.ts index 5ebfab5..501ef77 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -63,6 +63,16 @@ function escapeMarkdown(text: string): string { return text.replace(/[_*~`|\\]/g, "\\$&"); } +/** + * Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser + * earns 1.5 QP from the 9–16 split), so we must NOT round: show whole numbers plainly + * and fractional values to 2 decimals. Mirrors the web UI's formatQP + * (app/components/scoring/QualifyingPointsStandings.tsx) so Discord and the site agree. + */ +function formatQPValue(n: number): string { + return n % 1 === 0 ? n.toString() : n.toFixed(2); +} + export interface StandingEntry { teamId: string; teamName: string; @@ -251,6 +261,15 @@ export interface QPEventEntry { participantName: string; qpEarned: number; qpTotal: number; + /** + * The participant's rank in the FULL season QP standings (all participants), not + * their position among this event's scorers. Computed by the caller so the "QP + * Standings" block reflects the whole sport season — e.g. two R16 losers on 1.5 QP + * show as T9 (8 players ahead) rather than T1 among just the two of them. + */ + globalRank: number; + /** True when another participant in the full field shares this globalRank. */ + globalRankTied: boolean; ownerUsername?: string; ownerDiscordUserId?: string; } @@ -301,7 +320,7 @@ export async function sendQualifyingPointsUpdateNotification({ const label = managerLabel ? `${escapeMarkdown(e.participantName)} (${managerLabel})` : escapeMarkdown(e.participantName); - sections.push(`• **${label}** — +${Math.round(e.qpEarned)} QP`); + sections.push(`• **${label}** — +${formatQPValue(e.qpEarned)} QP`); } } @@ -338,33 +357,23 @@ export async function sendQualifyingPointsUpdateNotification({ } } - const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal); - // Compute ranks with tie detection - const ranks: number[] = []; - for (let i = 0; i < sorted.length; i++) { - if (i > 0 && sorted[i].qpTotal === sorted[i - 1].qpTotal) { - ranks.push(ranks[ranks.length - 1]); - } else { - ranks.push(i + 1); - } - } - const isTied = buildTiedRankChecker(ranks); + // Order by the participant's rank in the FULL season standings (globalRank), + // supplied by the caller, so the block reads as a season leaderboard slice. + const sorted = [...entries].toSorted((a, b) => a.globalRank - b.globalRank); // The standings block reflects QP earners; skip it entirely when this sync only // reported knockouts (no QP change) so we don't emit an empty header. if (sorted.length > 0) { sections.push("\n**QP Standings**"); - for (let i = 0; i < sorted.length; i++) { - const e = sorted[i]; - const r = ranks[i]; - const rankPrefix = isTied(r) ? `T${r}` : `${r}`; + for (const e of sorted) { + const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`; const ownerLabel = e.ownerDiscordUserId ? `<@${e.ownerDiscordUserId}>` : e.ownerUsername ? escapeMarkdown(e.ownerUsername) : undefined; const managerLabel = ownerLabel ? ` (${ownerLabel})` : ""; - sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${Math.round(e.qpTotal)} QP`); + sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${formatQPValue(e.qpTotal)} QP`); } } diff --git a/app/services/qualifying-points-discord.server.ts b/app/services/qualifying-points-discord.server.ts index 5601b57..f85ee42 100644 --- a/app/services/qualifying-points-discord.server.ts +++ b/app/services/qualifying-points-discord.server.ts @@ -95,6 +95,29 @@ export async function notifyQualifyingPointsUpdate( qpTotals.map((t) => [t.participantId, parseFloat(t.totalQualifyingPoints)]) ); + // Rank every participant across the FULL season field (not just this event's + // scorers) so the notification's "QP Standings" block reads as a season-leaderboard + // slice and matches the website's globalRank. Standard competition ranking: ties + // share the lower rank (…, 9, 9, 11, …). Two R16 losers on 1.5 QP with 8 players + // ahead therefore render as T9, not T1 among just the two of them. + const rankedField = [...qpTotalById.entries()] + .map(([id, total]) => ({ id, total })) + .sort((a, b) => b.total - a.total); + const globalRankById = new Map(); + let prevTotal = Number.NaN; + let prevRank = 0; + rankedField.forEach((row, index) => { + const rank = + index > 0 && Math.abs(row.total - prevTotal) < 0.001 ? prevRank : index + 1; + globalRankById.set(row.id, rank); + prevTotal = row.total; + prevRank = rank; + }); + const countByRank = new Map(); + for (const rank of globalRankById.values()) { + countByRank.set(rank, (countByRank.get(rank) ?? 0) + 1); + } + // Batch-fetch all season + league metadata in one query const seasons = await db.query.seasons.findMany({ where: inArray(schema.seasons.id, seasonIds), @@ -158,10 +181,13 @@ export async function notifyQualifyingPointsUpdate( const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => { const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null; + const globalRank = globalRankById.get(participantId) ?? 0; return { participantName: participantNameById.get(participantId) ?? participantId, qpEarned: qpEarnedById.get(participantId) ?? 0, qpTotal: qpTotalById.get(participantId) ?? 0, + globalRank, + globalRankTied: (countByRank.get(globalRank) ?? 0) > 1, ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined, ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined, }; diff --git a/app/services/sync-tournament-results.ts b/app/services/sync-tournament-results.ts index df86c6a..934a54e 100644 --- a/app/services/sync-tournament-results.ts +++ b/app/services/sync-tournament-results.ts @@ -35,6 +35,13 @@ export interface SyncOptions { * primary is already scored in place, so re-running it would be wasted work. */ skipEventId?: string; + /** + * Suppress ALL Discord notifications for this fan-out (both the per-window QP + * update from processQualifyingEvent and the league standings recalc). Used by + * one-off backfills that re-score historical events — the QP values change + * (e.g. a mis-split 2 → correct 1.5), which would otherwise re-ping every league. + */ + skipNotifications?: boolean; } /** @@ -57,7 +64,7 @@ export async function syncTournamentResults( options: SyncOptions = {} ): Promise { const db = database(); - const { markComplete = true, skipEventId } = options; + const { markComplete = true, skipEventId, skipNotifications = false } = options; const report: SyncReport = { tournamentId, @@ -176,7 +183,7 @@ export async function syncTournamentResults( } // 3d. Delegate to scoring engine inside the same transaction. - await processQualifyingEvent(ev.id, tx); + await processQualifyingEvent(ev.id, tx, { skipNotifications }); // 3e. Mark the window event complete (final-results sync only). This is // what makes "score once" actually complete every window — without it, @@ -213,8 +220,9 @@ export async function syncTournamentResults( eventName: w.eventName ?? undefined, // Mid-tournament fan-out (markComplete=false) updates standings silently; // the primary window already announced the round. Only the final sync - // (completion) announces to each window's leagues. - skipDiscord: !markComplete, + // (completion) announces to each window's leagues. A backfill + // (skipNotifications) is always silent. + skipDiscord: skipNotifications || !markComplete, }); } catch (e: unknown) { const msg = @@ -250,9 +258,9 @@ export async function syncTournamentResults( */ export async function syncMajorFromPrimaryEvent( primaryEventId: string, - options: { markComplete?: boolean } = {} + options: { markComplete?: boolean; skipNotifications?: boolean } = {} ): Promise { - const { markComplete = false } = options; + const { markComplete = false, skipNotifications = false } = options; const primaryEvent = await getScoringEventById(primaryEventId); if (!primaryEvent) { @@ -317,6 +325,7 @@ export async function syncMajorFromPrimaryEvent( return syncTournamentResults(tournamentId, { markComplete, skipEventId: primaryEventId, + skipNotifications, }); } diff --git a/scripts/backfill-qp-resplit.ts b/scripts/backfill-qp-resplit.ts new file mode 100644 index 0000000..8185f55 --- /dev/null +++ b/scripts/backfill-qp-resplit.ts @@ -0,0 +1,136 @@ +/** + * Backfill: re-score qualifying majors so mis-split QP is corrected. + * + * Sibling/mirror windows used to compute a placement's tie span from the players + * present on THAT window's roster (a subset of the field), so a tied group split + * fewer ways and over-awarded — tennis Round-of-16 losers landed at 2 QP instead of + * the correct 1.5 (positions 9–16: (2+2+2+2+1+1+1+1)/8). processQualifyingEvent now + * derives the tie span from the canonical full field, so re-running the fan-out + * rewrites the stored event_results QP, participant totals, and league standings. + * + * This is idempotent and SILENT: notifications are suppressed so re-scoring history + * (2 → 1.5 for many participants) does not re-ping every league's Discord. It does + * NOT re-link events or designate primaries — run backfill-major-linking.ts first if + * the data predates the shared-major model. majorsCompleted needs no fix (it is now + * derived on read from completed qualifying events). + * + * Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL. + * + * npx tsx scripts/backfill-qp-resplit.ts # apply + * npx tsx scripts/backfill-qp-resplit.ts --dry # report only + */ + +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { eq } from "drizzle-orm"; +import * as schema from "../database/schema.js"; +import { DatabaseContext, database } from "../database/context.js"; +import { isBracketMajor } from "../app/lib/event-utils.js"; +import { + syncTournamentResults, + syncMajorFromPrimaryEvent, +} from "../app/services/sync-tournament-results.js"; + +const DRY = process.argv.includes("--dry"); +const log = (...a: unknown[]) => console.log(...a); + +async function run() { + const db = database(); + + // Every tournament-linked qualifying event, grouped by canonical tournament. + const events = await db.query.scoringEvents.findMany({ + where: eq(schema.scoringEvents.isQualifyingEvent, true), + with: { sportsSeason: { with: { sport: true } } }, + }); + const byTournament = new Map(); + for (const ev of events) { + if (!ev.tournamentId) continue; // standalone/manual events: nothing to fan out + const arr = byTournament.get(ev.tournamentId) ?? []; + arr.push(ev); + byTournament.set(ev.tournamentId, arr); + } + + log(`Tournaments with linked qualifying events: ${byTournament.size}`); + let ok = 0; + let failed = 0; + + for (const [tournamentId, evs] of byTournament) { + const simulatorType = evs[0]?.sportsSeason?.sport?.simulatorType ?? null; + const bracketMajor = isBracketMajor(simulatorType); + const name = evs[0]?.name ?? tournamentId; + + // Only re-score fully-scored majors so we don't mark in-progress ones complete. + const anyComplete = evs.some((e) => e.isComplete); + const markComplete = evs.every((e) => e.isComplete); + + if (DRY) { + log( + ` (dry) ${name}: ${evs.length} window(s), bracket=${bracketMajor}, ` + + `complete=${markComplete} — would re-score silently` + ); + continue; + } + + try { + if (bracketMajor) { + // Re-derive canonical from the primary, then fan out to siblings (which now + // split ties by the full field). Primary is scored in place and skipped. + const primary = evs.find((e) => e.isPrimary); + if (!primary) { + log(` ! ${name}: no primary window — skipping (run backfill-major-linking.ts)`); + failed += 1; + continue; + } + const report = await syncMajorFromPrimaryEvent(primary.id, { + markComplete, + skipNotifications: true, + }); + ok += report.windowsSynced; + failed += report.windowsFailed; + log( + ` ${name}: re-scored via primary — ${report.windowsSynced} ok, ${report.windowsFailed} failed` + ); + } else { + // Golf/placement: canonical tournament_results already exist; just fan out. + if (!anyComplete) { + log(` ${name}: no completed window — skipping`); + continue; + } + const report = await syncTournamentResults(tournamentId, { + markComplete, + skipNotifications: true, + }); + ok += report.windowsSynced; + failed += report.windowsFailed; + log( + ` ${name}: re-scored — ${report.windowsSynced} ok, ${report.windowsFailed} failed` + ); + } + } catch (e) { + failed += 1; + log(` ! ${name}: ${(e as Error).message}`); + } + } + + log(`\nDone${DRY ? " (dry run — no writes)" : ""}. windows ok=${ok}, failed=${failed}.`); +} + +async function main() { + const dbUrl = process.env.DATABASE_URL; + if (!dbUrl) { + console.error("ERROR: DATABASE_URL is required"); + process.exit(1); + } + const client = postgres(dbUrl, { max: 1 }); + const db = drizzle(client, { schema }); + try { + await DatabaseContext.run(db, run); + } finally { + await client.end(); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +});