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 * 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,28 @@ 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 });
|
||||||
|
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) {
|
if (completed.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,22 @@ 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 import("~/models/scoring-calculator")>(
|
||||||
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 +101,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 +163,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 +305,7 @@ function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
|
||||||
scoringEvents: [],
|
scoringEvents: [],
|
||||||
seasonParticipants: [],
|
seasonParticipants: [],
|
||||||
eventResults: [],
|
eventResults: [],
|
||||||
|
playoffMatches: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -836,6 +851,139 @@ 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 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 () => {
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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 (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 = {}
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue