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 dba5c6a..2dadadb 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 @@ -65,7 +65,7 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server"; -import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results"; +import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results"; import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync"; import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis"; @@ -763,11 +763,28 @@ export async function action({ request, params }: Route.ActionArgs) { // skipDiscord: reprocess is a data-correction tool, not a result announcement. await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true }); } - // Re-propagate corrected QP to sibling windows (data-correction; not final). - await fanOutMajorIfPrimary(event, { markComplete: false }); - return { - success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`, - }; + // Re-propagate corrected QP to sibling/mirror windows (data-correction; not + // final). Call syncMajorFromPrimaryEvent directly rather than the + // swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the + // mirrors were re-scored — a silent failure here is exactly how mirrors got + // left showing stale QP behind a green "success". + const baseMessage = `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`; + if (event.isPrimary && event.tournamentId) { + try { + const report = await syncMajorFromPrimaryEvent(event.id, { markComplete: false }); + let fanout = ` Synced ${report.windowsSynced} mirror window(s).`; + if (report.windowsFailed > 0) { + const reasons = report.failures.map((f) => f.error).join("; "); + fanout = ` Synced ${report.windowsSynced} mirror window(s); ${report.windowsFailed} failed: ${reasons}`; + } + return { success: `${baseMessage}${fanout}` }; + } catch (error) { + return { + error: `Primary window recomputed, but mirror fan-out failed (mirror windows may be stale): ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + return { success: `${baseMessage} No mirror windows to sync.` }; } if (completed.length === 0) { diff --git a/app/services/__tests__/sync-tournament-results.test.ts b/app/services/__tests__/sync-tournament-results.test.ts index 024eea7..59092c8 100644 --- a/app/services/__tests__/sync-tournament-results.test.ts +++ b/app/services/__tests__/sync-tournament-results.test.ts @@ -5,20 +5,22 @@ vi.mock("~/database/context", () => ({ database: vi.fn(), })); -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-calculator", async () => { + // Keep the real implementations of the PURE helpers so the tie-count map handed + // to processQualifyingEvent reflects the mock canonical results AND the bracket's + // structural tie span (deriveBracketQualifyingStates / getRoundConfig). Only the + // DB-touching orchestrators are stubbed. + const actual = await vi.importActual( + "~/models/scoring-calculator" + ); + return { + processQualifyingEvent: vi.fn(), + recalculateAffectedLeagues: vi.fn(), + buildTieCountByPlacement: actual.buildTieCountByPlacement, + deriveBracketQualifyingStates: actual.deriveBracketQualifyingStates, + getRoundConfig: actual.getRoundConfig, + }; +}); vi.mock("~/models/scoring-event", () => ({ completeScoringEvent: vi.fn(), @@ -99,11 +101,21 @@ interface FakeEventResult { // - event_results // --------------------------------------------------------------------------- +interface FakePlayoffMatch { + scoringEventId: string; + round: string; + winnerId: string | null; + loserId: string | null; + participant1Id: string | null; + participant2Id: string | null; +} + interface FakeDbState { tournamentResults: FakeTournamentResult[]; scoringEvents: FakeScoringEvent[]; seasonParticipants: FakeSeasonParticipant[]; eventResults: FakeEventResult[]; + playoffMatches: FakePlayoffMatch[]; } /** @@ -151,6 +163,8 @@ function makeFakeDb(state: FakeDbState) { return state.seasonParticipants; case "event_results": return state.eventResults; + case "playoff_matches": + return state.playoffMatches; default: throw new Error(`Unknown table in fake db: ${name}`); } @@ -291,6 +305,7 @@ function seedBasicState(overrides: Partial = {}): FakeDbState { scoringEvents: [], seasonParticipants: [], eventResults: [], + playoffMatches: [], ...overrides, }; } @@ -836,6 +851,139 @@ describe("syncMajorFromPrimaryEvent", () => { expect(completeScoringEvent).not.toHaveBeenCalled(); }); + it("splits mirror QP by the bracket's structural tie span, not the canonical row count (R16 in progress)", async () => { + // Tennis R16 in progress: two R16 matches are decided (losers cp-L1/cp-L2 land + // final at 9th) and two players (cp-F1/cp-F2) won their R32 match and are still + // "floored" at the R16 tier (also placement 9). So only FOUR players sit at + // placement 9 right now — the canonical row-count is 4, which would wrongly + // split 9th–16th four ways (→ 2 QP). The R16 tier structurally spans 8 slots, + // so every window must split (2+2+2+2+1+1+1+1)/8 = 1.5. This asserts the map + // handed to each mirror's processQualifyingEvent carries the structural span (8), + // not the live count (4). + const state = seedBasicState({ + scoringEvents: [ + { + id: "ev-PRIMARY", + sportsSeasonId: "ss-P", + tournamentId: "t-1", + name: "Wimbledon", + }, + { + id: "ev-MIRROR", + sportsSeasonId: "ss-M", + tournamentId: "t-1", + name: "Wimbledon", + }, + ], + seasonParticipants: [ + { id: "sp-ML1", sportsSeasonId: "ss-M", participantId: "cp-L1", name: "L1" }, + { id: "sp-MF1", sportsSeasonId: "ss-M", participantId: "cp-F1", name: "F1" }, + ], + // Primary bracket state. R32 wins establish the "floored at R16" players; + // decided R16 matches establish the final 9th-place losers and the QF-floored + // winners (placement 5). + playoffMatches: [ + { + scoringEventId: "ev-PRIMARY", + round: "Round of 32", + winnerId: "cp-F1", + loserId: "cp-out1", + participant1Id: "cp-F1", + participant2Id: "cp-out1", + }, + { + scoringEventId: "ev-PRIMARY", + round: "Round of 32", + winnerId: "cp-F2", + loserId: "cp-out2", + participant1Id: "cp-F2", + participant2Id: "cp-out2", + }, + { + scoringEventId: "ev-PRIMARY", + round: "Round of 16", + winnerId: "cp-W1", + loserId: "cp-L1", + participant1Id: "cp-W1", + participant2Id: "cp-L1", + }, + { + scoringEventId: "ev-PRIMARY", + round: "Round of 16", + winnerId: "cp-W2", + loserId: "cp-L2", + participant1Id: "cp-W2", + participant2Id: "cp-L2", + }, + ], + }); + const db = makeFakeDb(state); + vi.mocked(database).mockReturnValue(db as never); + vi.mocked(processQualifyingEvent).mockResolvedValue(undefined); + vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never); + vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined); + + vi.mocked(getScoringEventById).mockResolvedValue({ + id: "ev-PRIMARY", + sportsSeasonId: "ss-P", + tournamentId: "t-1", + name: "Wimbledon", + bracketTemplateId: "tennis_128", + } as never); + + // Primary window's derived results (what processQualifyingBracketEvent wrote): + // four players at placement 9 (2 final losers + 2 floored), two at placement 5. + vi.mocked(getEventResults).mockResolvedValue( + [ + ["sp-PL1", "cp-L1", 9], + ["sp-PL2", "cp-L2", 9], + ["sp-PF1", "cp-F1", 9], + ["sp-PF2", "cp-F2", 9], + ["sp-PW1", "cp-W1", 5], + ["sp-PW2", "cp-W2", 5], + ].map(([seasonParticipantId, participantId, placement]) => ({ + placement, + rawScore: null, + notParticipating: false, + seasonParticipantId, + seasonParticipant: { participantId }, + })) as never + ); + + vi.mocked(upsertTournamentResult).mockImplementation( + async (data: { + tournamentId: string; + participantId: string; + placement?: number | null; + rawScore?: string | null; + }) => { + state.tournamentResults.push({ + tournamentId: data.tournamentId, + participantId: data.participantId, + placement: data.placement ?? null, + rawScore: data.rawScore ?? null, + }); + return data as never; + } + ); + + await syncMajorFromPrimaryEvent("ev-PRIMARY", { markComplete: false }); + + // Sanity: only four canonical rows sit at placement 9 (the live count is 4). + expect( + state.tournamentResults.filter((r) => r.placement === 9) + ).toHaveLength(4); + + // The mirror window was scored with the STRUCTURAL span, not the row count. + const mirrorCall = vi + .mocked(processQualifyingEvent) + .mock.calls.find((c) => c[0] === "ev-MIRROR"); + expect(mirrorCall).toBeDefined(); + const tieMap = mirrorCall![2]!.canonicalTieCountByPlacement as Map; + expect(tieMap.get(9)).toBe(8); // R16 tier spans 8, not the 4 rows currently there + expect(tieMap.get(5)).toBe(4); // QF tier spans 4 + }); + it("throws when the primary event is not linked to a tournament", async () => { const db = makeFakeDb(seedBasicState()); vi.mocked(database).mockReturnValue(db as never); diff --git a/app/services/sync-tournament-results.ts b/app/services/sync-tournament-results.ts index 76d88a4..792283d 100644 --- a/app/services/sync-tournament-results.ts +++ b/app/services/sync-tournament-results.ts @@ -5,10 +5,13 @@ import { processQualifyingEvent, recalculateAffectedLeagues, buildTieCountByPlacement, + deriveBracketQualifyingStates, + getRoundConfig, } from "~/models/scoring-calculator"; import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event"; import { getEventResults } from "~/models/event-result"; import { upsertTournamentResult } from "~/models/tournament-result"; +import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; import { logger } from "~/lib/logger"; export interface SyncReport { @@ -43,6 +46,20 @@ export interface SyncOptions { * (e.g. a mis-split 2 → correct 1.5), which would otherwise re-ping every league. */ skipNotifications?: boolean; + /** + * Pre-computed tie span (placement → tieCount) to split QP across each tied + * group, overriding the row-count derived from canonical tournament_results. + * + * For bracket majors (tennis, CS2) the tie span is a STRUCTURAL property of the + * round — R16 spans 8 slots (9th–16th), QF 4, SF 2, Final 1 — not the live count + * of players currently sitting at a placement. Mid-tournament, players "floored" + * at a tier make the canonical row-count diverge from the structural span, so a + * mirror window would split differently than the primary (e.g. R16 losers at + * 2 QP instead of 1.5). syncMajorFromPrimaryEvent derives this map from the + * primary bracket so every window splits identically to the primary at every + * stage. Omitted for golf-style majors (no bracket) → canonical row-count is used. + */ + tieCountByPlacement?: Map; } /** @@ -65,7 +82,12 @@ export async function syncTournamentResults( options: SyncOptions = {} ): Promise { const db = database(); - const { markComplete = true, skipEventId, skipNotifications = false } = options; + const { + markComplete = true, + skipEventId, + skipNotifications = false, + tieCountByPlacement, + } = options; const report: SyncReport = { tournamentId, @@ -80,10 +102,16 @@ 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. + // The tie span (placement → count) used to split QP across a tied group. Default + // is the count of canonical rows at each placement (a whole-tournament property, + // computed once here). For bracket majors the caller also passes the STRUCTURAL + // span from the primary bracket (R16 = 8, QF = 4, …); merge it OVER the counts so + // bracket placements split like the primary even mid-tournament while any + // non-bracket placements (e.g. CS2 Swiss exits, golf) keep their canonical count. const canonicalTieCountByPlacement = buildTieCountByPlacement(canonicalResults); + const effectiveTieCountByPlacement = tieCountByPlacement + ? new Map([...canonicalTieCountByPlacement, ...tieCountByPlacement]) + : canonicalTieCountByPlacement; // 2. Load every scoring_event that points at this tournament. const allLinkedEvents = await db @@ -191,7 +219,7 @@ export async function syncTournamentResults( // 3d. Delegate to scoring engine inside the same transaction. await processQualifyingEvent(ev.id, tx, { skipNotifications, - canonicalTieCountByPlacement, + canonicalTieCountByPlacement: effectiveTieCountByPlacement, }); // 3e. Mark the window event complete (final-results sync only). This is @@ -333,13 +361,70 @@ export async function syncMajorFromPrimaryEvent( `[syncMajorFromPrimaryEvent] promoted ${promotedCanonicalIds.size} result(s) from primary ${primaryEventId} to tournament ${tournamentId}; removed ${staleIds.length} stale` ); + // For a bracket major, split each placement's QP by the round's STRUCTURAL tie + // span (the same span the primary used via processQualifyingBracketEvent), not by + // the live count of canonical rows at that placement. Mid-tournament, players + // floored at a tier inflate the row-count and would make mirror windows split + // differently than the primary (e.g. R16 losers → 2 QP instead of 1.5). Derived + // here so every fan-out caller (reprocess, live rounds, finalize) stays consistent. + const tieCountByPlacement = await deriveStructuralTieSpanForBracket( + primaryEvent, + db + ); + return syncTournamentResults(tournamentId, { markComplete, skipEventId: primaryEventId, skipNotifications, + tieCountByPlacement, }); } +/** + * Build a placement → structural tie-span map for a bracket major's primary + * window, mirroring the spans processQualifyingBracketEvent assigns (R16 losers + * span 8 slots, QF 4, SF 2, Final 1). Returns undefined for a primary with no + * bracket template or no matches (e.g. golf), so the fan-out falls back to counting + * canonical rows exactly as before. + * + * The span for a given placement is consistent across the bracket (every R16-tier + * state carries tieCount 8, whether the player is a final loser or still floored), + * so collapsing the per-participant states into a placement → tieCount map is safe. + */ +async function deriveStructuralTieSpanForBracket( + primaryEvent: { id: string; bracketTemplateId: string | null }, + db: ReturnType +): Promise | undefined> { + if (!primaryEvent.bracketTemplateId) return undefined; + const template = BRACKET_TEMPLATES[primaryEvent.bracketTemplateId]; + if (!template) return undefined; + + const matches = await db + .select() + .from(schema.playoffMatches) + .where(eq(schema.playoffMatches.scoringEventId, primaryEvent.id)); + if (matches.length === 0) return undefined; + + const states = deriveBracketQualifyingStates( + matches.map((m) => ({ + round: m.round, + winnerId: m.winnerId, + loserId: m.loserId, + participant1Id: m.participant1Id, + participant2Id: m.participant2Id, + })), + template.rounds, + (round) => getRoundConfig(round, primaryEvent.bracketTemplateId) + ); + if (states.size === 0) return undefined; + + const map = new Map(); + for (const { placement, tieCount } of states.values()) { + map.set(placement, tieCount); + } + return map; +} + /** * Convenience guard for route handlers: fan out a just-scored bracket/stage event * to its sibling windows ONLY when it's the designated primary of a shared