brackt/app/services/sync-tournament-results.ts
Claude 0baef5a948
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
2026-07-04 01:45:53 +00:00

448 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { eq, and, inArray } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
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 {
tournamentId: string;
windowsSynced: number;
windowsFailed: number;
failures: Array<{
scoringEventId: string;
sportsSeasonId: string;
error: string;
}>;
}
export interface SyncOptions {
/**
* Mark each synced window's scoring_event as complete (is_complete +
* completed_at). Use for final results (golf batch entry, finalize-bracket).
* Leave false for mid-tournament fan-out (e.g. live bracket rounds) so a major
* isn't marked complete before it actually finishes.
* @default true
*/
markComplete?: boolean;
/**
* Skip the primary window when fanning out from a bracket/stage major — the
* primary is already scored in place, so re-running it would be wasted work.
*/
skipEventId?: string;
/**
* Suppress ALL Discord notifications for this fan-out (both the per-window QP
* update from processQualifyingEvent and the league standings recalc). Used by
* one-off backfills that re-score historical events — the QP values change
* (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>;
}
/**
* Fan out canonical tournament_results to every scoring_event window that
* points at this tournament. For each linked window:
* 1. Upsert event_results rows mirroring canonical placement / rawScore for
* every roster participant that appears in the canonical results.
* 2. Write 0-QP filler rows for roster participants who have no result.
* 3. Delegate to processQualifyingEvent to compute QP.
*
* Each window runs in its own transaction so partial failures isolate.
* Errors in one window never interrupt sync of the remaining windows.
*
* This service NEVER writes qualifying_points_awarded directly (except the
* "0" filler for participants with no placement) — QP calculation for real
* results is owned by processQualifyingEvent.
*/
export async function syncTournamentResults(
tournamentId: string,
options: SyncOptions = {}
): Promise<SyncReport> {
const db = database();
const {
markComplete = true,
skipEventId,
skipNotifications = false,
tieCountByPlacement,
} = options;
const report: SyncReport = {
tournamentId,
windowsSynced: 0,
windowsFailed: 0,
failures: [],
};
// 1. Load canonical results for this tournament.
const canonicalResults = await db
.select()
.from(schema.tournamentResults)
.where(eq(schema.tournamentResults.tournamentId, tournamentId));
// 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
.select()
.from(schema.scoringEvents)
.where(eq(schema.scoringEvents.tournamentId, tournamentId));
// The primary window (when fanning out from a bracket/stage major) is already
// scored in place — skip it so we don't redo its work.
const linkedEvents = skipEventId
? allLinkedEvents.filter((ev) => ev.id !== skipEventId)
: allLinkedEvents;
// Windows that synced cleanly — used to propagate to leagues after the loop
// (kept out of the per-window transaction so a recalc failure can't roll back
// the result write).
const syncedWindows: Array<{
sportsSeasonId: string;
eventId: string;
eventName: string | null;
}> = [];
// 3. Fan out — each event gets its own transaction.
for (const ev of linkedEvents) {
try {
await db.transaction(async (tx: typeof db) => {
// 3a. Window roster = season_participants for this event's sports_season.
const rosters = await tx
.select()
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, ev.sportsSeasonId));
// 3b. For each canonical result whose participant is on the roster,
// upsert the matching event_results row. Only placement / rawScore
// are copied; qualifying_points_awarded is left for
// processQualifyingEvent to recompute.
const canonicalSpIds = new Set<string>();
for (const cr of canonicalResults) {
const sp = rosters.find((r) => r.participantId === cr.participantId);
if (!sp) continue;
canonicalSpIds.add(sp.id);
const [existing] = await tx
.select()
.from(schema.eventResults)
.where(
and(
eq(schema.eventResults.scoringEventId, ev.id),
eq(schema.eventResults.seasonParticipantId, sp.id)
)
);
if (existing) {
await tx
.update(schema.eventResults)
.set({
placement: cr.placement,
rawScore: cr.rawScore,
updatedAt: new Date(),
})
.where(eq(schema.eventResults.id, existing.id));
} else {
await tx.insert(schema.eventResults).values({
scoringEventId: ev.id,
seasonParticipantId: sp.id,
placement: cr.placement,
rawScore: cr.rawScore,
});
}
}
// 3c. Reconcile every roster member NOT in the canonical results to a
// 0-QP filler row (placement=null, QP=0). This both creates missing rows
// AND resets rows for participants whose canonical placement was removed
// (a correction) — without this, a stale placement/rawScore would persist
// and keep fanning out. notParticipating markers are left untouched.
const afterRows = await tx
.select()
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, ev.id));
const existingBySp = new Map(afterRows.map((r) => [r.seasonParticipantId, r]));
for (const sp of rosters) {
if (canonicalSpIds.has(sp.id)) continue;
const existing = existingBySp.get(sp.id);
if (!existing) {
await tx.insert(schema.eventResults).values({
scoringEventId: ev.id,
seasonParticipantId: sp.id,
placement: null,
qualifyingPointsAwarded: "0",
});
} else if (!existing.notParticipating && existing.placement !== null) {
// Stale placement from a prior sync — reset to filler.
await tx
.update(schema.eventResults)
.set({
placement: null,
rawScore: null,
qualifyingPointsAwarded: "0",
updatedAt: new Date(),
})
.where(eq(schema.eventResults.id, existing.id));
}
}
// 3d. Delegate to scoring engine inside the same transaction.
await processQualifyingEvent(ev.id, tx, {
skipNotifications,
canonicalTieCountByPlacement: effectiveTieCountByPlacement,
});
// 3e. Mark the window event complete (final-results sync only). This is
// what makes "score once" actually complete every window — without it,
// each sibling stayed "In Progress" and had to be completed by hand.
// Skip windows already complete so a re-run (e.g. a backfill) doesn't
// needlessly re-stamp completedAt.
if (markComplete && !ev.isComplete) {
await completeScoringEvent(ev.id, tx);
}
});
report.windowsSynced += 1;
syncedWindows.push({
sportsSeasonId: ev.sportsSeasonId,
eventId: ev.id,
eventName: ev.name,
});
} catch (e: unknown) {
report.windowsFailed += 1;
const msg =
e instanceof Error ? e.message : typeof e === "string" ? e : String(e);
report.failures.push({
scoringEventId: ev.id,
sportsSeasonId: ev.sportsSeasonId,
error: msg,
});
}
}
// 4. Propagate to leagues for every cleanly-synced window. Done outside the
// per-window transactions: recalculateAffectedLeagues touches many fantasy
// seasons and must not roll back a committed result write if it fails.
for (const w of syncedWindows) {
try {
await recalculateAffectedLeagues(w.sportsSeasonId, undefined, {
eventId: w.eventId,
eventName: w.eventName ?? undefined,
// Mid-tournament fan-out (markComplete=false) updates standings silently;
// the primary window already announced the round. Only the final sync
// (completion) announces to each window's leagues. A backfill
// (skipNotifications) is always silent.
skipDiscord: skipNotifications || !markComplete,
});
} catch (e: unknown) {
const msg =
e instanceof Error ? e.message : typeof e === "string" ? e : String(e);
// Count this as a failed window so the caller's status derivation
// (windowsFailed === 0 ? "completed") doesn't mask stale standings behind
// a green badge — the result write committed, but the league is not current.
report.windowsFailed += 1;
report.failures.push({
scoringEventId: w.eventId,
sportsSeasonId: w.sportsSeasonId,
error: `League recalc failed: ${msg}`,
});
}
}
return report;
}
/**
* Fan out a bracket/stage major (tennis, CS2) from its primary window.
*
* The admin builds and scores the bracket/Swiss stages on ONE window (the
* primary). Those derive per-participant placements into the primary's
* event_results. This reads them back, translates each window-scoped
* season_participant to its canonical participant, and persists them as the
* tournament's canonical tournament_results — the exact same shape golf produces
* by hand. It then runs the normal fan-out to every sibling window (skipping the
* primary, which is already scored).
*
* markComplete: pass false while a tournament is still in progress (live rounds)
* so siblings stay in sync without being marked finished; pass true on finalize.
*/
export async function syncMajorFromPrimaryEvent(
primaryEventId: string,
options: { markComplete?: boolean; skipNotifications?: boolean } = {}
): Promise<SyncReport> {
const { markComplete = false, skipNotifications = false } = options;
const primaryEvent = await getScoringEventById(primaryEventId);
if (!primaryEvent) {
throw new Error(`Primary event ${primaryEventId} not found`);
}
if (!primaryEvent.tournamentId) {
throw new Error(
`Event ${primaryEventId} is not linked to a tournament; cannot fan out`
);
}
const tournamentId = primaryEvent.tournamentId;
// Read the primary window's derived results and persist them as canonical
// tournament_results, keyed by canonical participant. Only real placements are
// promoted — unplaced/filler rows are reconstructed per-window by the fan-out.
const primaryResults = await getEventResults(primaryEventId);
const promotedCanonicalIds = new Set<string>();
for (const r of primaryResults) {
if (r.placement === null || r.notParticipating) continue;
const canonicalParticipantId = r.seasonParticipant?.participantId;
if (!canonicalParticipantId) {
logger.warn(
`[syncMajorFromPrimaryEvent] season_participant ${r.seasonParticipantId} has no canonical participant link; skipping`
);
continue;
}
await upsertTournamentResult({
tournamentId,
participantId: canonicalParticipantId,
placement: r.placement,
rawScore: r.rawScore,
});
promotedCanonicalIds.add(canonicalParticipantId);
}
// Remove canonical rows for participants no longer placed on the primary (a
// correction that dropped someone) so the stale placement stops fanning out.
const db = database();
const existingCanonical = await db
.select({ participantId: schema.tournamentResults.participantId })
.from(schema.tournamentResults)
.where(eq(schema.tournamentResults.tournamentId, tournamentId));
const staleIds = existingCanonical
.map((r) => r.participantId)
.filter((id) => !promotedCanonicalIds.has(id));
if (staleIds.length > 0) {
await db
.delete(schema.tournamentResults)
.where(
and(
eq(schema.tournamentResults.tournamentId, tournamentId),
inArray(schema.tournamentResults.participantId, staleIds)
)
);
}
logger.log(
`[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
* tournament. No-ops for standalone events (no tournament_id) or non-primary
* windows (which are read-only and never scored directly). Failures are logged
* but never thrown — fan-out must not break the admin's local scoring action.
*/
export async function fanOutMajorIfPrimary(
event: { id: string; isPrimary: boolean; tournamentId: string | null },
options: { markComplete?: boolean } = {}
): Promise<void> {
if (!event.isPrimary || !event.tournamentId) return;
try {
await syncMajorFromPrimaryEvent(event.id, options);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
logger.error(
`[fanOutMajorIfPrimary] fan-out failed for primary event ${event.id}: ${msg}`
);
}
}