Merge pull request 'claude/reprocess-bracket-scoring-mbkfa4' (#128) from claude/reprocess-bracket-scoring-mbkfa4 into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m5s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m20s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s

Reviewed-on: #128
This commit is contained in:
chrisp 2026-07-04 04:09:09 +00:00
commit 8416b54052
4 changed files with 283 additions and 26 deletions

View file

@ -286,7 +286,7 @@ export function PlayoffBracket({
.map((id) => participantMap.get(id)) .map((id) => participantMap.get(id))
.filter((p): p is Participant => p !== undefined) .filter((p): p is Participant => p !== undefined)
// Owned-by-a-manager players first, then alphabetical by name. // Owned-by-a-manager players first, then alphabetical by name.
.sort((a, b) => { .toSorted((a, b) => {
const aOwned = ownershipMap.has(a.id); const aOwned = ownershipMap.has(a.id);
const bOwned = ownershipMap.has(b.id); const bOwned = ownershipMap.has(b.id);
if (aOwned !== bOwned) return aOwned ? -1 : 1; if (aOwned !== bOwned) return aOwned ? -1 : 1;

View file

@ -65,7 +65,7 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server"; 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 { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis"; import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis";
@ -763,11 +763,32 @@ export async function action({ request, params }: Route.ActionArgs) {
// skipDiscord: reprocess is a data-correction tool, not a result announcement. // skipDiscord: reprocess is a data-correction tool, not a result announcement.
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true }); await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
} }
// Re-propagate corrected QP to sibling windows (data-correction; not final). // Re-propagate corrected QP to sibling/mirror windows (data-correction; not
await fanOutMajorIfPrimary(event, { markComplete: false }); // final). Call syncMajorFromPrimaryEvent directly rather than the
return { // swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the
success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`, // 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 });
// Surface a partial fan-out as an error so it renders as a warning
// banner, not a green success the admin might skim past while some
// mirror windows are left stale.
if (report.windowsFailed > 0) {
const reasons = report.failures.map((f) => f.error).join("; ");
return {
error: `${baseMessage} Synced ${report.windowsSynced} mirror window(s), but ${report.windowsFailed} failed (those windows may be stale): ${reasons}`,
};
}
return { success: `${baseMessage} Synced ${report.windowsSynced} mirror window(s).` };
} 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) { if (completed.length === 0) {

View file

@ -1,24 +1,28 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Mock } from "vitest";
import type * as DrizzleOrm from "drizzle-orm"; import type * as DrizzleOrm from "drizzle-orm";
import type * as ScoringCalculatorModule from "~/models/scoring-calculator";
vi.mock("~/database/context", () => ({ vi.mock("~/database/context", () => ({
database: vi.fn(), database: vi.fn(),
})); }));
vi.mock("~/models/scoring-calculator", () => ({ vi.mock("~/models/scoring-calculator", async () => {
processQualifyingEvent: vi.fn(), // Keep the real implementations of the PURE helpers so the tie-count map handed
recalculateAffectedLeagues: vi.fn(), // to processQualifyingEvent reflects the mock canonical results AND the bracket's
// Pure helper — keep the real implementation so the tie-count map handed to // structural tie span (deriveBracketQualifyingStates / getRoundConfig). Only the
// processQualifyingEvent reflects the mock canonical results. // DB-touching orchestrators are stubbed.
buildTieCountByPlacement: (results: Array<{ placement: number | null }>) => { const actual = await vi.importActual<typeof ScoringCalculatorModule>(
const map = new Map<number, number>(); "~/models/scoring-calculator"
for (const r of results) { );
if (r.placement === null) continue; return {
map.set(r.placement, (map.get(r.placement) ?? 0) + 1); processQualifyingEvent: vi.fn(),
} recalculateAffectedLeagues: vi.fn(),
return map; buildTieCountByPlacement: actual.buildTieCountByPlacement,
}, deriveBracketQualifyingStates: actual.deriveBracketQualifyingStates,
})); getRoundConfig: actual.getRoundConfig,
};
});
vi.mock("~/models/scoring-event", () => ({ vi.mock("~/models/scoring-event", () => ({
completeScoringEvent: vi.fn(), completeScoringEvent: vi.fn(),
@ -99,11 +103,21 @@ interface FakeEventResult {
// - event_results // - event_results
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface FakePlayoffMatch {
scoringEventId: string;
round: string;
winnerId: string | null;
loserId: string | null;
participant1Id: string | null;
participant2Id: string | null;
}
interface FakeDbState { interface FakeDbState {
tournamentResults: FakeTournamentResult[]; tournamentResults: FakeTournamentResult[];
scoringEvents: FakeScoringEvent[]; scoringEvents: FakeScoringEvent[];
seasonParticipants: FakeSeasonParticipant[]; seasonParticipants: FakeSeasonParticipant[];
eventResults: FakeEventResult[]; eventResults: FakeEventResult[];
playoffMatches: FakePlayoffMatch[];
} }
/** /**
@ -151,6 +165,8 @@ function makeFakeDb(state: FakeDbState) {
return state.seasonParticipants; return state.seasonParticipants;
case "event_results": case "event_results":
return state.eventResults; return state.eventResults;
case "playoff_matches":
return state.playoffMatches;
default: default:
throw new Error(`Unknown table in fake db: ${name}`); throw new Error(`Unknown table in fake db: ${name}`);
} }
@ -291,6 +307,7 @@ function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
scoringEvents: [], scoringEvents: [],
seasonParticipants: [], seasonParticipants: [],
eventResults: [], eventResults: [],
playoffMatches: [],
...overrides, ...overrides,
}; };
} }
@ -836,6 +853,140 @@ describe("syncMajorFromPrimaryEvent", () => {
expect(completeScoringEvent).not.toHaveBeenCalled(); 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 = (processQualifyingEvent as Mock).mock.calls.find(
(c) => c[0] === "ev-MIRROR"
);
expect(mirrorCall).toBeDefined();
if (!mirrorCall) return;
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 () => { it("throws when the primary event is not linked to a tournament", async () => {
const db = makeFakeDb(seedBasicState()); const db = makeFakeDb(seedBasicState());
vi.mocked(database).mockReturnValue(db as never); vi.mocked(database).mockReturnValue(db as never);

View file

@ -5,10 +5,13 @@ import {
processQualifyingEvent, processQualifyingEvent,
recalculateAffectedLeagues, recalculateAffectedLeagues,
buildTieCountByPlacement, buildTieCountByPlacement,
deriveBracketQualifyingStates,
getRoundConfig,
} from "~/models/scoring-calculator"; } from "~/models/scoring-calculator";
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event"; import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
import { getEventResults } from "~/models/event-result"; import { getEventResults } from "~/models/event-result";
import { upsertTournamentResult } from "~/models/tournament-result"; import { upsertTournamentResult } from "~/models/tournament-result";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
export interface SyncReport { 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. * (e.g. a mis-split 2 correct 1.5), which would otherwise re-ping every league.
*/ */
skipNotifications?: boolean; 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 = {} options: SyncOptions = {}
): Promise<SyncReport> { ): Promise<SyncReport> {
const db = database(); const db = database();
const { markComplete = true, skipEventId, skipNotifications = false } = options; const {
markComplete = true,
skipEventId,
skipNotifications = false,
tieCountByPlacement,
} = options;
const report: SyncReport = { const report: SyncReport = {
tournamentId, tournamentId,
@ -80,10 +102,16 @@ export async function syncTournamentResults(
.from(schema.tournamentResults) .from(schema.tournamentResults)
.where(eq(schema.tournamentResults.tournamentId, tournamentId)); .where(eq(schema.tournamentResults.tournamentId, tournamentId));
// The full-field tie span (placement → count) is a property of the whole // The tie span (placement → count) used to split QP across a tied group. Default
// tournament, so compute it once here and hand it to every window's // is the count of canonical rows at each placement (a whole-tournament property,
// processQualifyingEvent instead of re-querying canonical results per window. // 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 canonicalTieCountByPlacement = buildTieCountByPlacement(canonicalResults);
const effectiveTieCountByPlacement = tieCountByPlacement
? new Map([...canonicalTieCountByPlacement, ...tieCountByPlacement])
: canonicalTieCountByPlacement;
// 2. Load every scoring_event that points at this tournament. // 2. Load every scoring_event that points at this tournament.
const allLinkedEvents = await db const allLinkedEvents = await db
@ -191,7 +219,7 @@ export async function syncTournamentResults(
// 3d. Delegate to scoring engine inside the same transaction. // 3d. Delegate to scoring engine inside the same transaction.
await processQualifyingEvent(ev.id, tx, { await processQualifyingEvent(ev.id, tx, {
skipNotifications, skipNotifications,
canonicalTieCountByPlacement, canonicalTieCountByPlacement: effectiveTieCountByPlacement,
}); });
// 3e. Mark the window event complete (final-results sync only). This is // 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` `[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, { return syncTournamentResults(tournamentId, {
markComplete, markComplete,
skipEventId: primaryEventId, skipEventId: primaryEventId,
skipNotifications, 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 * 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 * to its sibling windows ONLY when it's the designated primary of a shared