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:
parent
58e656f15e
commit
0baef5a948
3 changed files with 275 additions and 25 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 9th–16th 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);
|
||||
|
|
|
|||
|
|
@ -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 (9th–16th), 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue