mirrored elimination #129

Merged
chrisp merged 2 commits from claude/mirrored-tournament-eliminations-50nosy into main 2026-07-04 19:43:22 +00:00
8 changed files with 336 additions and 20 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

@ -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([]);
});
});

View file

@ -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);
}

View file

@ -68,6 +68,7 @@ import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.se
import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results"; import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results";
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync"; import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis"; 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) { export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id); const sportsSeason = await findSportsSeasonById(params.id);
@ -135,7 +136,15 @@ async function scoreQualifyingBracket(
tournamentId: string | null; tournamentId: string | null;
}, },
db: ReturnType<typeof database>, db: ReturnType<typeof database>,
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2] recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[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<string>
): Promise<void> { ): Promise<void> {
await processQualifyingBracketEvent(event.id, db); await processQualifyingBracketEvent(event.id, db);
await recalculateAffectedLeagues( await recalculateAffectedLeagues(
@ -145,7 +154,10 @@ async function scoreQualifyingBracket(
); );
// If this is the shared major's primary window, propagate to siblings. // If this is the shared major's primary window, propagate to siblings.
// Mid-tournament (a single round): don't mark complete yet. // 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" }; 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 // Set the winner
await setMatchWinner(matchId, winnerId, loserId); await setMatchWinner(matchId, winnerId, loserId);
@ -424,11 +443,16 @@ export async function action({ request, params }: Route.ActionArgs) {
if (event.isQualifyingEvent) { if (event.isQualifyingEvent) {
// Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not // Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not
// fantasy points. matchIds scopes the Discord notification to just this match. // fantasy points. matchIds scopes the Discord notification to just this match.
await scoreQualifyingBracket(event, db, { await scoreQualifyingBracket(
eventId: event.id, event,
eventName: event.name ?? undefined, db,
matchIds: [matchId], {
}); eventId: event.id,
eventName: event.name ?? undefined,
matchIds: [matchId],
},
setWinnerNewlyEliminated
);
} else { } else {
// Immediately score this match: loser gets their final placement, // Immediately score this match: loser gets their final placement,
// winner gets provisional floor points (isPartialScore=true). // winner gets provisional floor points (isPartialScore=true).
@ -500,6 +524,10 @@ export async function action({ request, params }: Route.ActionArgs) {
let successCount = 0; let successCount = 0;
const errors: string[] = []; const errors: string[] = [];
const processedMatchIds: 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) { for (const { matchId, winnerId } of winnerAssignments) {
try { try {
@ -568,6 +596,10 @@ export async function action({ request, params }: Route.ActionArgs) {
successCount++; successCount++;
processedMatchIds.push(matchId); 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) { } catch (error) {
logger.error(`Error setting winner for match ${matchId}:`, error); logger.error(`Error setting winner for match ${matchId}:`, error);
errors.push( errors.push(
@ -598,8 +630,12 @@ export async function action({ request, params }: Route.ActionArgs) {
if (!event.isQualifyingEvent) { if (!event.isQualifyingEvent) {
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
} else { } else {
// Shared major primary window: propagate this round to siblings. // Shared major primary window: propagate this round to siblings, carrying
await fanOutMajorIfPrimary(event, { markComplete: false }); // the batch's newly-decided knockouts so mirrors announce them too.
await fanOutMajorIfPrimary(event, {
markComplete: false,
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
});
} }
} }

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 {