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..14c838f 100644 --- a/app/models/__tests__/scoring-calculator-qualifying.test.ts +++ b/app/models/__tests__/scoring-calculator-qualifying.test.ts @@ -200,7 +200,21 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { }); describe("processQualifyingEvent", () => { - function makeProcessQPMockDb(eventResults: Array>) { + function makeProcessQPMockDb( + eventResults: Array>, + options: { + tournamentId?: string | null; + canonicalPlacements?: number[]; + bracketTemplateId?: string | null; + playoffMatchIds?: string[]; + } = {} + ) { + const { + tournamentId = null, + canonicalPlacements = [], + bracketTemplateId = null, + playoffMatchIds = [], + } = options; const setCalls: unknown[] = []; const updateChain = { set: (values: unknown) => { @@ -217,6 +231,8 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { id: "event-1", sportsSeasonId: "sports-season-1", isQualifyingEvent: true, + tournamentId, + bracketTemplateId, sportsSeason: { majorsCompleted: 1 }, }), }, @@ -238,6 +254,23 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { }), }, }, + // Two select shapes flow through the non-bracket path: + // • playoff-match existence check (has an `id` projection, uses .limit(1)) + // • canonical tournament_results tie span (has a `placement` projection). + // Discriminate by the projection keys and return a thenable that also + // exposes .limit so both call shapes resolve. + select: (fields: Record) => { + const isPlayoff = fields && "id" in fields; + const rows = isPlayoff + ? playoffMatchIds.map((id) => ({ id })) + : canonicalPlacements.map((placement) => ({ placement })); + return { + from: () => ({ + where: () => + Object.assign(Promise.resolve(rows), { limit: async () => rows }), + }), + }; + }, update: () => updateChain, } as any; @@ -273,6 +306,123 @@ 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("scores a cloned window (bracketTemplateId but no playoff matches) via the canonical path", async () => { + // cloneSportsSeason copies bracketTemplateId to league windows but not the + // playoff matches. Such a window must NOT take the bracket branch (which would + // derive zero states and write no QP) — it has to fall through to the + // placement/canonical path and still split R16 losers to 1.5. + 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", + bracketTemplateId: "tennis_128", // copied by clone… + playoffMatchIds: [], // …but no matches exist on this window + canonicalPlacements: Array.from({ length: 8 }, () => 9), + }); + + await processQualifyingEvent("event-1", db, { skipNotifications: true }); + + 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..2d2a834 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, @@ -848,13 +847,40 @@ export async function processQualifyingBracketEvent( await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db); } +/** + * Count how many results share each placement — the structural tie span used to + * split QP across a tied group. Callers pass the FULL canonical field + * (tournament_results) so the span reflects the whole tournament, not one window's + * roster subset. Null placements (filler / not-participating) are ignored. + */ +export function buildTieCountByPlacement( + results: Array<{ placement: number | null }> +): Map { + const map = new Map(); + for (const r of results) { + if (r.placement === null) continue; + map.set(r.placement, (map.get(r.placement) ?? 0) + 1); + } + return map; +} + /** * Process a qualifying event completion and update QP totals. * Ties in QP are handled by sharing placements (averaged points). */ export async function processQualifyingEvent( eventId: string, - providedDb?: ReturnType + providedDb?: ReturnType, + options: { + skipNotifications?: boolean; + /** + * Pre-computed full-field tie span (placement → count) from the canonical + * tournament_results. When the fan-out already loaded the canonical results it + * passes this in so we don't re-query per window. Omitted for direct callers, + * which fall back to querying it here. + */ + canonicalTieCountByPlacement?: Map; + } = {} ): Promise { const db = providedDb || database(); @@ -877,9 +903,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 @@ -889,7 +912,23 @@ export async function processQualifyingEvent( qp: r.qualifyingPointsAwarded, })); + // Route to the bracket writer only when this window actually OWNS a bracket (has + // playoff matches). A window can carry a bracketTemplateId with no matches — e.g. a + // league window cloned from a bracket season copies the template id but not the + // matches (cloneSportsSeason) — and processQualifyingBracketEvent would derive zero + // states and write NO QP. Those windows must be scored via the placement/canonical + // path below, exactly like a no-template sibling. + let hasBracketMatches = false; if (event.bracketTemplateId) { + const existing = await db + .select({ id: schema.playoffMatches.id }) + .from(schema.playoffMatches) + .where(eq(schema.playoffMatches.scoringEventId, eventId)) + .limit(1); + hasBracketMatches = existing.length > 0; + } + + if (hasBracketMatches) { // Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the // bracket/stage writers, which assign each placement its STRUCTURAL tie span. // Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows @@ -914,6 +953,28 @@ 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. The fan-out + // passes this map in (already loaded once per tournament); direct callers with a + // tournament link query it here. Standalone events (no tournamentId, no map) have + // no canonical field, so fall back to the live count. + const canonicalTieCountByPlacement: Map | null = + options.canonicalTieCountByPlacement ?? + (event.tournamentId + ? buildTieCountByPlacement( + await db + .select({ placement: schema.tournamentResults.placement }) + .from(schema.tournamentResults) + .where(eq(schema.tournamentResults.tournamentId, event.tournamentId)) + ) + : null); + // Group results by placement to handle ties const placementGroups = new Map(); for (const result of results) { @@ -926,7 +987,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 +1015,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 +1035,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..242a114 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -675,16 +675,40 @@ describe("sendDraftOrderNotification", () => { }); }); +// 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].toSorted((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, + })); +} + describe("sendQualifyingPointsUpdateNotification", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch(204)); }); - const BASE_ENTRIES = [ + 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..024eea7 100644 --- a/app/services/__tests__/sync-tournament-results.test.ts +++ b/app/services/__tests__/sync-tournament-results.test.ts @@ -8,6 +8,16 @@ vi.mock("~/database/context", () => ({ vi.mock("~/models/scoring-calculator", () => ({ processQualifyingEvent: vi.fn(), recalculateAffectedLeagues: vi.fn(), + // Pure helper — keep the real implementation so the tie-count map handed to + // processQualifyingEvent reflects the mock canonical results. + buildTieCountByPlacement: (results: Array<{ placement: number | null }>) => { + const map = new Map(); + for (const r of results) { + if (r.placement === null) continue; + map.set(r.placement, (map.get(r.placement) ?? 0) + 1); + } + return map; + }, })); vi.mock("~/models/scoring-event", () => ({ @@ -360,7 +370,10 @@ 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, + canonicalTieCountByPlacement: expect.any(Map), + }); }); // ------------------------------------------------------------------------- @@ -412,8 +425,14 @@ 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, + canonicalTieCountByPlacement: expect.any(Map), + }); + expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, { + skipNotifications: false, + canonicalTieCountByPlacement: expect.any(Map), + }); }); // ------------------------------------------------------------------------- @@ -723,7 +742,10 @@ 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, + canonicalTieCountByPlacement: expect.any(Map), + }); // 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..5acb909 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 })) + .toSorted((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..76d88a4 100644 --- a/app/services/sync-tournament-results.ts +++ b/app/services/sync-tournament-results.ts @@ -4,6 +4,7 @@ import * as schema from "~/database/schema"; import { processQualifyingEvent, recalculateAffectedLeagues, + buildTieCountByPlacement, } from "~/models/scoring-calculator"; import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event"; import { getEventResults } from "~/models/event-result"; @@ -35,6 +36,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 +65,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, @@ -72,6 +80,11 @@ export async function syncTournamentResults( .from(schema.tournamentResults) .where(eq(schema.tournamentResults.tournamentId, tournamentId)); + // The full-field tie span (placement → count) is a property of the whole + // tournament, so compute it once here and hand it to every window's + // processQualifyingEvent instead of re-querying canonical results per window. + const canonicalTieCountByPlacement = buildTieCountByPlacement(canonicalResults); + // 2. Load every scoring_event that points at this tournament. const allLinkedEvents = await db .select() @@ -176,12 +189,17 @@ export async function syncTournamentResults( } // 3d. Delegate to scoring engine inside the same transaction. - await processQualifyingEvent(ev.id, tx); + await processQualifyingEvent(ev.id, tx, { + skipNotifications, + canonicalTieCountByPlacement, + }); // 3e. Mark the window event complete (final-results sync only). This is // what makes "score once" actually complete every window — without it, // each sibling stayed "In Progress" and had to be completed by hand. - if (markComplete) { + // Skip windows already complete so a re-run (e.g. a backfill) doesn't + // needlessly re-stamp completedAt. + if (markComplete && !ev.isComplete) { await completeScoringEvent(ev.id, tx); } }); @@ -213,8 +231,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 +269,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 +336,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); +});