Fix reprocess bracket to score mirror windows like the primary

Reprocessing a tennis (qualifying) major recomputed the primary window's
QP correctly (R16 players = 1.5, the average of the 9th–16th values) but
left mirror/sibling windows showing 2 QP. Two defects:

1. The mirror fan-out ran through fanOutMajorIfPrimary, which swallows
   every error and returns void, so reprocess reported a green "success"
   even when mirrors were never re-scored. The reprocess qualifying path
   now calls syncMajorFromPrimaryEvent directly and folds the SyncReport
   (windows synced / failed) into the response, surfacing failures instead
   of hiding them.

2. Mirror windows split QP by counting canonical tournament_results rows
   at each placement, which only equals the round's structural tie span
   when placements are final. Mid-tournament, players floored at a tier
   make the row-count diverge from the tier size, so mirrors split
   differently than the primary. syncMajorFromPrimaryEvent now derives the
   structural span (R16 = 8, QF = 4, SF = 2, Final = 1) from the primary
   bracket via deriveBracketQualifyingStates and merges it over the
   canonical counts, so every window splits identically to the primary at
   every stage. Golf and CS2 Swiss-exit placements keep their canonical
   count via the merge.

Adds a fan-out test covering an R16-in-progress bracket where only 4 rows
sit at placement 9: the mirror is scored with the structural span (8), not
the live count (4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UkZVYgLmquWV2xDn349T2
This commit is contained in:
Claude 2026-07-04 01:45:53 +00:00
parent 58e656f15e
commit 0baef5a948
No known key found for this signature in database
3 changed files with 275 additions and 25 deletions

View file

@ -65,7 +65,7 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results";
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis";
@ -763,11 +763,28 @@ export async function action({ request, params }: Route.ActionArgs) {
// skipDiscord: reprocess is a data-correction tool, not a result announcement.
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
}
// Re-propagate corrected QP to sibling windows (data-correction; not final).
await fanOutMajorIfPrimary(event, { markComplete: false });
return {
success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`,
};
// Re-propagate corrected QP to sibling/mirror windows (data-correction; not
// final). Call syncMajorFromPrimaryEvent directly rather than the
// swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the
// mirrors were re-scored — a silent failure here is exactly how mirrors got
// left showing stale QP behind a green "success".
const baseMessage = `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`;
if (event.isPrimary && event.tournamentId) {
try {
const report = await syncMajorFromPrimaryEvent(event.id, { markComplete: false });
let fanout = ` Synced ${report.windowsSynced} mirror window(s).`;
if (report.windowsFailed > 0) {
const reasons = report.failures.map((f) => f.error).join("; ");
fanout = ` Synced ${report.windowsSynced} mirror window(s); ${report.windowsFailed} failed: ${reasons}`;
}
return { success: `${baseMessage}${fanout}` };
} catch (error) {
return {
error: `Primary window recomputed, but mirror fan-out failed (mirror windows may be stale): ${error instanceof Error ? error.message : String(error)}`,
};
}
}
return { success: `${baseMessage} No mirror windows to sync.` };
}
if (completed.length === 0) {

View file

@ -5,20 +5,22 @@ vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", () => ({
processQualifyingEvent: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
// Pure helper — keep the real implementation so the tie-count map handed to
// processQualifyingEvent reflects the mock canonical results.
buildTieCountByPlacement: (results: Array<{ placement: number | null }>) => {
const map = new Map<number, number>();
for (const r of results) {
if (r.placement === null) continue;
map.set(r.placement, (map.get(r.placement) ?? 0) + 1);
}
return map;
},
}));
vi.mock("~/models/scoring-calculator", async () => {
// Keep the real implementations of the PURE helpers so the tie-count map handed
// to processQualifyingEvent reflects the mock canonical results AND the bracket's
// structural tie span (deriveBracketQualifyingStates / getRoundConfig). Only the
// DB-touching orchestrators are stubbed.
const actual = await vi.importActual<typeof import("~/models/scoring-calculator")>(
"~/models/scoring-calculator"
);
return {
processQualifyingEvent: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
buildTieCountByPlacement: actual.buildTieCountByPlacement,
deriveBracketQualifyingStates: actual.deriveBracketQualifyingStates,
getRoundConfig: actual.getRoundConfig,
};
});
vi.mock("~/models/scoring-event", () => ({
completeScoringEvent: vi.fn(),
@ -99,11 +101,21 @@ interface FakeEventResult {
// - event_results
// ---------------------------------------------------------------------------
interface FakePlayoffMatch {
scoringEventId: string;
round: string;
winnerId: string | null;
loserId: string | null;
participant1Id: string | null;
participant2Id: string | null;
}
interface FakeDbState {
tournamentResults: FakeTournamentResult[];
scoringEvents: FakeScoringEvent[];
seasonParticipants: FakeSeasonParticipant[];
eventResults: FakeEventResult[];
playoffMatches: FakePlayoffMatch[];
}
/**
@ -151,6 +163,8 @@ function makeFakeDb(state: FakeDbState) {
return state.seasonParticipants;
case "event_results":
return state.eventResults;
case "playoff_matches":
return state.playoffMatches;
default:
throw new Error(`Unknown table in fake db: ${name}`);
}
@ -291,6 +305,7 @@ function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
scoringEvents: [],
seasonParticipants: [],
eventResults: [],
playoffMatches: [],
...overrides,
};
}
@ -836,6 +851,139 @@ describe("syncMajorFromPrimaryEvent", () => {
expect(completeScoringEvent).not.toHaveBeenCalled();
});
it("splits mirror QP by the bracket's structural tie span, not the canonical row count (R16 in progress)", async () => {
// Tennis R16 in progress: two R16 matches are decided (losers cp-L1/cp-L2 land
// final at 9th) and two players (cp-F1/cp-F2) won their R32 match and are still
// "floored" at the R16 tier (also placement 9). So only FOUR players sit at
// placement 9 right now — the canonical row-count is 4, which would wrongly
// split 9th16th four ways (→ 2 QP). The R16 tier structurally spans 8 slots,
// so every window must split (2+2+2+2+1+1+1+1)/8 = 1.5. This asserts the map
// handed to each mirror's processQualifyingEvent carries the structural span (8),
// not the live count (4).
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: [
{ id: "sp-ML1", sportsSeasonId: "ss-M", participantId: "cp-L1", name: "L1" },
{ id: "sp-MF1", sportsSeasonId: "ss-M", participantId: "cp-F1", name: "F1" },
],
// Primary bracket state. R32 wins establish the "floored at R16" players;
// decided R16 matches establish the final 9th-place losers and the QF-floored
// winners (placement 5).
playoffMatches: [
{
scoringEventId: "ev-PRIMARY",
round: "Round of 32",
winnerId: "cp-F1",
loserId: "cp-out1",
participant1Id: "cp-F1",
participant2Id: "cp-out1",
},
{
scoringEventId: "ev-PRIMARY",
round: "Round of 32",
winnerId: "cp-F2",
loserId: "cp-out2",
participant1Id: "cp-F2",
participant2Id: "cp-out2",
},
{
scoringEventId: "ev-PRIMARY",
round: "Round of 16",
winnerId: "cp-W1",
loserId: "cp-L1",
participant1Id: "cp-W1",
participant2Id: "cp-L1",
},
{
scoringEventId: "ev-PRIMARY",
round: "Round of 16",
winnerId: "cp-W2",
loserId: "cp-L2",
participant1Id: "cp-W2",
participant2Id: "cp-L2",
},
],
});
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",
bracketTemplateId: "tennis_128",
} as never);
// Primary window's derived results (what processQualifyingBracketEvent wrote):
// four players at placement 9 (2 final losers + 2 floored), two at placement 5.
vi.mocked(getEventResults).mockResolvedValue(
[
["sp-PL1", "cp-L1", 9],
["sp-PL2", "cp-L2", 9],
["sp-PF1", "cp-F1", 9],
["sp-PF2", "cp-F2", 9],
["sp-PW1", "cp-W1", 5],
["sp-PW2", "cp-W2", 5],
].map(([seasonParticipantId, participantId, placement]) => ({
placement,
rawScore: null,
notParticipating: false,
seasonParticipantId,
seasonParticipant: { participantId },
})) 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", { markComplete: false });
// Sanity: only four canonical rows sit at placement 9 (the live count is 4).
expect(
state.tournamentResults.filter((r) => r.placement === 9)
).toHaveLength(4);
// The mirror window was scored with the STRUCTURAL span, not the row count.
const mirrorCall = vi
.mocked(processQualifyingEvent)
.mock.calls.find((c) => c[0] === "ev-MIRROR");
expect(mirrorCall).toBeDefined();
const tieMap = mirrorCall![2]!.canonicalTieCountByPlacement as Map<number, number>;
expect(tieMap.get(9)).toBe(8); // R16 tier spans 8, not the 4 rows currently there
expect(tieMap.get(5)).toBe(4); // QF tier spans 4
});
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

@ -5,10 +5,13 @@ import {
processQualifyingEvent,
recalculateAffectedLeagues,
buildTieCountByPlacement,
deriveBracketQualifyingStates,
getRoundConfig,
} from "~/models/scoring-calculator";
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
import { getEventResults } from "~/models/event-result";
import { upsertTournamentResult } from "~/models/tournament-result";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import { logger } from "~/lib/logger";
export interface SyncReport {
@ -43,6 +46,20 @@ export interface SyncOptions {
* (e.g. a mis-split 2 correct 1.5), which would otherwise re-ping every league.
*/
skipNotifications?: boolean;
/**
* Pre-computed tie span (placement tieCount) to split QP across each tied
* group, overriding the row-count derived from canonical tournament_results.
*
* For bracket majors (tennis, CS2) the tie span is a STRUCTURAL property of the
* round R16 spans 8 slots (9th16th), QF 4, SF 2, Final 1 not the live count
* of players currently sitting at a placement. Mid-tournament, players "floored"
* at a tier make the canonical row-count diverge from the structural span, so a
* mirror window would split differently than the primary (e.g. R16 losers at
* 2 QP instead of 1.5). syncMajorFromPrimaryEvent derives this map from the
* primary bracket so every window splits identically to the primary at every
* stage. Omitted for golf-style majors (no bracket) canonical row-count is used.
*/
tieCountByPlacement?: Map<number, number>;
}
/**
@ -65,7 +82,12 @@ export async function syncTournamentResults(
options: SyncOptions = {}
): Promise<SyncReport> {
const db = database();
const { markComplete = true, skipEventId, skipNotifications = false } = options;
const {
markComplete = true,
skipEventId,
skipNotifications = false,
tieCountByPlacement,
} = options;
const report: SyncReport = {
tournamentId,
@ -80,10 +102,16 @@ export async function syncTournamentResults(
.from(schema.tournamentResults)
.where(eq(schema.tournamentResults.tournamentId, tournamentId));
// The full-field tie span (placement → count) is a property of the whole
// tournament, so compute it once here and hand it to every window's
// processQualifyingEvent instead of re-querying canonical results per window.
// The tie span (placement → count) used to split QP across a tied group. Default
// is the count of canonical rows at each placement (a whole-tournament property,
// computed once here). For bracket majors the caller also passes the STRUCTURAL
// span from the primary bracket (R16 = 8, QF = 4, …); merge it OVER the counts so
// bracket placements split like the primary even mid-tournament while any
// non-bracket placements (e.g. CS2 Swiss exits, golf) keep their canonical count.
const canonicalTieCountByPlacement = buildTieCountByPlacement(canonicalResults);
const effectiveTieCountByPlacement = tieCountByPlacement
? new Map([...canonicalTieCountByPlacement, ...tieCountByPlacement])
: canonicalTieCountByPlacement;
// 2. Load every scoring_event that points at this tournament.
const allLinkedEvents = await db
@ -191,7 +219,7 @@ export async function syncTournamentResults(
// 3d. Delegate to scoring engine inside the same transaction.
await processQualifyingEvent(ev.id, tx, {
skipNotifications,
canonicalTieCountByPlacement,
canonicalTieCountByPlacement: effectiveTieCountByPlacement,
});
// 3e. Mark the window event complete (final-results sync only). This is
@ -333,13 +361,70 @@ export async function syncMajorFromPrimaryEvent(
`[syncMajorFromPrimaryEvent] promoted ${promotedCanonicalIds.size} result(s) from primary ${primaryEventId} to tournament ${tournamentId}; removed ${staleIds.length} stale`
);
// For a bracket major, split each placement's QP by the round's STRUCTURAL tie
// span (the same span the primary used via processQualifyingBracketEvent), not by
// the live count of canonical rows at that placement. Mid-tournament, players
// floored at a tier inflate the row-count and would make mirror windows split
// differently than the primary (e.g. R16 losers → 2 QP instead of 1.5). Derived
// here so every fan-out caller (reprocess, live rounds, finalize) stays consistent.
const tieCountByPlacement = await deriveStructuralTieSpanForBracket(
primaryEvent,
db
);
return syncTournamentResults(tournamentId, {
markComplete,
skipEventId: primaryEventId,
skipNotifications,
tieCountByPlacement,
});
}
/**
* Build a placement structural tie-span map for a bracket major's primary
* window, mirroring the spans processQualifyingBracketEvent assigns (R16 losers
* span 8 slots, QF 4, SF 2, Final 1). Returns undefined for a primary with no
* bracket template or no matches (e.g. golf), so the fan-out falls back to counting
* canonical rows exactly as before.
*
* The span for a given placement is consistent across the bracket (every R16-tier
* state carries tieCount 8, whether the player is a final loser or still floored),
* so collapsing the per-participant states into a placement tieCount map is safe.
*/
async function deriveStructuralTieSpanForBracket(
primaryEvent: { id: string; bracketTemplateId: string | null },
db: ReturnType<typeof database>
): Promise<Map<number, number> | undefined> {
if (!primaryEvent.bracketTemplateId) return undefined;
const template = BRACKET_TEMPLATES[primaryEvent.bracketTemplateId];
if (!template) return undefined;
const matches = await db
.select()
.from(schema.playoffMatches)
.where(eq(schema.playoffMatches.scoringEventId, primaryEvent.id));
if (matches.length === 0) return undefined;
const states = deriveBracketQualifyingStates(
matches.map((m) => ({
round: m.round,
winnerId: m.winnerId,
loserId: m.loserId,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
})),
template.rounds,
(round) => getRoundConfig(round, primaryEvent.bracketTemplateId)
);
if (states.size === 0) return undefined;
const map = new Map<number, number>();
for (const { placement, tieCount } of states.values()) {
map.set(placement, tieCount);
}
return map;
}
/**
* Convenience guard for route handlers: fan out a just-scored bracket/stage event
* to its sibling windows ONLY when it's the designated primary of a shared