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:
parent
8416b54052
commit
7ca89aafc4
5 changed files with 222 additions and 11 deletions
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 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(
|
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
|
|
||||||
// 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.
|
// 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(
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue