Compare commits
3 commits
8416b54052
...
91227b2cb3
| Author | SHA1 | Date | |
|---|---|---|---|
| 91227b2cb3 | |||
|
|
b9a9eb4c4b | ||
|
|
7ca89aafc4 |
8 changed files with 336 additions and 20 deletions
|
|
@ -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<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.
|
||||
*/
|
||||
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> {
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<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> {
|
||||
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, {
|
||||
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)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 () => {
|
||||
const db = makeFakeDb(seedBasicState());
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
|
|
|||
|
|
@ -611,9 +611,16 @@ export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
|
|||
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<DrawSyncResult> {
|
|||
// 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(
|
||||
|
|
|
|||
|
|
@ -60,6 +60,16 @@ export interface SyncOptions {
|
|||
* stage. Omitted for golf-style majors (no bracket) → canonical row-count is used.
|
||||
*/
|
||||
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,
|
||||
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<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, {
|
||||
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<string>;
|
||||
} = {}
|
||||
): Promise<SyncReport> {
|
||||
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<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, {
|
||||
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<string>;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
if (!event.isPrimary || !event.tournamentId) return;
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue