Fix mirrored tournaments dropping QP knockout announcements

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WZGsG5R1q3kyKCaXuysiWJ
This commit is contained in:
Claude 2026-07-04 16:24:36 +00:00
parent 8416b54052
commit 7ca89aafc4
No known key found for this signature in database
5 changed files with 222 additions and 11 deletions

View file

@ -104,4 +104,40 @@ describe("processQualifyingEvent — QP change notification", () => {
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled(); 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<string>)]).toEqual([]);
// The knocked-out player is forwarded to the notifier.
expect([...(eliminated as Set<string>)]).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();
});
}); });

View file

@ -880,6 +880,15 @@ export async function processQualifyingEvent(
* which fall back to querying it here. * which fall back to querying it here.
*/ */
canonicalTieCountByPlacement?: Map<number, number>; canonicalTieCountByPlacement?: Map<number, number>;
/**
* 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<string>;
} = {} } = {}
): Promise<void> { ): Promise<void> {
const db = providedDb || database(); const db = providedDb || database();
@ -1035,9 +1044,22 @@ export async function processQualifyingEvent(
afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded })) 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<string>();
if (
(changedParticipantIds.size > 0 || eliminatedIds.size > 0) &&
!options.skipNotifications
) {
try { try {
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds); await notifyQualifyingPointsUpdate(
event.sportsSeasonId,
eventId,
db,
changedParticipantIds,
eliminatedIds,
);
} catch (error) { } catch (error) {
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error); logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
} }

View file

@ -274,6 +274,12 @@ vi.mock("drizzle-orm", async () => {
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
and: (...preds: any[]) => (row: any) => preds.every((p) => p(row)), 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 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<string>;
// 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 () => { it("throws when the primary event is not linked to a tournament", async () => {
const db = makeFakeDb(seedBasicState()); const db = makeFakeDb(seedBasicState());
vi.mocked(database).mockReturnValue(db as never); vi.mocked(database).mockReturnValue(db as never);

View file

@ -611,9 +611,16 @@ export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
eventId, eventId,
eventName: event.name ?? undefined, eventName: event.name ?? undefined,
}); });
// Players knocked out this sync in a non-scoring round (rounds 13 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( await fanOutMajorIfPrimary(
{ id: event.id, isPrimary: event.isPrimary, tournamentId: event.tournamentId }, { 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 // Announce QP changes for the primary window. Sibling windows are announced by
@ -622,10 +629,7 @@ export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
// outside the rescore transaction so the webhook HTTP call neither holds the // outside the rescore transaction so the webhook HTTP call neither holds the
// transaction open nor rolls back the score if Discord fails. // transaction open nor rolls back the score if Discord fails.
// //
// Also announce players knocked out this sync in a non-scoring round (rounds
// 13 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. // 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) { if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) {
try { try {
await notifyQualifyingPointsUpdate( await notifyQualifyingPointsUpdate(

View file

@ -60,6 +60,16 @@ export interface SyncOptions {
* stage. Omitted for golf-style majors (no bracket) canonical row-count is used. * stage. Omitted for golf-style majors (no bracket) canonical row-count is used.
*/ */
tieCountByPlacement?: Map<number, number>; tieCountByPlacement?: Map<number, number>;
/**
* 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<string>;
} }
/** /**
@ -87,6 +97,7 @@ export async function syncTournamentResults(
skipEventId, skipEventId,
skipNotifications = false, skipNotifications = false,
tieCountByPlacement, tieCountByPlacement,
newlyEliminatedCanonicalParticipantIds,
} = options; } = options;
const report: SyncReport = { 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<string> | undefined;
if (newlyEliminatedCanonicalParticipantIds?.size) {
windowEliminatedSpIds = new Set<string>();
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, { await processQualifyingEvent(ev.id, tx, {
skipNotifications, skipNotifications,
canonicalTieCountByPlacement: effectiveTieCountByPlacement, 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, // what makes "score once" actually complete every window — without it,
// each sibling stayed "In Progress" and had to be completed by hand. // 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 // 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( export async function syncMajorFromPrimaryEvent(
primaryEventId: string, 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<string>;
} = {}
): Promise<SyncReport> { ): Promise<SyncReport> {
const { markComplete = false, skipNotifications = false } = options; const {
markComplete = false,
skipNotifications = false,
newlyEliminatedParticipantIds,
} = options;
const primaryEvent = await getScoringEventById(primaryEventId); const primaryEvent = await getScoringEventById(primaryEventId);
if (!primaryEvent) { if (!primaryEvent) {
@ -372,11 +412,32 @@ export async function syncMajorFromPrimaryEvent(
db 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<string> | 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, { return syncTournamentResults(tournamentId, {
markComplete, markComplete,
skipEventId: primaryEventId, skipEventId: primaryEventId,
skipNotifications, skipNotifications,
tieCountByPlacement, tieCountByPlacement,
newlyEliminatedCanonicalParticipantIds,
}); });
} }
@ -434,7 +495,15 @@ async function deriveStructuralTieSpanForBracket(
*/ */
export async function fanOutMajorIfPrimary( export async function fanOutMajorIfPrimary(
event: { id: string; isPrimary: boolean; tournamentId: string | null }, 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<string>;
} = {}
): Promise<void> { ): Promise<void> {
if (!event.isPrimary || !event.tournamentId) return; if (!event.isPrimary || !event.tournamentId) return;
try { try {