Merge pull request 'mirrored elimination' (#129) from claude/mirrored-tournament-eliminations-50nosy into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m55s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s

This commit is contained in:
chrisp 2026-07-04 19:43:21 +00:00
commit 91227b2cb3
8 changed files with 336 additions and 20 deletions

View file

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

View file

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

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 { 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)),
});
}
}

View file

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

View file

@ -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 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(
{ 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
// 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.
const newlyEliminatedIds = new Set(newlyDecidedLoserIds);
if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) {
try {
await notifyQualifyingPointsUpdate(

View file

@ -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 {