Announce mirror knockouts from the admin bracket-scoring path too
The live tennis-draw sync now fans eliminations out to mirror windows, but the admin bracket UI is a second live scoring path: when an admin enters match results for a shared qualifying major, mirror windows already receive "Qualifying Points Update" posts via the fan-out, yet the "Knocked Out" section was still dropped there. Thread the newly-decided losers (losers of matches reaching completion for the first time this action) through the admin route's fanOutMajorIfPrimary calls in the set-winner and set-round-winners intents, reusing the same per-window elimination translation. Re-scores, complete-round, reprocess, and finalize decide no new losers, so they pass nothing and never re-announce an exit — mirroring populateBracketFromDraw's first-completion rule. Extract the first-completion decision into a pure newlyDecidedLosers() helper shared by both intents and unit-tested directly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WZGsG5R1q3kyKCaXuysiWJ
This commit is contained in:
parent
7ca89aafc4
commit
b9a9eb4c4b
3 changed files with 114 additions and 9 deletions
|
|
@ -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 { 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(
|
||||||
|
event,
|
||||||
|
db,
|
||||||
|
{
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventName: event.name ?? undefined,
|
eventName: event.name ?? undefined,
|
||||||
matchIds: [matchId],
|
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)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue