From 7ca89aafc42f8712a6df9047378fd7127921e6d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 16:24:36 +0000 Subject: [PATCH 1/2] Fix mirrored tournaments dropping QP knockout announcements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror tournament windows (leagues drafting the same real-world major) announced Qualifying Points awards but silently omitted the "Knocked Out" section. Knockouts are derived only on the primary window's bracket (playoff_matches → newlyDecidedLoserIds); mirrors receive placement/rawScore only, so a player eliminated in a non-scoring round (0 QP) becomes a null-placement filler indistinguishable from "not yet played" — the mirror has no local signal to detect the knockout, and its notification was also gated on QP having changed. Thread the primary's newly-eliminated participants down the fan-out (syncTennisDraw → fanOutMajorIfPrimary → syncMajorFromPrimaryEvent → syncTournamentResults → processQualifyingEvent), translating identity across window boundaries (primary season_participant → canonical participant → each mirror's season_participant), and relax the mirror notification guard to fire on eliminations even when no QP changed — matching the primary path. Reuses the same notifyQualifyingPointsUpdate the primary already calls, so mirrors now produce the same combined "Points Awarded" + "Knocked Out" embed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WZGsG5R1q3kyKCaXuysiWJ --- .../scoring-calculator-qp-notify.test.ts | 36 +++++++++ app/models/scoring-calculator.ts | 26 +++++- .../__tests__/sync-tournament-results.test.ts | 80 +++++++++++++++++++ app/services/match-sync/index.ts | 12 ++- app/services/sync-tournament-results.ts | 79 ++++++++++++++++-- 5 files changed, 222 insertions(+), 11 deletions(-) diff --git a/app/models/__tests__/scoring-calculator-qp-notify.test.ts b/app/models/__tests__/scoring-calculator-qp-notify.test.ts index b5470d5..9547fb5 100644 --- a/app/models/__tests__/scoring-calculator-qp-notify.test.ts +++ b/app/models/__tests__/scoring-calculator-qp-notify.test.ts @@ -104,4 +104,40 @@ describe("processQualifyingEvent — QP change notification", () => { expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled(); }); + + it("announces newly-eliminated players even when no QP changed (mirror non-scoring-round exit)", async () => { + // A mirror window re-scored on fan-out: nobody's QP changed, but the primary + // bracket reports p2 knocked out in a non-scoring round. The notification must + // still fire, passing the eliminated id through as the 5th arg so the "Knocked + // Out" section isn't dropped on the mirror. + const rows: ResultRow[] = [ + { id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent }, + { id: "r2", seasonParticipantId: "p2", placement: null, qualifyingPointsAwarded: "0.00", scoringEvent }, + ]; + const db = makeDb(rows, rows.map((r) => ({ ...r }))); + + await processQualifyingEvent(EVENT_ID, db, { + newlyEliminatedParticipantIds: new Set(["p2"]), + }); + + expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1); + const [, , , changed, eliminated] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0]; + // No QP change this sync. + expect([...(changed as Set)]).toEqual([]); + // The knocked-out player is forwarded to the notifier. + expect([...(eliminated as Set)]).toEqual(["p2"]); + }); + + it("does not notify on a re-sync with no QP change and no eliminations", async () => { + const rows: ResultRow[] = [ + { id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent }, + ]; + const db = makeDb(rows, rows.map((r) => ({ ...r }))); + + await processQualifyingEvent(EVENT_ID, db, { + newlyEliminatedParticipantIds: new Set(), + }); + + expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled(); + }); }); diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 2d2a834..ab0981a 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -880,6 +880,15 @@ export async function processQualifyingEvent( * which fall back to querying it here. */ canonicalTieCountByPlacement?: Map; + /** + * This window's season_participant ids that were knocked out this sync in a + * non-scoring round. They earn no QP (so they never surface via changed QP), + * but a manager who drafted them should still be told. Threaded down from the + * primary bracket by the fan-out (syncTournamentResults), already translated + * to THIS window's season_participant ids. See the primary path in + * app/services/match-sync/index.ts (newlyEliminatedIds). + */ + newlyEliminatedParticipantIds?: Set; } = {} ): Promise { const db = providedDb || database(); @@ -1035,9 +1044,22 @@ export async function processQualifyingEvent( afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded })) ); - if (changedParticipantIds.size > 0 && !options.skipNotifications) { + // Players knocked out this sync in a non-scoring round earn no QP, so they never + // appear in changedParticipantIds. Announce them too (mirroring the primary path + // in syncTennisDraw), so a mirror window's "Knocked Out" section isn't dropped. + const eliminatedIds = options.newlyEliminatedParticipantIds ?? new Set(); + if ( + (changedParticipantIds.size > 0 || eliminatedIds.size > 0) && + !options.skipNotifications + ) { try { - await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds); + await notifyQualifyingPointsUpdate( + event.sportsSeasonId, + eventId, + db, + changedParticipantIds, + eliminatedIds, + ); } catch (error) { logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error); } diff --git a/app/services/__tests__/sync-tournament-results.test.ts b/app/services/__tests__/sync-tournament-results.test.ts index 01bebfb..af97012 100644 --- a/app/services/__tests__/sync-tournament-results.test.ts +++ b/app/services/__tests__/sync-tournament-results.test.ts @@ -274,6 +274,12 @@ vi.mock("drizzle-orm", async () => { }, // eslint-disable-next-line @typescript-eslint/no-explicit-any and: (...preds: any[]) => (row: any) => preds.every((p) => p(row)), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inArray: (col: any, vals: any[]) => { + const key = colKey(col); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (row: any) => vals.includes(row[key]); + }, }; }); @@ -987,6 +993,80 @@ describe("syncMajorFromPrimaryEvent", () => { expect(tieMap.get(5)).toBe(4); // QF tier spans 4 }); + it("fans a non-scoring-round elimination out to each mirror window (translated to its own season_participant id)", async () => { + // The reported bug: a player knocked out in a non-scoring round earns no QP, so + // canonical promotion skips them (null placement) and the mirror can't detect the + // knockout locally. The primary passes its eliminated season_participant id + // (sp-PX) down; syncMajorFromPrimaryEvent must translate it to the canonical + // participant (cp-X) and each mirror window must re-translate to ITS own + // season_participant (sp-MX) before handing it to processQualifyingEvent. + 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: [ + // Placed finalist, present on both windows. + { id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" }, + { id: "sp-MA", sportsSeasonId: "ss-M", participantId: "cp-A", name: "A" }, + // Knocked-out player: different season_participant row per window, same + // canonical participant cp-X. + { id: "sp-PX", sportsSeasonId: "ss-P", participantId: "cp-X", name: "X" }, + { id: "sp-MX", sportsSeasonId: "ss-M", participantId: "cp-X", name: "X" }, + ], + }); + 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", + } as never); + + // Primary derived results promote only the PLACED player. cp-X (the non-scoring + // loser) has no placement and is not promoted to canonical — exactly why the + // mirror needs the elimination threaded separately. + vi.mocked(getEventResults).mockResolvedValue([ + { + placement: 1, + rawScore: "1", + notParticipating: false, + seasonParticipantId: "sp-PA", + seasonParticipant: { participantId: "cp-A" }, + }, + ] 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", { + newlyEliminatedParticipantIds: new Set(["sp-PX"]), + }); + + const mirrorCall = (processQualifyingEvent as Mock).mock.calls.find( + (c) => c[0] === "ev-MIRROR" + ); + expect(mirrorCall).toBeDefined(); + if (!mirrorCall) return; + const eliminated = mirrorCall[2].newlyEliminatedParticipantIds as Set; + // cp-X → the MIRROR's own season_participant id, not the primary's. + expect([...eliminated]).toEqual(["sp-MX"]); + }); + 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/match-sync/index.ts b/app/services/match-sync/index.ts index d52fff2..891739d 100644 --- a/app/services/match-sync/index.ts +++ b/app/services/match-sync/index.ts @@ -611,9 +611,16 @@ export async function syncTennisDraw(eventId: string): Promise { eventId, eventName: event.name ?? undefined, }); + // Players knocked out this sync in a non-scoring round (rounds 1–3 of a Grand + // Slam) earn no QP and so never appear via changedParticipantIds. Computed here + // so it feeds BOTH the primary's own notification below AND the fan-out, which + // propagates it to every mirror window (whose placement-only data can't detect a + // knockout on its own). + const newlyEliminatedIds = new Set(newlyDecidedLoserIds); + await fanOutMajorIfPrimary( { id: event.id, isPrimary: event.isPrimary, tournamentId: event.tournamentId }, - { markComplete: false }, + { markComplete: false, newlyEliminatedParticipantIds: newlyEliminatedIds }, ); // Announce QP changes for the primary window. Sibling windows are announced by @@ -622,10 +629,7 @@ export async function syncTennisDraw(eventId: string): Promise { // outside the rescore transaction so the webhook HTTP call neither holds the // transaction open nor rolls back the score if Discord fails. // - // Also announce players knocked out this sync in a non-scoring round (rounds - // 1–3 of a Grand Slam), who earn no QP and so never appear via changedParticipantIds. // Fire even when no QP changed, so an early-round elimination is still surfaced. - const newlyEliminatedIds = new Set(newlyDecidedLoserIds); if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) { try { await notifyQualifyingPointsUpdate( diff --git a/app/services/sync-tournament-results.ts b/app/services/sync-tournament-results.ts index 792283d..0410f45 100644 --- a/app/services/sync-tournament-results.ts +++ b/app/services/sync-tournament-results.ts @@ -60,6 +60,16 @@ export interface SyncOptions { * stage. Omitted for golf-style majors (no bracket) → canonical row-count is used. */ tieCountByPlacement?: Map; + /** + * Canonical participant ids (participants.id) knocked out this sync in a + * non-scoring round on the primary bracket. Threaded through so each mirror + * window can translate them to its own season_participant ids and announce the + * "Knocked Out" section — these players earn no QP and so are otherwise invisible + * to the mirror (a null-placement filler row indistinguishable from "not yet + * played"). Canonical ids because they cross window boundaries; each window holds + * a different season_participant row for the same canonical participant. + */ + newlyEliminatedCanonicalParticipantIds?: Set; } /** @@ -87,6 +97,7 @@ export async function syncTournamentResults( skipEventId, skipNotifications = false, tieCountByPlacement, + newlyEliminatedCanonicalParticipantIds, } = options; const report: SyncReport = { @@ -216,13 +227,28 @@ export async function syncTournamentResults( } } - // 3d. Delegate to scoring engine inside the same transaction. + // 3d. Translate the primary's newly-eliminated canonical participants into + // THIS window's season_participant ids (same participantId → sp.id key the + // canonical result copy uses above), so processQualifyingEvent can announce + // the "Knocked Out" section for players drafted in this window's leagues. + let windowEliminatedSpIds: Set | undefined; + if (newlyEliminatedCanonicalParticipantIds?.size) { + windowEliminatedSpIds = new Set(); + for (const sp of rosters) { + if (sp.participantId && newlyEliminatedCanonicalParticipantIds.has(sp.participantId)) { + windowEliminatedSpIds.add(sp.id); + } + } + } + + // 3e. Delegate to scoring engine inside the same transaction. await processQualifyingEvent(ev.id, tx, { skipNotifications, canonicalTieCountByPlacement: effectiveTieCountByPlacement, + newlyEliminatedParticipantIds: windowEliminatedSpIds, }); - // 3e. Mark the window event complete (final-results sync only). This is + // 3f. 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. // Skip windows already complete so a re-run (e.g. a backfill) doesn't @@ -297,9 +323,23 @@ export async function syncTournamentResults( */ export async function syncMajorFromPrimaryEvent( primaryEventId: string, - options: { markComplete?: boolean; skipNotifications?: boolean } = {} + options: { + markComplete?: boolean; + skipNotifications?: boolean; + /** + * Primary-window season_participant ids knocked out this sync in a non-scoring + * round (from the primary bracket's newlyDecidedLoserIds). Translated to + * canonical participant ids here, then fanned out so each mirror window can + * announce its own "Knocked Out" section. + */ + newlyEliminatedParticipantIds?: Set; + } = {} ): Promise { - const { markComplete = false, skipNotifications = false } = options; + const { + markComplete = false, + skipNotifications = false, + newlyEliminatedParticipantIds, + } = options; const primaryEvent = await getScoringEventById(primaryEventId); if (!primaryEvent) { @@ -372,11 +412,32 @@ export async function syncMajorFromPrimaryEvent( db ); + // Translate the primary window's eliminated season_participant ids into canonical + // participant ids so the fan-out can re-key them per mirror window. These are + // non-scoring-round losers (null placement), so they were skipped from canonical + // promotion above — resolve them directly from season_participants, not from + // tournament_results. + let newlyEliminatedCanonicalParticipantIds: Set | undefined; + if (newlyEliminatedParticipantIds?.size) { + const eliminatedRows = await db + .select({ participantId: schema.seasonParticipants.participantId }) + .from(schema.seasonParticipants) + .where( + inArray(schema.seasonParticipants.id, [...newlyEliminatedParticipantIds]) + ); + newlyEliminatedCanonicalParticipantIds = new Set( + eliminatedRows + .map((r) => r.participantId) + .filter((id): id is string => id !== null) + ); + } + return syncTournamentResults(tournamentId, { markComplete, skipEventId: primaryEventId, skipNotifications, tieCountByPlacement, + newlyEliminatedCanonicalParticipantIds, }); } @@ -434,7 +495,15 @@ async function deriveStructuralTieSpanForBracket( */ export async function fanOutMajorIfPrimary( event: { id: string; isPrimary: boolean; tournamentId: string | null }, - options: { markComplete?: boolean } = {} + options: { + markComplete?: boolean; + /** + * Primary-window season_participant ids knocked out this sync in a non-scoring + * round. Forwarded to the fan-out so each mirror window announces its own + * "Knocked Out" section. + */ + newlyEliminatedParticipantIds?: Set; + } = {} ): Promise { if (!event.isPrimary || !event.tournamentId) return; try { From b9a9eb4c4b0a7e8f68b370aad8c416c35008c21d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:19:52 +0000 Subject: [PATCH 2/2] Announce mirror knockouts from the admin bracket-scoring path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live tennis-draw sync now fans eliminations out to mirror windows, but the admin bracket UI is a second live scoring path: when an admin enters match results for a shared qualifying major, mirror windows already receive "Qualifying Points Update" posts via the fan-out, yet the "Knocked Out" section was still dropped there. Thread the newly-decided losers (losers of matches reaching completion for the first time this action) through the admin route's fanOutMajorIfPrimary calls in the set-winner and set-round-winners intents, reusing the same per-window elimination translation. Re-scores, complete-round, reprocess, and finalize decide no new losers, so they pass nothing and never re-announce an exit — mirroring populateBracketFromDraw's first-completion rule. Extract the first-completion decision into a pure newlyDecidedLosers() helper shared by both intents and unit-tested directly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WZGsG5R1q3kyKCaXuysiWJ --- ...min.sports-seasons.bracket.helpers.test.ts | 44 +++++++++++++++ ...ons.$id.events.$eventId.bracket.helpers.ts | 25 +++++++++ ...sons.$id.events.$eventId.bracket.server.ts | 54 +++++++++++++++---- 3 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 app/routes/__tests__/admin.sports-seasons.bracket.helpers.test.ts create mode 100644 app/routes/admin.sports-seasons.$id.events.$eventId.bracket.helpers.ts diff --git a/app/routes/__tests__/admin.sports-seasons.bracket.helpers.test.ts b/app/routes/__tests__/admin.sports-seasons.bracket.helpers.test.ts new file mode 100644 index 0000000..1230d64 --- /dev/null +++ b/app/routes/__tests__/admin.sports-seasons.bracket.helpers.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { newlyDecidedLosers } from "../admin.sports-seasons.$id.events.$eventId.bracket.helpers"; + +describe("newlyDecidedLosers", () => { + it("returns losers of matches that were not already complete", () => { + expect( + newlyDecidedLosers([ + { wasComplete: false, loserId: "sp-1" }, + { wasComplete: false, loserId: "sp-2" }, + ]) + ).toEqual(["sp-1", "sp-2"]); + }); + + it("drops re-scores of already-complete matches (no re-announce)", () => { + // sp-1's match just finished; sp-2's match was already complete before this + // action — only sp-1 is a NEW knockout. + expect( + newlyDecidedLosers([ + { wasComplete: false, loserId: "sp-1" }, + { wasComplete: true, loserId: "sp-2" }, + ]) + ).toEqual(["sp-1"]); + }); + + it("skips entries with an unknown loser", () => { + expect( + newlyDecidedLosers([ + { wasComplete: false, loserId: null }, + { wasComplete: false, loserId: "sp-3" }, + ]) + ).toEqual(["sp-3"]); + }); + + it("returns an empty array when nothing was newly decided", () => { + expect( + newlyDecidedLosers([ + { wasComplete: true, loserId: "sp-1" }, + { wasComplete: true, loserId: "sp-2" }, + ]) + ).toEqual([]); + expect(newlyDecidedLosers([])).toEqual([]); + }); +}); diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.helpers.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.helpers.ts new file mode 100644 index 0000000..bc38286 --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.helpers.ts @@ -0,0 +1,25 @@ +/** + * Pure helpers for the bracket-scoring route action, extracted so the + * knockout-detection logic can be unit-tested without the full action harness. + */ + +/** + * From a set of matches being scored this action, return the season_participant + * ids knocked out for the FIRST time. A loser counts only when its match was NOT + * already complete — a re-score/correction of a finished match must not + * re-announce the exit (mirrors populateBracketFromDraw's first-completion rule in + * the live-sync path). Entries with an unknown loser (`null`) are skipped. + * + * The ids are season_participant ids of the primary window (playoff_matches are + * keyed by season_participant); the fan-out translates them to each mirror + * window's own season_participant id before announcing the "Knocked Out" section. + */ +export function newlyDecidedLosers( + entries: Array<{ wasComplete: boolean; loserId: string | null }> +): string[] { + return entries + .filter((e): e is { wasComplete: boolean; loserId: string } => + !e.wasComplete && e.loserId !== null + ) + .map((e) => e.loserId); +} 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 500858d..5c9f54e 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 @@ -68,6 +68,7 @@ import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.se import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results"; import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync"; import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis"; +import { newlyDecidedLosers } from "./admin.sports-seasons.$id.events.$eventId.bracket.helpers"; export async function loader({ params }: Route.LoaderArgs) { const sportsSeason = await findSportsSeasonById(params.id); @@ -135,7 +136,15 @@ async function scoreQualifyingBracket( tournamentId: string | null; }, db: ReturnType, - recalcOptions?: Parameters[2] + recalcOptions?: Parameters[2], + /** + * Season_participant ids of players knocked out for the first time by this + * operation (losers of matches that just reached completion). Forwarded to the + * fan-out so every mirror window announces the "Knocked Out" section — a + * non-scoring-round exit earns no QP and is otherwise invisible to the mirror. + * Empty for re-scores/reprocesses, which decide no new losers. + */ + newlyEliminatedParticipantIds?: Set ): Promise { await processQualifyingBracketEvent(event.id, db); await recalculateAffectedLeagues( @@ -145,7 +154,10 @@ async function scoreQualifyingBracket( ); // If this is the shared major's primary window, propagate to siblings. // Mid-tournament (a single round): don't mark complete yet. - await fanOutMajorIfPrimary(event, { markComplete: false }); + await fanOutMajorIfPrimary(event, { + markComplete: false, + newlyEliminatedParticipantIds, + }); } /** @@ -398,6 +410,13 @@ export async function action({ request, params }: Route.ActionArgs) { return { error: "Could not determine loser" }; } + // A knockout is "newly decided" only when the match wasn't already complete + // (mirrors populateBracketFromDraw's first-completion rule) — a re-score/ + // correction of an already-finished match must not re-announce the exit. + const setWinnerNewlyEliminated = new Set( + newlyDecidedLosers([{ wasComplete: match.isComplete, loserId }]) + ); + // Set the winner await setMatchWinner(matchId, winnerId, loserId); @@ -424,11 +443,16 @@ export async function action({ request, params }: Route.ActionArgs) { if (event.isQualifyingEvent) { // Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not // fantasy points. matchIds scopes the Discord notification to just this match. - await scoreQualifyingBracket(event, db, { - eventId: event.id, - eventName: event.name ?? undefined, - matchIds: [matchId], - }); + await scoreQualifyingBracket( + event, + db, + { + eventId: event.id, + eventName: event.name ?? undefined, + matchIds: [matchId], + }, + setWinnerNewlyEliminated + ); } else { // Immediately score this match: loser gets their final placement, // winner gets provisional floor points (isPartialScore=true). @@ -500,6 +524,10 @@ export async function action({ request, params }: Route.ActionArgs) { let successCount = 0; const errors: string[] = []; const processedMatchIds: string[] = []; + // Per-match completion + loser, collected so newlyDecidedLosers() can pick out + // the batch's first-time knockouts to fan out to mirror windows (a non-scoring- + // round exit earns no QP and is otherwise invisible to the mirror). + const decidedEntries: Array<{ wasComplete: boolean; loserId: string | null }> = []; for (const { matchId, winnerId } of winnerAssignments) { try { @@ -568,6 +596,10 @@ export async function action({ request, params }: Route.ActionArgs) { successCount++; processedMatchIds.push(matchId); + // Only after the match fully succeeded: record its prior completion so + // newlyDecidedLosers() announces this loser only if it's a first-time exit + // (and never for a match whose write failed above). + decidedEntries.push({ wasComplete: match.isComplete, loserId }); } catch (error) { logger.error(`Error setting winner for match ${matchId}:`, error); errors.push( @@ -598,8 +630,12 @@ export async function action({ request, params }: Route.ActionArgs) { if (!event.isQualifyingEvent) { await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); } else { - // Shared major primary window: propagate this round to siblings. - await fanOutMajorIfPrimary(event, { markComplete: false }); + // Shared major primary window: propagate this round to siblings, carrying + // the batch's newly-decided knockouts so mirrors announce them too. + await fanOutMajorIfPrimary(event, { + markComplete: false, + newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)), + }); } }