Unify majors: score once, fan out across windows + tennis bracket EV
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m27s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m25s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 14s

Make a "major" (golf/tennis/CS2) scored once on its canonical tournament
and fan out to every linked sports_season window and league.

Fan-out & completion (app/services/sync-tournament-results.ts):
- syncTournamentResults now marks each synced window's event complete
  (gated by markComplete), recalculates affected leagues, and counts
  recalc failures so a stale league can't hide behind a "completed" badge
- syncMajorFromPrimaryEvent promotes a primary window's derived results to
  canonical tournament_results (deleting rows for dropped placements) and
  fans out to siblings; fanOutMajorIfPrimary guards on the primary
- placement removals now propagate (stale rows reset to filler)

Primary-event model (scoring_events.is_primary, migration 0122):
- getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent /
  setPrimaryEvent; event creation auto-seeds a primary for bracket majors;
  "Make primary" button on the tournament page
- per-window event/bracket/cs2 pages are read-only for non-primary linked
  events (not-participating stays editable)

Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG):
- bracket-scored qualifying major via the existing bracket pipeline
- simulator conditions in-progress EV on the real bracket (honoring
  completed matches, walkover for withdrawals), QP derived from config,
  round structure read from the template; CS2 + tennis share resolveStructureSource

Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile
of existing majors (link orphans, designate primary, promote canonical, sync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-06-22 20:32:22 -07:00
parent b960d39be3
commit 9480501932
23 changed files with 8689 additions and 150 deletions

View file

@ -455,6 +455,31 @@ export const DARTS_128: BracketTemplate = {
], ],
}; };
/**
* Tennis Grand Slam (128-player single-elimination draw)
* R128 R64 R32 Round of 16 Quarterfinals Semifinals Final
*
* Qualifying-points sport: QP is derived from how far each player advances
* (see TEMPLATE_ROUND_CONFIG["tennis_128"] in scoring-calculator.ts). Scoring
* begins at the Round of 16 R16 losers share placements 916; earlier rounds
* award nothing. Seeding: top 32 fixed, remaining 96 randomly drawn.
*/
export const TENNIS_128: BracketTemplate = {
id: "tennis_128",
name: "Tennis Grand Slam (128 Players)",
totalTeams: 128,
scoringStartsAtRound: "Round of 16",
rounds: [
{ name: "Round of 128", matchCount: 64, feedsInto: "Round of 64", isScoring: false },
{ name: "Round of 64", matchCount: 32, feedsInto: "Round of 32", isScoring: false },
{ name: "Round of 32", matchCount: 16, feedsInto: "Round of 16", isScoring: false },
{ name: "Round of 16", matchCount: 8, feedsInto: "Quarterfinals", isScoring: true }, // losers share 9th16th
{ name: "Quarterfinals", matchCount: 4, feedsInto: "Semifinals", isScoring: true }, // losers share 5th8th
{ name: "Semifinals", matchCount: 2, feedsInto: "Final", isScoring: true }, // losers share 3rd4th
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true }, // winner 1st, loser 2nd
],
};
/** /**
* NCAA March Madness (68 teams) * NCAA March Madness (68 teams)
* First Four (play-in) Round of 64 Round of 32 Sweet Sixteen Elite Eight Final Four Championship * First Four (play-in) Round of 64 Round of 32 Sweet Sixteen Elite Eight Final Four Championship
@ -923,6 +948,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
afl_10: AFL_10, afl_10: AFL_10,
fifa_48: FIFA_48, fifa_48: FIFA_48,
darts_128: DARTS_128, darts_128: DARTS_128,
tennis_128: TENNIS_128,
cfp_12: CFP_12, cfp_12: CFP_12,
nba_20: NBA_20, nba_20: NBA_20,
}; };

View file

@ -1,11 +1,23 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
const mockFindMany = vi.fn(); const mockFindMany = vi.fn();
const mockFindFirst = vi.fn();
// Records each db.update(...).set(patch).where(pred) call for assertions.
const updateCalls: Array<{ patch: unknown; where: unknown }> = [];
const mockUpdate = vi.fn(() => ({
set: (patch: unknown) => ({
where: (where: unknown) => {
updateCalls.push({ patch, where });
return Promise.resolve();
},
}),
}));
const mockDb = { const mockDb = {
query: { query: {
scoringEvents: { findMany: mockFindMany }, scoringEvents: { findMany: mockFindMany, findFirst: mockFindFirst },
}, },
update: mockUpdate,
}; };
vi.mock("~/database/context", () => ({ vi.mock("~/database/context", () => ({
@ -14,8 +26,10 @@ vi.mock("~/database/context", () => ({
vi.mock("~/database/schema", () => ({ vi.mock("~/database/schema", () => ({
scoringEvents: { scoringEvents: {
id: "se.id",
sportsSeasonId: "se.sports_season_id", sportsSeasonId: "se.sports_season_id",
tournamentId: "se.tournament_id", tournamentId: "se.tournament_id",
isPrimary: "se.is_primary",
}, },
})); }));
@ -23,11 +37,14 @@ vi.mock("drizzle-orm", () => ({
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }), eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
and: (...args: unknown[]) => ({ type: "and", args }), and: (...args: unknown[]) => ({ type: "and", args }),
isNotNull: (col: unknown) => ({ type: "isNotNull", col }), isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
asc: (col: unknown) => ({ type: "asc", col }),
})); }));
import { import {
getTournamentsBySportsSeason, getTournamentsBySportsSeason,
getSportsSeasonsByTournament, getSportsSeasonsByTournament,
ensurePrimaryEvent,
setPrimaryEvent,
} from "../scoring-event"; } from "../scoring-event";
const SEASON_ID = "ss-1"; const SEASON_ID = "ss-1";
@ -39,6 +56,7 @@ const mockTournamentB = { id: TOURNAMENT_ID_B, name: "US Open", year: 2026 };
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
updateCalls.length = 0;
}); });
describe("getTournamentsBySportsSeason", () => { describe("getTournamentsBySportsSeason", () => {
@ -116,3 +134,37 @@ describe("getSportsSeasonsByTournament", () => {
); );
}); });
}); });
describe("ensurePrimaryEvent", () => {
it("sets the event primary when the tournament has no primary yet", async () => {
mockFindFirst.mockResolvedValue(undefined); // no existing primary
const result = await ensurePrimaryEvent(TOURNAMENT_ID_A, "ev-1");
expect(result).toBe("ev-1");
expect(updateCalls).toHaveLength(1);
expect(updateCalls[0].patch).toMatchObject({ isPrimary: true });
});
it("is idempotent: keeps the existing primary and writes nothing", async () => {
mockFindFirst.mockResolvedValue({ id: "ev-existing" });
const result = await ensurePrimaryEvent(TOURNAMENT_ID_A, "ev-2");
expect(result).toBe("ev-existing");
expect(updateCalls).toHaveLength(0);
});
});
describe("setPrimaryEvent", () => {
it("clears siblings then sets the chosen event primary", async () => {
mockFindFirst.mockResolvedValue({ id: "ev-3", tournamentId: TOURNAMENT_ID_A });
await setPrimaryEvent("ev-3");
// Two updates: clear-all (isPrimary:false) then set-one (isPrimary:true).
expect(updateCalls).toHaveLength(2);
expect(updateCalls[0].patch).toMatchObject({ isPrimary: false });
expect(updateCalls[1].patch).toMatchObject({ isPrimary: true });
});
it("throws when the event is not linked to a tournament", async () => {
mockFindFirst.mockResolvedValue({ id: "ev-4", tournamentId: null });
await expect(setPrimaryEvent("ev-4")).rejects.toThrow(/not linked to a tournament/);
expect(updateCalls).toHaveLength(0);
});
});

View file

@ -112,6 +112,16 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
// 3rd place game finalizes both positions distinctly. // 3rd place game finalizes both positions distinctly.
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 }, "Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
}, },
tennis_128: {
// R16 losers share 9th16th; winner advances to QF (floor 5th8th).
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
// QF losers share 5th8th; winner advances to SF (floor 3rd4th).
Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
// SF losers share 3rd4th; winner advances to Final (floor 2nd).
Semifinals: { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
// Final finalizes both: winner 1st, loser 2nd.
Final: { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
},
}; };
/** /**

View file

@ -1067,3 +1067,94 @@ export async function getSportsSeasonsByTournament(tournamentId: string) {
return true; return true;
}); });
} }
/**
* The primary scoring event for a tournament the single window where the admin
* builds the bracket/stages and scoring happens; its results fan out to siblings.
* Returns the explicitly-flagged primary, falling back to the earliest-created
* linked event so callers (league/sim loaders) always have a structure source
* even before a primary is designated.
*/
export async function getPrimaryEventForTournament(
tournamentId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const primary = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.tournamentId, tournamentId),
eq(schema.scoringEvents.isPrimary, true)
),
});
if (primary) return primary;
return db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.tournamentId, tournamentId),
orderBy: asc(schema.scoringEvents.createdAt),
});
}
/**
* A tournament-linked, non-primary event is a read-only mirror: its results are
* owned by the shared major's primary window and fan out to it. Direct scoring
* on such an event must be rejected so windows can't diverge. Single source of
* truth for that predicate, shared by the admin route guards and the loaders.
*/
export function isReadOnlySibling(event: {
tournamentId: string | null;
isPrimary: boolean;
}): boolean {
return !!event.tournamentId && !event.isPrimary;
}
/**
* Make `eventId` the single primary window for its tournament set it primary
* and clear the flag on every sibling. Used by the "Make this the primary
* window" admin action and to seed a primary when the first bracket-major event
* is created.
*/
export async function setPrimaryEvent(
eventId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
});
if (!event?.tournamentId) {
throw new Error(`Event ${eventId} is not linked to a tournament`);
}
await db
.update(schema.scoringEvents)
.set({ isPrimary: false, updatedAt: new Date() })
.where(eq(schema.scoringEvents.tournamentId, event.tournamentId));
await db
.update(schema.scoringEvents)
.set({ isPrimary: true, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, eventId));
}
/**
* Designate `eventId` as the tournament's primary window only if none is set
* yet. Idempotent: returns the existing primary's id when one already exists, so
* creating/linking additional windows never steals primary from the first.
*/
export async function ensurePrimaryEvent(
tournamentId: string,
eventId: string,
providedDb?: ReturnType<typeof database>
): Promise<string> {
const db = providedDb || database();
const existing = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.tournamentId, tournamentId),
eq(schema.scoringEvents.isPrimary, true)
),
});
if (existing) return existing.id;
await db
.update(schema.scoringEvents)
.set({ isPrimary: true, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, eventId));
return eventId;
}

View file

@ -2,7 +2,7 @@ import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.br
import { findSportsSeasonById } from "~/models/sports-season"; import { findSportsSeasonById } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant"; import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event"; import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import { import {
findPlayoffMatchesByEventId, findPlayoffMatchesByEventId,
generateBracketFromTemplate, generateBracketFromTemplate,
@ -61,6 +61,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";
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id); const sportsSeason = await findSportsSeasonById(params.id);
@ -120,7 +121,13 @@ export async function loader({ params }: Route.LoaderArgs) {
* fantasy placements come from finalizeQualifyingPoints across all majors. * fantasy placements come from finalizeQualifyingPoints across all majors.
*/ */
async function scoreQualifyingBracket( async function scoreQualifyingBracket(
event: { id: string; sportsSeasonId: string; name: string | null }, event: {
id: string;
sportsSeasonId: string;
name: string | null;
isPrimary: boolean;
tournamentId: string | null;
},
db: ReturnType<typeof database>, db: ReturnType<typeof database>,
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2] recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2]
): Promise<void> { ): Promise<void> {
@ -130,12 +137,28 @@ async function scoreQualifyingBracket(
db, db,
recalcOptions ?? { eventId: event.id, eventName: event.name ?? undefined } recalcOptions ?? { eventId: event.id, eventName: event.name ?? undefined }
); );
// If this is the shared major's primary window, propagate to siblings.
// Mid-tournament (a single round): don't mark complete yet.
await fanOutMajorIfPrimary(event, { markComplete: false });
} }
export async function action({ request, params }: Route.ActionArgs) { export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData(); const formData = await request.formData();
const intent = formData.get("intent"); const intent = formData.get("intent");
// Brackets are built/scored only on the major's primary window. A
// tournament-linked, non-primary event is a read-only mirror that receives
// results via fan-out, so reject every mutating action here.
{
const ev = await getScoringEventById(params.eventId);
if (ev && isReadOnlySibling(ev)) {
return {
error:
"This bracket belongs to a shared major. Build and score it on the primary window (linked from Admin → Tournaments); results fan out here automatically.",
};
}
}
if (intent === "generate-bracket") { if (intent === "generate-bracket") {
const templateId = formData.get("templateId"); const templateId = formData.get("templateId");
@ -474,6 +497,9 @@ export async function action({ request, params }: Route.ActionArgs) {
// qualifying majors, which are scored via processQualifyingBracketEvent above. // qualifying majors, which are scored via processQualifyingBracketEvent above.
if (!event.isQualifyingEvent) { if (!event.isQualifyingEvent) {
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
} else {
// Shared major primary window: propagate this round to siblings.
await fanOutMajorIfPrimary(event, { markComplete: false });
} }
} }
@ -637,6 +663,8 @@ 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).
await fanOutMajorIfPrimary(event, { markComplete: false });
return { return {
success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`, success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`,
}; };
@ -766,6 +794,8 @@ export async function action({ request, params }: Route.ActionArgs) {
eventId: params.eventId, eventId: params.eventId,
eventName: event.name ?? undefined, eventName: event.name ?? undefined,
}); });
// Finalize: propagate to siblings AND mark every window complete.
await fanOutMajorIfPrimary(event, { markComplete: true });
return { success: "Major bracket finalized — qualifying points awarded." }; return { success: "Major bracket finalized — qualifying points awarded." };
} }

View file

@ -2,7 +2,7 @@ import { Form, useLoaderData, useActionData, useNavigation, useFetcher, Link } f
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup'; import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
import { findSportsSeasonById } from '~/models/sports-season'; import { findSportsSeasonById } from '~/models/sports-season';
import { getScoringEventById } from '~/models/scoring-event'; import { getScoringEventById, isReadOnlySibling } from '~/models/scoring-event';
import { findParticipantsBySportsSeasonId } from '~/models/season-participant'; import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
import { import {
getCs2StageResultsForEvent, getCs2StageResultsForEvent,
@ -14,6 +14,7 @@ import {
} from '~/models/cs2-major-stage'; } from '~/models/cs2-major-stage';
import { findSeasonMatchesByScoringEventId } from '~/models/season-match'; import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
import { syncMatches } from '~/services/match-sync'; import { syncMatches } from '~/services/match-sync';
import { fanOutMajorIfPrimary } from '~/services/sync-tournament-results';
import { Button } from '~/components/ui/button'; import { Button } from '~/components/ui/button';
import { import {
Card, Card,
@ -61,7 +62,20 @@ export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData(); const formData = await request.formData();
const intent = formData.get('intent') as string; const intent = formData.get('intent') as string;
// CS2 stages are set up only on the major's primary window; a tournament-linked
// non-primary event is a read-only mirror fed by fan-out.
const guardEvent = await getScoringEventById(eventId);
if (guardEvent && isReadOnlySibling(guardEvent)) {
return {
success: false,
message:
'This CS2 major is set up on its primary window; results fan out here automatically.',
};
}
if (intent === 'reset') { if (intent === 'reset') {
// Local recovery tool: clears only the primary's torn state. Siblings are not
// touched here — re-entering results re-propagates via the scoring fan-out.
await resetCs2Event(eventId, params.id); await resetCs2Event(eventId, params.id);
return { success: true, message: 'Stage assignments and recorded results cleared.' }; return { success: true, message: 'Stage assignments and recorded results cleared.' };
} }
@ -126,6 +140,10 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
await markCs2StageEliminations(eventId, eliminations); await markCs2StageEliminations(eventId, eliminations);
await assignCs2EliminationQP(eventId, params.id); await assignCs2EliminationQP(eventId, params.id);
// Propagate the derived QP/placements to every sibling window (in-progress:
// don't mark complete — that happens when the bracket is finalized).
const event = await getScoringEventById(eventId);
if (event) await fanOutMajorIfPrimary(event, { markComplete: false });
return { success: true, message: `Marked ${eliminations.length} eliminations.` }; return { success: true, message: `Marked ${eliminations.length} eliminations.` };
} catch (err) { } catch (err) {
return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' }; return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' };

View file

@ -6,6 +6,7 @@ import {
getScoringEventById, getScoringEventById,
completeScoringEvent, completeScoringEvent,
updateScoringEvent, updateScoringEvent,
isReadOnlySibling,
} from "~/models/scoring-event"; } from "~/models/scoring-event";
import { import {
getEventResults, getEventResults,
@ -77,6 +78,9 @@ export async function loader({ params }: Route.LoaderArgs) {
) )
: new Set(); : new Set();
// A tournament-linked event that is NOT the primary is a read-only mirror:
// scoring happens once on the canonical tournament (or its primary window) and
// fans out here. Surface this so the UI can point the admin to the right place.
return { return {
sportsSeason: sportsSeason as typeof sportsSeason & { sportsSeason: sportsSeason as typeof sportsSeason & {
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null }; sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
@ -89,13 +93,45 @@ export async function loader({ params }: Route.LoaderArgs) {
qpConfig, qpConfig,
qpStandings, qpStandings,
notParticipatingIds, notParticipatingIds,
isReadOnlySibling: isReadOnlySibling(event),
canonicalTournamentId: event.tournamentId,
}; };
} }
// Scoring intents that mutate this window's results directly. For a read-only
// sibling (tournament-linked, non-primary) these are rejected — score on the
// canonical tournament page instead, and the result fans out automatically.
//
// mark/unmark-not-participating are deliberately NOT here: they are per-window
// simulator inputs (exclude a participant from this window's EV draws), have no
// canonical-tournament equivalent, and must stay editable on every window.
const SCORING_INTENTS = new Set([
"process-qp",
"complete",
"uncomplete",
"add-result",
"update-result",
"delete-result",
"update-standings",
"batch-add-results",
]);
export async function action({ request, params }: Route.ActionArgs) { export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData(); const formData = await request.formData();
const intent = formData.get("intent"); const intent = formData.get("intent");
// Enforce single-source scoring: reject direct scoring on a read-only sibling.
if (typeof intent === "string" && SCORING_INTENTS.has(intent)) {
const ev = await getScoringEventById(params.eventId);
if (ev && isReadOnlySibling(ev)) {
return {
error:
"This event belongs to a shared major. Score it once on the tournament page (Admin → Tournaments) and it fans out to every window automatically.",
canonicalTournamentId: ev.tournamentId,
};
}
}
if (intent === "mark-qualifying") { if (intent === "mark-qualifying") {
try { try {
const event = await getScoringEventById(params.eventId); const event = await getScoringEventById(params.eventId);

View file

@ -95,9 +95,24 @@ export default function EventResults({
loaderData, loaderData,
actionData, actionData,
}: Route.ComponentProps) { }: Route.ComponentProps) {
const { sportsSeason, event, participants, results, participantResults, seasonResults, notParticipatingIds } = loaderData; const { sportsSeason, event, participants, results, participantResults, seasonResults, notParticipatingIds, isReadOnlySibling, canonicalTournamentId } = loaderData;
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
// Banner shown on read-only sibling events: scoring lives on the canonical
// tournament page, and results fan out here automatically.
const readOnlySiblingBanner = isReadOnlySibling ? (
<div className="bg-amber-500/15 text-amber-500 border border-amber-500/30 px-4 py-3 rounded-md text-sm mb-4">
This event is part of a shared major. Scoring is done once on the{" "}
<Link
to={`/admin/tournaments/${canonicalTournamentId}`}
className="underline font-medium"
>
tournament page
</Link>{" "}
and fans out to every window automatically the controls here are read-only.
</div>
) : null;
const isBracketEvent = event.eventType === "playoff_game" || isBracketMajor(sportsSeason.sport?.simulatorType); const isBracketEvent = event.eventType === "playoff_game" || isBracketMajor(sportsSeason.sport?.simulatorType);
// Create a map of participants with results for easy lookup // Create a map of participants with results for easy lookup
@ -218,6 +233,7 @@ export default function EventResults({
return ( return (
<div className="p-8"> <div className="p-8">
<div className="max-w-4xl"> <div className="max-w-4xl">
{readOnlySiblingBanner}
<div className="mb-6"> <div className="mb-6">
<Button variant="ghost" size="sm" asChild className="mb-2"> <Button variant="ghost" size="sm" asChild className="mb-2">
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events`}> <Link to={`/admin/sports-seasons/${sportsSeason.id}/events`}>

View file

@ -7,8 +7,10 @@ import {
createScoringEvent, createScoringEvent,
deleteScoringEvent, deleteScoringEvent,
bulkCreateScoringEvents, bulkCreateScoringEvents,
ensurePrimaryEvent,
type CreateScoringEventData, type CreateScoringEventData,
} from "~/models/scoring-event"; } from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
import { getQPStandings } from "~/models/qualifying-points"; import { getQPStandings } from "~/models/qualifying-points";
import { finalizeQualifyingPoints } from "~/models/scoring-calculator"; import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server"; import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
@ -92,6 +94,12 @@ export async function action({ request, params }: Route.ActionArgs) {
eventDate, eventDate,
eventStartsAt: tournament.startsAt ? new Date(tournament.startsAt) : undefined, eventStartsAt: tournament.startsAt ? new Date(tournament.startsAt) : undefined,
}); });
// Bracket majors (tennis/CS2) need a primary window to be scorable. Seed it
// on the first linked window; later windows stay siblings (idempotent).
const ss = await findSportsSeasonById(params.id);
if (isBracketMajor(ss?.sport?.simulatorType)) {
await ensurePrimaryEvent(tournament.id, event.id);
}
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`); return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
} catch (error) { } catch (error) {
logger.error("Error adding scoring event from tournament:", error); logger.error("Error adding scoring event from tournament:", error);
@ -185,7 +193,13 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
try { try {
await bulkCreateScoringEvents(params.id, validEvents); const created = await bulkCreateScoringEvents(params.id, validEvents);
// Seed a primary window per bracket-major tournament that lacks one.
if (isBracketMajor(sportsSeason.sport?.simulatorType)) {
for (const ev of created) {
if (ev.tournamentId) await ensurePrimaryEvent(ev.tournamentId, ev.id);
}
}
return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` }; return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` };
} catch (error) { } catch (error) {
logger.error("Error bulk creating events:", error); logger.error("Error bulk creating events:", error);
@ -282,6 +296,11 @@ export async function action({ request, params }: Route.ActionArgs) {
try { try {
const event = await createScoringEvent(eventData); const event = await createScoringEvent(eventData);
// Bracket majors need a primary window to be scorable — seed it on the first
// linked window (idempotent for later windows).
if (eventData.tournamentId && isBracketMajor(sportsSeason.sport?.simulatorType)) {
await ensurePrimaryEvent(eventData.tournamentId, event.id);
}
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`); return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
} catch (error) { } catch (error) {
logger.error("Error creating scoring event:", error); logger.error("Error creating scoring event:", error);

View file

@ -17,7 +17,12 @@ import {
syncTournamentResults, syncTournamentResults,
type SyncReport, type SyncReport,
} from "~/services/sync-tournament-results"; } from "~/services/sync-tournament-results";
import { getSportsSeasonsByTournament } from "~/models/scoring-event"; import {
getSportsSeasonsByTournament,
getPrimaryEventForTournament,
setPrimaryEvent,
} from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
import { import {
Card, Card,
CardContent, CardContent,
@ -52,13 +57,30 @@ export async function loader({ params }: Route.LoaderArgs) {
throw new Response("Not Found", { status: 404 }); throw new Response("Not Found", { status: 404 });
} }
const [results, canonicalParticipants, linkedSportsSeasons] = await Promise.all([ const [results, canonicalParticipants, linkedSportsSeasons, primaryEvent] =
await Promise.all([
getTournamentResults(tournament.id), getTournamentResults(tournament.id),
findCanonicalParticipantsBySport(tournament.sportId), findCanonicalParticipantsBySport(tournament.sportId),
getSportsSeasonsByTournament(tournament.id), getSportsSeasonsByTournament(tournament.id),
getPrimaryEventForTournament(tournament.id),
]); ]);
return { tournament, results, canonicalParticipants, linkedSportsSeasons }; // Bracket majors (tennis, CS2) are scored on their primary window's bracket /
// stage UI; golf is entered right here. Surface a link for the bracket case.
const simulatorType =
linkedSportsSeasons[0]?.sportsSeason?.sport?.simulatorType ?? null;
const bracketMajor = isBracketMajor(simulatorType);
const isCs2 = simulatorType === "cs2_major_qualifying_points";
return {
tournament,
results,
canonicalParticipants,
linkedSportsSeasons,
primaryEvent,
bracketMajor,
isCs2,
};
} }
export async function action(args: Route.ActionArgs) { export async function action(args: Route.ActionArgs) {
@ -127,11 +149,17 @@ export async function action(args: Route.ActionArgs) {
}); });
} }
if (tournament.status !== "completed") { const syncReport = await syncTournamentResults(tournament.id);
await updateTournamentStatus(tournament.id, "completed");
// Status reflects reality: completed only when every linked window synced.
// If any window failed, leave it in_progress so the failure is visible and
// retryable rather than masked by a green "completed" badge.
const newStatus =
syncReport.windowsFailed === 0 ? "completed" : "in_progress";
if (tournament.status !== newStatus) {
await updateTournamentStatus(tournament.id, newStatus);
} }
const syncReport = await syncTournamentResults(tournament.id);
return { return {
success: true as const, success: true as const,
error: null, error: null,
@ -151,6 +179,11 @@ export async function action(args: Route.ActionArgs) {
if (intent === "retry-window-sync") { if (intent === "retry-window-sync") {
try { try {
const syncReport = await syncTournamentResults(tournament.id); const syncReport = await syncTournamentResults(tournament.id);
const newStatus =
syncReport.windowsFailed === 0 ? "completed" : "in_progress";
if (tournament.status !== newStatus) {
await updateTournamentStatus(tournament.id, newStatus);
}
return { success: true as const, error: null, syncReport }; return { success: true as const, error: null, syncReport };
} catch (error) { } catch (error) {
logger.error("retry-window-sync failed:", error); logger.error("retry-window-sync failed:", error);
@ -163,6 +196,24 @@ export async function action(args: Route.ActionArgs) {
} }
} }
if (intent === "set-primary") {
const eventId = formData.get("eventId");
if (typeof eventId !== "string" || !eventId) {
return { success: false as const, error: "Event ID is required", syncReport: null };
}
try {
await setPrimaryEvent(eventId);
return { success: true as const, error: null, syncReport: null };
} catch (error) {
logger.error("set-primary failed:", error);
return {
success: false as const,
error: error instanceof Error ? error.message : "Failed to set primary window",
syncReport: null,
};
}
}
return { return {
success: false as const, success: false as const,
error: "Invalid intent", error: "Invalid intent",
@ -174,8 +225,25 @@ export default function AdminTournamentDetail({
loaderData, loaderData,
actionData, actionData,
}: Route.ComponentProps) { }: Route.ComponentProps) {
const { tournament, results, canonicalParticipants, linkedSportsSeasons } = loaderData; const {
tournament,
results,
canonicalParticipants,
linkedSportsSeasons,
primaryEvent,
bracketMajor,
isCs2,
} = loaderData;
const retryFetcher = useFetcher<typeof action>(); const retryFetcher = useFetcher<typeof action>();
const primaryFetcher = useFetcher<typeof action>();
// For bracket majors, scoring lives on the primary window's bracket/stage UI.
const primaryScoringHref =
bracketMajor && primaryEvent
? isCs2
? `/admin/sports-seasons/${primaryEvent.sportsSeasonId}/events/${primaryEvent.id}/cs2-setup`
: `/admin/sports-seasons/${primaryEvent.sportsSeasonId}/events/${primaryEvent.id}/bracket`
: null;
// Prefer the latest action/retry response for the sync report // Prefer the latest action/retry response for the sync report
const liveReport: SyncReport | null = const liveReport: SyncReport | null =
@ -195,6 +263,7 @@ export default function AdminTournamentDetail({
Back to tournaments Back to tournaments
</Link> </Link>
</Button> </Button>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">{tournament.name}</h1> <h1 className="text-3xl font-bold">{tournament.name}</h1>
<Badge <Badge
@ -209,6 +278,14 @@ export default function AdminTournamentDetail({
{tournament.status} {tournament.status}
</Badge> </Badge>
</div> </div>
{primaryScoringHref && (
<Button asChild>
<Link to={primaryScoringHref}>
{isCs2 ? "Manage stages & bracket →" : "Manage bracket →"}
</Link>
</Button>
)}
</div>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
{tournament.year} {tournament.year}
{tournament.location ? `${tournament.location}` : ""} {tournament.location ? `${tournament.location}` : ""}
@ -313,6 +390,24 @@ export default function AdminTournamentDetail({
{link.sportsSeason.scoringType.replace("_", " ")} {link.sportsSeason.scoringType.replace("_", " ")}
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
{bracketMajor &&
(link.isPrimary ? (
<Badge variant="default">Primary</Badge>
) : (
<primaryFetcher.Form method="post">
<input type="hidden" name="intent" value="set-primary" />
<input type="hidden" name="eventId" value={link.id} />
<Button
type="submit"
variant="outline"
size="sm"
disabled={primaryFetcher.state !== "idle"}
>
Make primary
</Button>
</primaryFetcher.Form>
))}
<Badge <Badge
variant={ variant={
link.sportsSeason.status === "completed" link.sportsSeason.status === "completed"
@ -325,6 +420,7 @@ export default function AdminTournamentDetail({
{link.sportsSeason.status} {link.sportsSeason.status}
</Badge> </Badge>
</div> </div>
</div>
))} ))}
</div> </div>
) : ( ) : (
@ -382,6 +478,31 @@ export default function AdminTournamentDetail({
</CardContent> </CardContent>
</Card> </Card>
{bracketMajor ? (
<Card>
<CardHeader>
<CardTitle>Scoring</CardTitle>
<CardDescription>
This is a bracket major. Results are derived from the
bracket/stage workflow on the primary window and fan out here
automatically.
</CardDescription>
</CardHeader>
<CardContent>
{primaryScoringHref ? (
<Button asChild>
<Link to={primaryScoringHref}>
{isCs2 ? "Manage stages & bracket →" : "Manage bracket →"}
</Link>
</Button>
) : (
<p className="text-sm text-muted-foreground">
No primary window is set up yet for this tournament.
</p>
)}
</CardContent>
</Card>
) : (
<BatchResultEntry <BatchResultEntry
participants={canonicalParticipants.map((p) => ({ participants={canonicalParticipants.map((p) => ({
id: p.id, id: p.id,
@ -393,6 +514,7 @@ export default function AdminTournamentDetail({
} }
intent="batch-upsert-results" intent="batch-upsert-results"
/> />
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -8,7 +8,8 @@ import {
getUserDisplayName, getUserDisplayName,
} from "~/models"; } from "~/models";
import { getDraftPicks } from "~/models/draft-pick"; import { getDraftPicks } from "~/models/draft-pick";
import { getScoringEventById } from "~/models/scoring-event"; import { getScoringEventById, getPrimaryEventForTournament, isReadOnlySibling } from "~/models/scoring-event";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getCs2StageResultsForEvent } from "~/models/cs2-major-stage"; import { getCs2StageResultsForEvent } from "~/models/cs2-major-stage";
import { findSeasonMatchesByScoringEventId } from "~/models/season-match"; import { findSeasonMatchesByScoringEventId } from "~/models/season-match";
import { getEventResults } from "~/models/event-result"; import { getEventResults } from "~/models/event-result";
@ -99,6 +100,55 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const simulatorType = sportsSeason.sport?.simulatorType ?? null; const simulatorType = sportsSeason.sport?.simulatorType ?? null;
// Shared-major fan-out for DISPLAY: a tournament-linked, non-primary event has
// no bracket/stage rows of its own — those live on the primary window. Read the
// structure from the primary, then re-key this league's ownership overlay from
// its own season_participant ids to the PRIMARY's season_participant ids (the
// ids the bracket structure uses) via the shared canonical participant. QP/
// results branches keep using this window's local rows and are untouched.
let structureEventId = eventId;
let structureBracketTemplateId = scoringEvent.bracketTemplateId;
let bracketTeamOwnerships = teamOwnerships;
let bracketUserParticipantIds = userParticipantIds;
if (isReadOnlySibling(scoringEvent) && scoringEvent.tournamentId) {
const primaryEvent = await getPrimaryEventForTournament(scoringEvent.tournamentId);
if (primaryEvent && primaryEvent.id !== eventId) {
structureEventId = primaryEvent.id;
structureBracketTemplateId = primaryEvent.bracketTemplateId;
const [siblingParticipants, primaryParticipants] = await Promise.all([
findParticipantsBySportsSeasonId(sportsSeasonId),
findParticipantsBySportsSeasonId(primaryEvent.sportsSeasonId),
]);
// sibling season_participant id → canonical participant id
const siblingSpToCanonical = new Map(
siblingParticipants
.filter((p) => p.participantId)
.map((p) => [p.id, p.participantId as string])
);
// canonical participant id → primary season_participant id
const canonicalToPrimarySp = new Map(
primaryParticipants
.filter((p) => p.participantId)
.map((p) => [p.participantId as string, p.id])
);
const toPrimarySp = (siblingSpId: string): string | undefined => {
const canonical = siblingSpToCanonical.get(siblingSpId);
return canonical ? canonicalToPrimarySp.get(canonical) : undefined;
};
bracketTeamOwnerships = teamOwnerships.flatMap((o) => {
const primarySpId = toPrimarySp(o.participantId);
return primarySpId ? [{ ...o, participantId: primarySpId }] : [];
});
bracketUserParticipantIds = userParticipantIds.flatMap((id) => {
const primarySpId = toPrimarySp(id);
return primarySpId ? [primarySpId] : [];
});
}
}
type PlayoffMatchWithRelations = Omit<PlayoffMatch, "createdAt" | "updatedAt"> & { type PlayoffMatchWithRelations = Omit<PlayoffMatch, "createdAt" | "updatedAt"> & {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@ -118,10 +168,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
// CS2 Major — load Swiss stage data + bracket // CS2 Major — load Swiss stage data + bracket
if (simulatorType === "cs2_major_qualifying_points") { if (simulatorType === "cs2_major_qualifying_points") {
const [cs2StageResults, seasonMatches, playoffMatchRows] = await Promise.all([ const [cs2StageResults, seasonMatches, playoffMatchRows] = await Promise.all([
getCs2StageResultsForEvent(eventId), getCs2StageResultsForEvent(structureEventId),
findSeasonMatchesByScoringEventId(eventId), findSeasonMatchesByScoringEventId(structureEventId),
db.query.playoffMatches.findMany({ db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId), where: eq(schema.playoffMatches.scoringEventId, structureEventId),
with: { participant1: true, participant2: true, winner: true, loser: true }, with: { participant1: true, participant2: true, winner: true, loser: true },
}), }),
]); ]);
@ -131,7 +181,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
createdAt: m.createdAt.toISOString(), createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(), updatedAt: m.updatedAt.toISOString(),
})); }));
const templateId = scoringEvent.bracketTemplateId ?? undefined; const templateId = structureBracketTemplateId ?? undefined;
const template = templateId ? getBracketTemplate(templateId) : undefined; const template = templateId ? getBracketTemplate(templateId) : undefined;
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template); const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
@ -140,8 +190,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
league, league,
sportsSeason, sportsSeason,
scoringEvent, scoringEvent,
teamOwnerships, teamOwnerships: bracketTeamOwnerships,
userParticipantIds, userParticipantIds: bracketUserParticipantIds,
cs2StageResults, cs2StageResults,
seasonMatches: seasonMatches.map((m) => ({ seasonMatches: seasonMatches.map((m) => ({
...m, ...m,
@ -158,7 +208,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
// Bracket events (tennis/golf bracket majors, soccer/UCL group+bracket) // Bracket events (tennis/golf bracket majors, soccer/UCL group+bracket)
if (scoringEvent.eventType === "playoff_game") { if (scoringEvent.eventType === "playoff_game") {
const playoffMatchRows = await db.query.playoffMatches.findMany({ const playoffMatchRows = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId), where: eq(schema.playoffMatches.scoringEventId, structureEventId),
with: { participant1: true, participant2: true, winner: true, loser: true }, with: { participant1: true, participant2: true, winner: true, loser: true },
}); });
const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({ const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({
@ -166,13 +216,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
createdAt: m.createdAt.toISOString(), createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(), updatedAt: m.updatedAt.toISOString(),
})); }));
const templateId = scoringEvent.bracketTemplateId ?? undefined; const templateId = structureBracketTemplateId ?? undefined;
const template = templateId ? getBracketTemplate(templateId) : undefined; const template = templateId ? getBracketTemplate(templateId) : undefined;
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template); const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
let groupStandings: GroupStandingData[] = []; let groupStandings: GroupStandingData[] = [];
if (template?.groupStage) { if (template?.groupStage) {
const groups = await findGroupsByEventId(eventId); const groups = await findGroupsByEventId(structureEventId);
const matchesByGroupId = await findMatchesByGroupIds(groups.map((g) => g.id)); const matchesByGroupId = await findMatchesByGroupIds(groups.map((g) => g.id));
groupStandings = groups.map((group) => { groupStandings = groups.map((group) => {
const groupMatches = matchesByGroupId.get(group.id) ?? []; const groupMatches = matchesByGroupId.get(group.id) ?? [];
@ -216,8 +266,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
league, league,
sportsSeason, sportsSeason,
scoringEvent, scoringEvent,
teamOwnerships, teamOwnerships: bracketTeamOwnerships,
userParticipantIds, userParticipantIds: bracketUserParticipantIds,
playoffMatches, playoffMatches,
playoffRounds, playoffRounds,
bracketTemplateId: templateId ?? null, bracketTemplateId: templateId ?? null,

View file

@ -7,11 +7,38 @@ vi.mock("~/database/context", () => ({
vi.mock("~/models/scoring-calculator", () => ({ vi.mock("~/models/scoring-calculator", () => ({
processQualifyingEvent: vi.fn(), processQualifyingEvent: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
})); }));
import { syncTournamentResults } from "../sync-tournament-results"; vi.mock("~/models/scoring-event", () => ({
completeScoringEvent: vi.fn(),
getScoringEventById: vi.fn(),
}));
vi.mock("~/models/event-result", () => ({
getEventResults: vi.fn(),
}));
vi.mock("~/models/tournament-result", () => ({
upsertTournamentResult: vi.fn(),
}));
vi.mock("~/lib/logger", () => ({
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import {
syncTournamentResults,
syncMajorFromPrimaryEvent,
} from "../sync-tournament-results";
import { database } from "~/database/context"; import { database } from "~/database/context";
import { processQualifyingEvent } from "~/models/scoring-calculator"; import {
processQualifyingEvent,
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
import { getEventResults } from "~/models/event-result";
import { upsertTournamentResult } from "~/models/tournament-result";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mock data types // Mock data types
@ -28,6 +55,7 @@ interface FakeScoringEvent {
id: string; id: string;
sportsSeasonId: string; sportsSeasonId: string;
tournamentId: string | null; tournamentId: string | null;
name?: string | null;
} }
interface FakeSeasonParticipant { interface FakeSeasonParticipant {
@ -574,4 +602,287 @@ describe("syncTournamentResults", () => {
expect(c?.placement).toBeNull(); expect(c?.placement).toBeNull();
expect(c?.qualifyingPointsAwarded).toBe("0"); expect(c?.qualifyingPointsAwarded).toBe("0");
}); });
// -------------------------------------------------------------------------
// Test 7: Completion fan-out (the core "score once" fix)
// -------------------------------------------------------------------------
it("marks each synced window complete by default", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
],
});
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);
await syncTournamentResults("t-1");
expect(completeScoringEvent).toHaveBeenCalledTimes(2);
expect(completeScoringEvent).toHaveBeenCalledWith("ev-A", db);
expect(completeScoringEvent).toHaveBeenCalledWith("ev-B", db);
});
it("does NOT mark windows complete when markComplete is false", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" }],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
await syncTournamentResults("t-1", { markComplete: false });
expect(completeScoringEvent).not.toHaveBeenCalled();
});
// -------------------------------------------------------------------------
// Test 8: League recalculation per synced window
// -------------------------------------------------------------------------
it("recalculates affected leagues once per cleanly-synced window", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1", name: "Major A" },
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1", name: "Major B" },
],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
],
});
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);
await syncTournamentResults("t-1");
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(2);
expect(recalculateAffectedLeagues).toHaveBeenCalledWith("ss-A", undefined, {
eventId: "ev-A",
eventName: "Major A",
skipDiscord: false,
});
expect(recalculateAffectedLeagues).toHaveBeenCalledWith("ss-B", undefined, {
eventId: "ev-B",
eventName: "Major B",
skipDiscord: false,
});
});
// -------------------------------------------------------------------------
// Test 9: skipEventId excludes the primary window
// -------------------------------------------------------------------------
it("skips the primary window when skipEventId is given", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-PRIMARY", sportsSeasonId: "ss-P", tournamentId: "t-1" },
{ id: "ev-SIBLING", sportsSeasonId: "ss-S", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" },
{ id: "sp-SA", sportsSeasonId: "ss-S", participantId: "cp-A", name: "A" },
],
});
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);
const report = await syncTournamentResults("t-1", {
skipEventId: "ev-PRIMARY",
});
expect(report.windowsSynced).toBe(1);
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db);
// No event_results written for the skipped primary window.
expect(
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
).toBe(false);
});
});
// ---------------------------------------------------------------------------
// syncMajorFromPrimaryEvent: bracket/CS2 majors promote primary results to
// canonical tournament_results, then fan out to siblings (primary skipped).
// ---------------------------------------------------------------------------
describe("syncMajorFromPrimaryEvent", () => {
it("promotes primary event_results to canonical and fans out to siblings", async () => {
const state = seedBasicState({
scoringEvents: [
{ id: "ev-PRIMARY", sportsSeasonId: "ss-P", tournamentId: "t-1", name: "Major" },
{ id: "ev-SIBLING", sportsSeasonId: "ss-S", tournamentId: "t-1", name: "Major" },
],
seasonParticipants: [
{ id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" },
{ id: "sp-PB", sportsSeasonId: "ss-P", participantId: "cp-B", name: "B" },
{ id: "sp-SA", sportsSeasonId: "ss-S", participantId: "cp-A", name: "A" },
{ id: "sp-SB", sportsSeasonId: "ss-S", participantId: "cp-B", name: "B" },
],
});
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: "Major",
} as never);
// Primary window's derived results: cp-A 1st, cp-B 2nd.
vi.mocked(getEventResults).mockResolvedValue([
{
placement: 1,
rawScore: "1",
notParticipating: false,
seasonParticipantId: "sp-PA",
seasonParticipant: { participantId: "cp-A" },
},
{
placement: 2,
rawScore: "2",
notParticipating: false,
seasonParticipantId: "sp-PB",
seasonParticipant: { participantId: "cp-B" },
},
] as never);
// upsertTournamentResult writes into our fake canonical table so the
// subsequent syncTournamentResults can read them back.
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;
}
);
const report = await syncMajorFromPrimaryEvent("ev-PRIMARY");
// Two canonical results promoted from the primary.
expect(upsertTournamentResult).toHaveBeenCalledTimes(2);
expect(state.tournamentResults).toHaveLength(2);
// Only the sibling window is synced (primary is skipped).
expect(report.windowsSynced).toBe(1);
expect(
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
).toBe(false);
const sib = state.eventResults.filter((r) => r.scoringEventId === "ev-SIBLING");
expect(sib.find((r) => r.seasonParticipantId === "sp-SA")?.placement).toBe(1);
expect(sib.find((r) => r.seasonParticipantId === "sp-SB")?.placement).toBe(2);
// In-progress fan-out: siblings are NOT marked complete by default.
expect(completeScoringEvent).not.toHaveBeenCalled();
});
it("throws when the primary event is not linked to a tournament", async () => {
const db = makeFakeDb(seedBasicState());
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(getScoringEventById).mockResolvedValue({
id: "ev-X",
sportsSeasonId: "ss-X",
tournamentId: null,
name: "Standalone",
} as never);
await expect(syncMajorFromPrimaryEvent("ev-X")).rejects.toThrow(
/not linked to a tournament/
);
});
});
// ---------------------------------------------------------------------------
// Regression: review fixes #1 (recalc failure counts) and #2 (stale removal)
// ---------------------------------------------------------------------------
describe("syncTournamentResults — recalc failure & stale removal", () => {
it("counts a league-recalc failure as a failed window (so status can't show completed)", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" }],
seasonParticipants: [
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
],
});
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).mockRejectedValue(new Error("recalc boom"));
const report = await syncTournamentResults("t-1");
expect(report.windowsFailed).toBe(1);
expect(report.failures.some((f) => f.error.includes("League recalc failed"))).toBe(true);
});
it("resets a previously-placed participant to filler when dropped from canonical", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: "10" },
],
scoringEvents: [{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" }],
seasonParticipants: [
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
],
});
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);
await syncTournamentResults("t-1");
expect(state.eventResults.find((r) => r.seasonParticipantId === "sp-A")?.placement).toBe(1);
// Correction: cp-A is removed from the canonical results entirely.
state.tournamentResults.length = 0;
await syncTournamentResults("t-1");
const row = state.eventResults.find((r) => r.seasonParticipantId === "sp-A");
expect(row?.placement).toBeNull();
expect(row?.rawScore).toBeNull();
expect(row?.qualifyingPointsAwarded).toBe("0");
});
}); });

View file

@ -9,6 +9,7 @@ import {
simulateOneMajor, simulateOneMajor,
reconcileAdvancers, reconcileAdvancers,
} from "../cs-major-simulator"; } from "../cs-major-simulator";
import { buildSpTranslator } from "../shared-major";
import type { AdvancedTeam, BracketMatchInput, SwissMatchInput, StageResultsMap } from "../cs-major-simulator"; import type { AdvancedTeam, BracketMatchInput, SwissMatchInput, StageResultsMap } from "../cs-major-simulator";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@ -840,3 +841,47 @@ describe("simulateOneMajor full conditioning", () => {
} }
}); });
}); });
// ─── buildSpTranslator (shared-major id bridging) ───────────────────────────────
describe("buildSpTranslator", () => {
const primary = [
{ id: "P-1", participantId: "C-A" },
{ id: "P-2", participantId: "C-B" },
{ id: "P-3", participantId: "C-C" },
];
const local = [
{ id: "L-1", participantId: "C-A" },
{ id: "L-2", participantId: "C-B" },
{ id: "L-3", participantId: "C-C" },
];
it("maps primary season_participant ids to the local window's via canonical", () => {
const tr = buildSpTranslator(primary, local);
expect(tr("P-1")).toBe("L-1");
expect(tr("P-2")).toBe("L-2");
expect(tr("P-3")).toBe("L-3");
});
it("passes null/undefined through unchanged", () => {
const tr = buildSpTranslator(primary, local);
expect(tr(null)).toBeNull();
});
it("passes ids through when no canonical link exists on the primary side", () => {
const tr = buildSpTranslator(
[{ id: "P-X", participantId: null }],
local
);
expect(tr("P-X")).toBe("P-X");
});
it("passes ids through when the local window has no counterpart", () => {
const tr = buildSpTranslator(primary, [
{ id: "L-1", participantId: "C-A" },
// C-B and C-C absent from the local field
]);
expect(tr("P-1")).toBe("L-1"); // bridged
expect(tr("P-2")).toBe("P-2"); // no local counterpart → unchanged
});
});

View file

@ -1,5 +1,15 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { eloWinProb, buildDraw, simulateMajor } from "../tennis-simulator"; import {
eloWinProb,
buildDraw,
simulateMajor,
drawFromBracket,
buildHonoredMap,
DEFAULT_SLAM_QP,
type RealBracketMatch,
} from "../tennis-simulator";
import { deriveBracketQualifyingStates, getRoundConfig } from "~/models/scoring-calculator";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
// ─── eloWinProb ─────────────────────────────────────────────────────────────── // ─── eloWinProb ───────────────────────────────────────────────────────────────
@ -337,3 +347,187 @@ describe("QP accumulation ranking (unit)", () => {
expect(winRate).toBeGreaterThan(0.02); // > 2% (well above random 0.78%) expect(winRate).toBeGreaterThan(0.02); // > 2% (well above random 0.78%)
}); });
}); });
// ─── drawFromBracket ────────────────────────────────────────────────────────────
describe("drawFromBracket", () => {
const ids = makeIds(128);
// 64 Round-of-128 matches: match N holds ids[2N-2], ids[2N-1].
const fullR128 = Array.from({ length: 64 }, (_, i) => ({
round: "Round of 128",
matchNumber: i + 1,
participant1Id: ids[i * 2],
participant2Id: ids[i * 2 + 1],
}));
it("reconstructs a 128-slot draw in match order", () => {
const draw = drawFromBracket(fullR128, (id) => id);
expect(draw).not.toBeNull();
expect(draw).toHaveLength(128);
expect(draw?.[0]).toBe("p001");
expect(draw?.[1]).toBe("p002");
expect(draw?.[127]).toBe("p128");
});
it("returns null when the draw is not fully populated", () => {
const partial = fullR128.slice(0, 60); // only 60 of 64 matches
expect(drawFromBracket(partial, (id) => id)).toBeNull();
const withNull = fullR128.map((m, i) =>
i === 0 ? { ...m, participant1Id: null } : m
);
expect(drawFromBracket(withNull, (id) => id)).toBeNull();
});
it("applies the id translator", () => {
const draw = drawFromBracket(fullR128, (id) => (id ? `x-${id}` : id));
expect(draw?.[0]).toBe("x-p001");
});
});
// ─── simulateMajor honoring a real bracket ──────────────────────────────────────
describe("simulateMajor with realBracket", () => {
const ids = makeIds(128);
const eloMap = makeEloMap(ids, ids.map(() => 1500));
// A fully-decided bracket where the lower draw index always wins. Champion is
// therefore ids[0]; the result is deterministic regardless of RNG.
function decideBracket(draw: string[]): RealBracketMatch[] {
const rounds = [
"Round of 128", "Round of 64", "Round of 32", "Round of 16",
"Quarterfinals", "Semifinals", "Final",
];
const order = new Map(draw.map((id, i) => [id, i]));
const matches: RealBracketMatch[] = [];
let current = [...draw];
for (const round of rounds) {
const next: string[] = [];
for (let i = 0; i < current.length; i += 2) {
const p1 = current[i];
const p2 = current[i + 1];
const winner = (order.get(p1) ?? 0) < (order.get(p2) ?? 0) ? p1 : p2;
matches.push({ round, matchNumber: i / 2 + 1, winnerId: winner, isComplete: true });
next.push(winner);
}
current = next;
}
return matches;
}
it("honors a fully-decided bracket deterministically (champion = draw[0])", () => {
const draw = [...ids];
const realBracket = decideBracket(draw);
const results = simulateMajor(draw, eloMap, "hard", { realBracket });
const champ = results.find((r) => r.qp === DEFAULT_SLAM_QP.winner);
expect(champ?.participantId).toBe(draw[0]);
// Structural counts are unchanged by honoring.
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.winner)).toHaveLength(1);
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.finalist)).toHaveLength(1);
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.sf)).toHaveLength(2);
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.qf)).toHaveLength(4);
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.r16)).toHaveLength(8);
});
it("honors a completed Final winner even against a heavy Elo underdog", () => {
// Underdog at draw[0] (low Elo) but the Final is recorded as their win.
const draw = [...ids];
const skewed = makeEloMap(draw, draw.map((_, i) => (i === 0 ? 100 : 3000)));
const realBracket = decideBracket(draw); // draw[0] wins everything
const results = simulateMajor(draw, skewed, "hard", { realBracket });
const champ = results.find((r) => r.qp === DEFAULT_SLAM_QP.winner);
expect(champ?.participantId).toBe(draw[0]);
});
it("uses config QP values from opts.qp", () => {
const draw = [...ids];
const realBracket = decideBracket(draw);
const qp = { winner: 100, finalist: 70, sf: 40, qf: 20, r16: 5 };
const results = simulateMajor(draw, eloMap, "hard", { realBracket, qp });
expect(results.find((r) => r.participantId === draw[0])?.qp).toBe(100);
expect(results.filter((r) => r.qp === 70)).toHaveLength(1);
expect(results.filter((r) => r.qp === 5)).toHaveLength(8);
});
});
// ─── tennis_128 bracket scoring (QP placement derivation) ───────────────────────
describe("tennis_128 qualifying bracket scoring", () => {
it("derives placements 1/2/3/5/9 from the tennis_128 round config", () => {
const template = BRACKET_TEMPLATES.tennis_128;
// A wins R16 → QF → SF → Final (champion). Losers exit at each scoring round.
const matches = [
{ round: "Round of 16", winnerId: "A", loserId: "I", participant1Id: "A", participant2Id: "I" },
{ round: "Quarterfinals", winnerId: "A", loserId: "E", participant1Id: "A", participant2Id: "E" },
{ round: "Semifinals", winnerId: "A", loserId: "C", participant1Id: "A", participant2Id: "C" },
{ round: "Final", winnerId: "A", loserId: "B", participant1Id: "A", participant2Id: "B" },
];
const states = deriveBracketQualifyingStates(
matches,
template.rounds,
(round) => getRoundConfig(round, "tennis_128")
);
expect(states.get("A")?.placement).toBe(1); // champion
expect(states.get("B")?.placement).toBe(2); // Final loser
expect(states.get("C")).toMatchObject({ placement: 3, tieCount: 2 }); // SF loser
expect(states.get("E")).toMatchObject({ placement: 5, tieCount: 4 }); // QF loser
expect(states.get("I")).toMatchObject({ placement: 9, tieCount: 8 }); // R16 loser
});
});
// ─── exclusions (not-participating) + prebuilt honored map ──────────────────────
describe("simulateMajor exclusions & prebuilt honored", () => {
const ids = makeIds(128);
it("a withdrawn player walks over (earns 0 QP) even with a dominant Elo", () => {
// draw[0] would otherwise win almost everything; excluding them must zero them.
const dominant = makeEloMap(ids, ids.map((_, i) => (i === 0 ? 5000 : 1000)));
const excluded = new Set([ids[0]]);
for (let t = 0; t < 20; t++) {
const results = simulateMajor(ids, dominant, "hard", { excluded });
expect(results.find((r) => r.participantId === ids[0])?.qp).toBe(0);
}
});
it("a completed match is still honored even if the winner is later excluded", () => {
// Fully-decided bracket says draw[0] wins; excluding them must NOT override a
// recorded real result (the match already happened).
function decide(draw: string[]): RealBracketMatch[] {
const rounds = [
"Round of 128", "Round of 64", "Round of 32", "Round of 16",
"Quarterfinals", "Semifinals", "Final",
];
const order = new Map(draw.map((id, i) => [id, i]));
const matches: RealBracketMatch[] = [];
let cur = [...draw];
for (const round of rounds) {
const next: string[] = [];
for (let i = 0; i < cur.length; i += 2) {
const w = (order.get(cur[i]) ?? 0) < (order.get(cur[i + 1]) ?? 0) ? cur[i] : cur[i + 1];
matches.push({ round, matchNumber: i / 2 + 1, winnerId: w, isComplete: true });
next.push(w);
}
cur = next;
}
return matches;
}
const honored = buildHonoredMap(decide(ids));
const eloMap = makeEloMap(ids, ids.map(() => 1500));
const results = simulateMajor(ids, eloMap, "hard", {
honored,
excluded: new Set([ids[0]]),
});
expect(results.find((r) => r.qp === DEFAULT_SLAM_QP.winner)?.participantId).toBe(ids[0]);
});
it("prebuilt honored map produces the same champion as realBracket input", () => {
const realBracket: RealBracketMatch[] = [
{ round: "Final", matchNumber: 1, winnerId: null, isComplete: false },
];
const honored = buildHonoredMap(realBracket);
// Empty honored (no completed matches) → no crash, full simulation runs.
const eloMap = makeEloMap(ids, ids.map(() => 1500));
const results = simulateMajor(ids, eloMap, "hard", { honored });
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.winner)).toHaveLength(1);
});
});

View file

@ -46,11 +46,18 @@ import { database } from "~/database/context";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { getQPConfig } from "~/models/qualifying-points"; import { getQPConfig } from "~/models/qualifying-points";
import { getCs2StageResultsMapForEvent, computeStage3ExitQP as calcStage3ExitQP } from "~/models/cs2-major-stage"; import { getCs2StageResultsMapForEvent, computeStage3ExitQP as calcStage3ExitQP, type Cs2StageResult } from "~/models/cs2-major-stage";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
export { calcStage3ExitQP }; export { calcStage3ExitQP };
import { getExcludedByEventMap } from "~/models/event-result"; import { getExcludedByEventMap } from "~/models/event-result";
import { findPlayoffMatchesByEventId } from "~/models/playoff-match"; import { findPlayoffMatchesByEventId } from "~/models/playoff-match";
import { findSeasonMatchesByScoringEventId } from "~/models/season-match"; import { findSeasonMatchesByScoringEventId } from "~/models/season-match";
import {
resolveStructureSource,
IDENTITY_TR,
type IdTranslator,
type StructureSource,
} from "./shared-major";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
@ -611,6 +618,30 @@ export class CSMajorSimulator implements Simulator {
); );
} }
// 4b. Shared-major structure resolution. A tournament-linked, non-primary
// event holds no bracket/stage/Swiss rows of its own — they live on the
// primary window. For each such event, read the structure from the primary
// and translate the primary window's season_participant ids into THIS
// window's ids via the shared canonical participant, so the simulation
// conditions on real in-progress results instead of re-simulating from
// scratch. (Provisional QP already fans out to event_results, so it is read
// locally below and needs no translation.)
const localParticipants = await findParticipantsBySportsSeasonId(sportsSeasonId);
// Composed translator (primary→local) cached per primary sports season —
// a window's majors may have primaries in several different windows.
const translatorByPrimarySeason = new Map<string, IdTranslator>();
const structureByEventId = new Map<string, StructureSource>(
await Promise.all(
events.map(
async (e) =>
[
e.id,
await resolveStructureSource(e, localParticipants, translatorByPrimarySeason),
] as const
)
)
);
// 5. For completed events, read actual QP from eventResults. // 5. For completed events, read actual QP from eventResults.
const completedEventIds = events.filter((e) => e.isComplete).map((e) => e.id); const completedEventIds = events.filter((e) => e.isComplete).map((e) => e.id);
const actualQPMap = new Map<string, number>(participantIds.map((id) => [id, 0])); const actualQPMap = new Map<string, number>(participantIds.map((id) => [id, 0]));
@ -632,11 +663,21 @@ export class CSMajorSimulator implements Simulator {
} }
} }
// 6. Load stage results for all events in parallel. // 6. Load stage results for all events in parallel (from the structure
// source — the primary window for siblings — translating participant ids).
const stageResultEntries = await Promise.all( const stageResultEntries = await Promise.all(
events.map(async (event) => { events.map(async (event) => {
const results = await getCs2StageResultsMapForEvent(event.id); const { sourceId, tr } = structureByEventId.get(event.id) ?? {
return [event.id, results] as const; sourceId: event.id,
tr: IDENTITY_TR,
};
const results = await getCs2StageResultsMapForEvent(sourceId);
const translated = new Map<string, Cs2StageResult>();
for (const [pid, r] of results) {
const t = tr(pid) ?? pid;
translated.set(t, { ...r, participantId: t });
}
return [event.id, translated] as const;
}) })
); );
const eventStageResults = new Map( const eventStageResults = new Map(
@ -652,35 +693,49 @@ export class CSMajorSimulator implements Simulator {
// 6b. For incomplete events, load already-known results so the simulation // 6b. For incomplete events, load already-known results so the simulation
// conditions on them instead of re-simulating: the Champions Stage // conditions on them instead of re-simulating: the Champions Stage
// bracket, the Swiss match results, and provisional recorded QP. // bracket, the Swiss match results, and provisional recorded QP.
// Each entry carries its own translation fn (identity for primaries, primary
// →this-window for siblings) applied to every participant id read below.
const [bracketEntries, swissEntries] = await Promise.all([ const [bracketEntries, swissEntries] = await Promise.all([
Promise.all( Promise.all(
incompleteEvents.map(async (e) => [e.id, await findPlayoffMatchesByEventId(e.id)] as const) incompleteEvents.map(async (e) => {
const { sourceId, tr } = structureByEventId.get(e.id) ?? {
sourceId: e.id,
tr: IDENTITY_TR,
};
return [e.id, tr, await findPlayoffMatchesByEventId(sourceId)] as const;
})
), ),
Promise.all( Promise.all(
incompleteEvents.map(async (e) => [e.id, await findSeasonMatchesByScoringEventId(e.id)] as const) incompleteEvents.map(async (e) => {
const { sourceId, tr } = structureByEventId.get(e.id) ?? {
sourceId: e.id,
tr: IDENTITY_TR,
};
return [e.id, tr, await findSeasonMatchesByScoringEventId(sourceId)] as const;
})
), ),
]); ]);
const eventBracket = new Map<string, BracketMatchInput[]>( const eventBracket = new Map<string, BracketMatchInput[]>(
bracketEntries.map(([id, matches]) => [ bracketEntries.map(([id, tr, matches]) => [
id, id,
matches.map((m) => ({ matches.map((m) => ({
round: m.round, round: m.round,
matchNumber: m.matchNumber, matchNumber: m.matchNumber,
participant1Id: m.participant1Id, participant1Id: tr(m.participant1Id),
participant2Id: m.participant2Id, participant2Id: tr(m.participant2Id),
winnerId: m.winnerId, winnerId: tr(m.winnerId),
isComplete: m.isComplete, isComplete: m.isComplete,
})), })),
]) ])
); );
const eventSwiss = new Map<string, SwissMatchInput[]>( const eventSwiss = new Map<string, SwissMatchInput[]>(
swissEntries.map(([id, matches]) => [ swissEntries.map(([id, tr, matches]) => [
id, id,
matches.map((m) => ({ matches.map((m) => ({
matchStage: m.matchStage, matchStage: m.matchStage,
participant1Id: m.participant1Id, participant1Id: tr(m.participant1Id),
participant2Id: m.participant2Id, participant2Id: tr(m.participant2Id),
winnerId: m.winnerId, winnerId: tr(m.winnerId),
})), })),
]) ])
); );

View file

@ -0,0 +1,88 @@
/**
* Shared helpers for "shared major" simulators (CS2, tennis).
*
* A major (a real tournament) can be linked to several sports_season windows.
* The bracket/stage structure is built and scored once on the PRIMARY window;
* sibling windows hold no structure of their own. When simulating a sibling, the
* simulator must read the structure from the primary and translate the primary
* window's season_participant ids into the local window's ids via the shared
* canonical participant. These helpers centralise that resolution + translation.
*/
import { getPrimaryEventForTournament } from "~/models/scoring-event";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
/** Maps a participant id from one window to another (or passes it through). */
export type IdTranslator = (id: string | null) => string | null;
/** Identity translation — used when structure lives in the event's own window. */
export const IDENTITY_TR: IdTranslator = (id) => id;
/**
* Build a translator that maps a PRIMARY window's season_participant id to the
* equivalent id in a LOCAL (sibling) window, bridging via the shared canonical
* participant. Ids that can't be bridged (no canonical link, or no local
* counterpart) pass through unchanged so structure is never silently dropped.
*/
export function buildSpTranslator(
primaryParticipants: Array<{ id: string; participantId: string | null }>,
localParticipants: Array<{ id: string; participantId: string | null }>
): IdTranslator {
const primaryToCanonical = new Map(
primaryParticipants
.filter((p) => p.participantId)
.map((p) => [p.id, p.participantId as string])
);
const canonicalToLocal = new Map(
localParticipants
.filter((p) => p.participantId)
.map((p) => [p.participantId as string, p.id])
);
return (id: string | null): string | null => {
if (!id) return id;
const canonical = primaryToCanonical.get(id);
if (!canonical) return id;
return canonicalToLocal.get(canonical) ?? id;
};
}
export interface StructureSource {
/** Event id whose bracket/stage rows hold this event's structure. */
sourceId: string;
/** Translate the source window's participant ids into the local window's. */
tr: IdTranslator;
}
/**
* Resolve where an event's bracket/stage structure lives and how to map its
* participant ids into the local window. For a primary/standalone/non-linked
* event this is the event itself with identity translation. For a sibling
* (tournament-linked, non-primary) event, the structure is the primary event and
* ids are translated primarylocal.
*
* `translatorCache` is keyed by primary sports_season id so a window's majors
* (whose primaries may live in different windows) each build their translator
* once.
*/
export async function resolveStructureSource(
event: { id: string; tournamentId: string | null; isPrimary: boolean },
localParticipants: Array<{ id: string; participantId: string | null }>,
translatorCache: Map<string, IdTranslator>
): Promise<StructureSource> {
if (!event.tournamentId || event.isPrimary) {
return { sourceId: event.id, tr: IDENTITY_TR };
}
const primary = await getPrimaryEventForTournament(event.tournamentId);
if (!primary || primary.id === event.id) {
return { sourceId: event.id, tr: IDENTITY_TR };
}
let tr = translatorCache.get(primary.sportsSeasonId);
if (!tr) {
const primaryParticipants = await findParticipantsBySportsSeasonId(
primary.sportsSeasonId
);
tr = buildSpTranslator(primaryParticipants, localParticipants);
translatorCache.set(primary.sportsSeasonId, tr);
}
return { sourceId: primary.id, tr };
}

View file

@ -42,6 +42,11 @@ import { eq, and, inArray } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { getSurfaceEloMap } from "~/models/surface-elo"; import { getSurfaceEloMap } from "~/models/surface-elo";
import { getExcludedByEventMap } from "~/models/event-result"; import { getExcludedByEventMap } from "~/models/event-result";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { findPlayoffMatchesByEventId } from "~/models/playoff-match";
import { getQPConfig, calculateSplitQualifyingPoints } from "~/models/qualifying-points";
import { resolveStructureSource, type IdTranslator } from "./shared-major";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
@ -180,12 +185,89 @@ interface QPResult {
qp: number; qp: number;
} }
/** QP awarded at each scoring stage of a Grand Slam (tie-split pre-applied). */
export interface SlamQP {
winner: number;
finalist: number;
sf: number; // each SF loser
qf: number; // each QF loser
r16: number; // each R16 loser
}
/**
* Default QP the historical hardcoded tie-split averages. Used when a season's
* QP config isn't supplied (e.g. unit tests). The live simulator derives these
* from getQPConfig so simulated QP matches what the bracket actually awards.
*/
export const DEFAULT_SLAM_QP: SlamQP = {
winner: QP_WINNER,
finalist: QP_FINALIST,
sf: QP_SF_LOSER,
qf: QP_QF_LOSER,
r16: QP_R16_LOSER,
};
/** A real (DB) bracket match used to condition an in-progress simulation. */
export interface RealBracketMatch {
round: string;
matchNumber: number;
winnerId: string | null;
isComplete: boolean;
}
// Round structure is derived from the tennis_128 template so the simulator and
// the scorer share one source of truth — renaming a round or changing the draw
// size in the template can't silently desync the bracket-conditioned sim.
const TENNIS_TEMPLATE = BRACKET_TEMPLATES.tennis_128;
const SLAM_ROUND_NAMES = TENNIS_TEMPLATE.rounds.map((r) => r.name);
const FIRST_ROUND = TENNIS_TEMPLATE.rounds[0];
const FINAL_ROUND_NAME = SLAM_ROUND_NAMES[SLAM_ROUND_NAMES.length - 1];
// Scoring rounds before the final, ordered final-adjacent first ([SF, QF, R16]),
// so loser QP tiers (sf, qf, r16) map positionally rather than by hardcoded name.
const NON_FINAL_SCORING_ROUNDS = TENNIS_TEMPLATE.rounds
.filter((r) => r.isScoring && r.name !== FINAL_ROUND_NAME)
.map((r) => r.name)
.toReversed();
/** Build the round→matchNumber→winnerId lookup honored during simulation. */
export function buildHonoredMap(
realBracket: RealBracketMatch[]
): Map<string, Map<number, string>> {
const honored = new Map<string, Map<number, string>>();
for (const m of realBracket) {
if (m.isComplete && m.winnerId) {
let r = honored.get(m.round);
if (!r) { r = new Map(); honored.set(m.round, r); }
r.set(m.matchNumber, m.winnerId);
}
}
return honored;
}
/** /**
* Simulate one Grand Slam major and return QP earned per participant. * Simulate one Grand Slam major and return QP earned per participant.
* The draw is a pre-shuffled 128-slot array from buildDraw(). * The draw is a 128-slot array (from buildDraw, or from a real bracket's draw).
*
* When `opts.realBracket` is supplied, completed matches are HONORED (their
* winners advance instead of being re-simulated) and only undecided matches are
* played out the tennis analog of CS2's bracket-conditioned simulation. The
* draw passed in must already reflect the real bracket's Round-of-128 ordering.
*
* Exported for unit testing. * Exported for unit testing.
*/ */
export function simulateMajor(draw: string[], eloMap: Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null } | undefined>, surface: CourtSurface): QPResult[] { export function simulateMajor(
draw: string[],
eloMap: Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null } | undefined>,
surface: CourtSurface,
opts: {
realBracket?: RealBracketMatch[];
honored?: Map<string, Map<number, string>>;
qp?: SlamQP;
excluded?: Set<string>;
} = {}
): QPResult[] {
const qpValues = opts.qp ?? DEFAULT_SLAM_QP;
const excluded = opts.excluded;
const qp = new Map<string, number>(draw.map((id) => [id, 0])); const qp = new Map<string, number>(draw.map((id) => [id, 0]));
const getElo = (id: string): number => { const getElo = (id: string): number => {
@ -201,60 +283,83 @@ export function simulateMajor(draw: string[], eloMap: Map<string, { worldRanking
return { winner, loser: winner === p1 ? p2 : p1 }; return { winner, loser: winner === p1 ? p2 : p1 };
}; };
// 7 rounds: R1(64 matches) R2(32) R3(16) R16(8) QF(4) SF(2) Final(1) // round name → (matchNumber → completed winner id). Prefer the prebuilt map
let current = [...draw]; // 128 players // (hoisted out of the Monte Carlo loop by the caller); otherwise derive it.
const honored =
opts.honored ?? (opts.realBracket ? buildHonoredMap(opts.realBracket) : undefined);
// Rounds 13 award 0 QP; just advance winners. // Per-round loser QP, mapped positionally from the template's scoring rounds.
for (let round = 0; round < 3; round++) { const loserQpByRound: Record<string, number> = {};
const tiers = [qpValues.sf, qpValues.qf, qpValues.r16];
NON_FINAL_SCORING_ROUNDS.forEach((name, i) => {
if (i < tiers.length) loserQpByRound[name] = tiers[i];
});
let current = [...draw];
for (const roundName of SLAM_ROUND_NAMES) {
const honoredRound = honored?.get(roundName);
const next: string[] = []; const next: string[] = [];
for (let i = 0; i < current.length; i += 2) { for (let i = 0; i < current.length; i += 2) {
const { winner } = simMatch(current[i], current[i + 1]); const p1 = current[i];
const p2 = current[i + 1];
const matchNumber = i / 2 + 1;
// Honor a completed match only if its winner is actually one of the two
// players at this slot (guards against inconsistent partial entry).
const forced = honoredRound?.get(matchNumber);
let winner: string;
let loser: string;
if (forced && (forced === p1 || forced === p2)) {
winner = forced;
loser = forced === p1 ? p2 : p1;
} else if (excluded && excluded.has(p1) !== excluded.has(p2)) {
// Exactly one player withdrew (not-participating) and the match isn't
// decided yet → the present player advances by walkover.
winner = excluded.has(p1) ? p2 : p1;
loser = winner === p1 ? p2 : p1;
} else {
({ winner, loser } = simMatch(p1, p2));
}
if (roundName === FINAL_ROUND_NAME) {
qp.set(winner, (qp.get(winner) ?? 0) + qpValues.winner);
qp.set(loser, (qp.get(loser) ?? 0) + qpValues.finalist);
} else {
const lq = loserQpByRound[roundName];
if (lq) qp.set(loser, (qp.get(loser) ?? 0) + lq);
}
next.push(winner); next.push(winner);
} }
current = next; current = next;
} }
// R16: 8 matches, losers get 1.5 QP each.
{
const next: string[] = [];
for (let i = 0; i < current.length; i += 2) {
const { winner, loser } = simMatch(current[i], current[i + 1]);
qp.set(loser, (qp.get(loser) ?? 0) + QP_R16_LOSER);
next.push(winner);
}
current = next;
}
// QF: 4 matches, losers get 4 QP each.
{
const next: string[] = [];
for (let i = 0; i < current.length; i += 2) {
const { winner, loser } = simMatch(current[i], current[i + 1]);
qp.set(loser, (qp.get(loser) ?? 0) + QP_QF_LOSER);
next.push(winner);
}
current = next;
}
// SF: 2 matches, losers get 9 QP each.
{
const next: string[] = [];
for (let i = 0; i < current.length; i += 2) {
const { winner, loser } = simMatch(current[i], current[i + 1]);
qp.set(loser, (qp.get(loser) ?? 0) + QP_SF_LOSER);
next.push(winner);
}
current = next;
}
// Final: 1 match.
const { winner: champion, loser: finalist } = simMatch(current[0], current[1]);
qp.set(champion, (qp.get(champion) ?? 0) + QP_WINNER);
qp.set(finalist, (qp.get(finalist) ?? 0) + QP_FINALIST);
return Array.from(qp.entries()).map(([participantId, qpVal]) => ({ participantId, qp: qpVal })); return Array.from(qp.entries()).map(([participantId, qpVal]) => ({ participantId, qp: qpVal }));
} }
/**
* Reconstruct a 128-slot draw from a real bracket's Round-of-128 matches.
* Match N (1-indexed) occupies slots [2N-2, 2N-1]. Returns null if the draw is
* not fully populated (so the caller falls back to a synthetic seeded draw).
* `tr` maps the source window's participant ids into the local window's.
*/
export function drawFromBracket(
matches: Array<{ round: string; matchNumber: number; participant1Id: string | null; participant2Id: string | null }>,
tr: IdTranslator
): string[] | null {
// First round name + match count come from the template (single source).
const firstRoundMatches = FIRST_ROUND.matchCount;
const slotCount = firstRoundMatches * 2;
const r1 = matches.filter((m) => m.round === FIRST_ROUND.name);
if (r1.length !== firstRoundMatches) return null;
const slots: (string | null)[] = Array(slotCount).fill(null);
for (const m of r1) {
if (m.matchNumber < 1 || m.matchNumber > firstRoundMatches) return null;
slots[(m.matchNumber - 1) * 2] = tr(m.participant1Id);
slots[(m.matchNumber - 1) * 2 + 1] = tr(m.participant2Id);
}
if (slots.some((s) => s === null)) return null;
return slots as string[];
}
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class TennisSimulator implements Simulator { export class TennisSimulator implements Simulator {
@ -327,6 +432,74 @@ export class TennisSimulator implements Simulator {
// Load not-participating exclusions for each incomplete major. // Load not-participating exclusions for each incomplete major.
const excludedByEvent = await getExcludedByEventMap(incompleteMajors.map((e) => e.id)); const excludedByEvent = await getExcludedByEventMap(incompleteMajors.map((e) => e.id));
// Per-stage QP derived from the season's QP config (tie-split) so simulated QP
// matches what the bracket actually awards via processQualifyingBracketEvent.
// Falls back to the historical defaults if the season has no QP config.
const qpConfigArray = await getQPConfig(sportsSeasonId);
const qpMap = new Map<number, number>(
qpConfigArray.map((c) => [c.placement, parseFloat(c.points)])
);
const slamQP: SlamQP =
qpConfigArray.length > 0
? {
winner: calculateSplitQualifyingPoints(1, 1, qpMap),
finalist: calculateSplitQualifyingPoints(2, 1, qpMap),
sf: calculateSplitQualifyingPoints(3, 2, qpMap),
qf: calculateSplitQualifyingPoints(5, 4, qpMap),
r16: calculateSplitQualifyingPoints(9, 8, qpMap),
}
: DEFAULT_SLAM_QP;
// Precompute, once, how each incomplete major is simulated. If a real bracket
// exists (and is fully drawn), condition on it: a fixed draw from the bracket
// + completed matches honored (read from the primary window for siblings, with
// id translation). Otherwise fall back to a fresh synthetic seeded draw.
const localParticipants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const translatorCache = new Map<string, IdTranslator>();
interface MajorPlan {
eventId: string;
surface: CourtSurface;
fixedDraw: string[] | null;
// honored is built ONCE here (not per Monte Carlo iteration).
honored: Map<string, Map<number, string>> | null;
excluded: Set<string>;
}
const majorPlans: MajorPlan[] = [];
for (const event of incompleteMajors) {
const surface = getSurfaceForEvent(event.name);
const excluded = excludedByEvent.get(event.id) ?? new Set<string>();
const { sourceId, tr } = await resolveStructureSource(
event,
localParticipants,
translatorCache
);
const matches = await findPlayoffMatchesByEventId(sourceId);
let fixedDraw: string[] | null = null;
let honored: Map<string, Map<number, string>> | null = null;
if (matches.length > 0) {
fixedDraw = drawFromBracket(
matches.map((m) => ({
round: m.round,
matchNumber: m.matchNumber,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
})),
tr
);
if (fixedDraw) {
honored = buildHonoredMap(
matches.map((m) => ({
round: m.round,
matchNumber: m.matchNumber,
winnerId: tr(m.winnerId),
isComplete: m.isComplete,
}))
);
}
}
majorPlans.push({ eventId: event.id, surface, fixedDraw, honored, excluded });
}
// 5. Monte Carlo loop. // 5. Monte Carlo loop.
// For each player, count how many times they finish 1st8th by QP rank. // For each player, count how many times they finish 1st8th by QP rank.
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array.from({ length: 8 }, () => 0)); const counts: number[][] = Array.from({ length: participantIds.length }, () => Array.from({ length: 8 }, () => 0));
@ -337,15 +510,24 @@ export class TennisSimulator implements Simulator {
const simQP = new Map<string, number>(actualQPMap); const simQP = new Map<string, number>(actualQPMap);
// Simulate each incomplete major and accumulate QP. // Simulate each incomplete major and accumulate QP.
for (const event of incompleteMajors) { for (const plan of majorPlans) {
const surface = getSurfaceForEvent(event.name); let results: QPResult[];
const excluded = excludedByEvent.get(event.id) ?? new Set<string>(); if (plan.fixedDraw && plan.honored) {
const activeIds = participantIds.filter((id) => !excluded.has(id)); // Real bracket: fixed draw, completed matches honored, withdrawn
// players walk over in undecided matches.
results = simulateMajor(plan.fixedDraw, surfaceEloMap, plan.surface, {
honored: plan.honored,
qp: slamQP,
excluded: plan.excluded,
});
} else {
const activeIds = participantIds.filter((id) => !plan.excluded.has(id));
// Fall back to full participant list if too few remain after exclusions // Fall back to full participant list if too few remain after exclusions
// (buildDraw requires >= 128 players to fill all bracket slots). // (buildDraw requires >= 128 players to fill all bracket slots).
const drawIds = activeIds.length >= 128 ? activeIds : participantIds; const drawIds = activeIds.length >= 128 ? activeIds : participantIds;
const draw = buildDraw(drawIds, surfaceEloMap); const draw = buildDraw(drawIds, surfaceEloMap);
const results = simulateMajor(draw, surfaceEloMap, surface); results = simulateMajor(draw, surfaceEloMap, plan.surface, { qp: slamQP });
}
for (const { participantId, qp } of results) { for (const { participantId, qp } of results) {
simQP.set(participantId, (simQP.get(participantId) ?? 0) + qp); simQP.set(participantId, (simQP.get(participantId) ?? 0) + qp);
} }

View file

@ -1,7 +1,14 @@
import { eq, and } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { processQualifyingEvent } from "~/models/scoring-calculator"; import {
processQualifyingEvent,
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
import { getEventResults } from "~/models/event-result";
import { upsertTournamentResult } from "~/models/tournament-result";
import { logger } from "~/lib/logger";
export interface SyncReport { export interface SyncReport {
tournamentId: string; tournamentId: string;
@ -14,6 +21,22 @@ export interface SyncReport {
}>; }>;
} }
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;
}
/** /**
* Fan out canonical tournament_results to every scoring_event window that * Fan out canonical tournament_results to every scoring_event window that
* points at this tournament. For each linked window: * points at this tournament. For each linked window:
@ -30,9 +53,11 @@ export interface SyncReport {
* results is owned by processQualifyingEvent. * results is owned by processQualifyingEvent.
*/ */
export async function syncTournamentResults( export async function syncTournamentResults(
tournamentId: string tournamentId: string,
options: SyncOptions = {}
): Promise<SyncReport> { ): Promise<SyncReport> {
const db = database(); const db = database();
const { markComplete = true, skipEventId } = options;
const report: SyncReport = { const report: SyncReport = {
tournamentId, tournamentId,
@ -48,10 +73,24 @@ export async function syncTournamentResults(
.where(eq(schema.tournamentResults.tournamentId, tournamentId)); .where(eq(schema.tournamentResults.tournamentId, tournamentId));
// 2. Load every scoring_event that points at this tournament. // 2. Load every scoring_event that points at this tournament.
const linkedEvents = await db const allLinkedEvents = await db
.select() .select()
.from(schema.scoringEvents) .from(schema.scoringEvents)
.where(eq(schema.scoringEvents.tournamentId, tournamentId)); .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. // 3. Fan out — each event gets its own transaction.
for (const ev of linkedEvents) { for (const ev of linkedEvents) {
@ -67,9 +106,11 @@ export async function syncTournamentResults(
// upsert the matching event_results row. Only placement / rawScore // upsert the matching event_results row. Only placement / rawScore
// are copied; qualifying_points_awarded is left for // are copied; qualifying_points_awarded is left for
// processQualifyingEvent to recompute. // processQualifyingEvent to recompute.
const canonicalSpIds = new Set<string>();
for (const cr of canonicalResults) { for (const cr of canonicalResults) {
const sp = rosters.find((r) => r.participantId === cr.participantId); const sp = rosters.find((r) => r.participantId === cr.participantId);
if (!sp) continue; if (!sp) continue;
canonicalSpIds.add(sp.id);
const [existing] = await tx const [existing] = await tx
.select() .select()
@ -100,28 +141,56 @@ export async function syncTournamentResults(
} }
} }
// 3c. 0-QP filler pass: every roster member without an event_results // 3c. Reconcile every roster member NOT in the canonical results to a
// row for this event gets one with placement=null, QP=0. This matches // 0-QP filler row (placement=null, QP=0). This both creates missing rows
// the behavior from the per-window batch-qualifying-results design. // 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 const afterRows = await tx
.select() .select()
.from(schema.eventResults) .from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, ev.id)); .where(eq(schema.eventResults.scoringEventId, ev.id));
const covered = new Set(afterRows.map((r) => r.seasonParticipantId)); const existingBySp = new Map(afterRows.map((r) => [r.seasonParticipantId, r]));
for (const sp of rosters) { for (const sp of rosters) {
if (covered.has(sp.id)) continue; if (canonicalSpIds.has(sp.id)) continue;
const existing = existingBySp.get(sp.id);
if (!existing) {
await tx.insert(schema.eventResults).values({ await tx.insert(schema.eventResults).values({
scoringEventId: ev.id, scoringEventId: ev.id,
seasonParticipantId: sp.id, seasonParticipantId: sp.id,
placement: null, placement: null,
qualifyingPointsAwarded: "0", 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. // 3d. Delegate to scoring engine inside the same transaction.
await processQualifyingEvent(ev.id, tx); await processQualifyingEvent(ev.id, tx);
// 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.
if (markComplete) {
await completeScoringEvent(ev.id, tx);
}
}); });
report.windowsSynced += 1; report.windowsSynced += 1;
syncedWindows.push({
sportsSeasonId: ev.sportsSeasonId,
eventId: ev.id,
eventName: ev.name,
});
} catch (e: unknown) { } catch (e: unknown) {
report.windowsFailed += 1; report.windowsFailed += 1;
const msg = const msg =
@ -134,5 +203,141 @@ export async function syncTournamentResults(
} }
} }
// 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.
skipDiscord: !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; 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 } = {}
): Promise<SyncReport> {
const { markComplete = 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`
);
return syncTournamentResults(tournamentId, {
markComplete,
skipEventId: primaryEventId,
});
}
/**
* 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}`
);
}
}

View file

@ -643,6 +643,11 @@ export const scoringEvents = pgTable("scoring_events", {
bracketRegionConfig: jsonb("bracket_region_config"), // BracketRegion[] | null bracketRegionConfig: jsonb("bracket_region_config"), // BracketRegion[] | null
isComplete: boolean("is_complete").notNull().default(false), isComplete: boolean("is_complete").notNull().default(false),
completedAt: timestamp("completed_at"), completedAt: timestamp("completed_at"),
// For tournament-linked majors shared across windows: marks the single window
// where the admin does the actual scoring (bracket/stage/results). Scoring on
// the primary fans out to every sibling window. At most one true per
// tournament_id. NULL/false for golf (scored on the canonical tournament page).
isPrimary: boolean("is_primary").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, (t) => ({ }, (t) => ({

View file

@ -0,0 +1,2 @@
ALTER TABLE "scoring_events" ADD COLUMN "is_primary" boolean DEFAULT false NOT NULL;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tournament_groups_scoring_event_id_idx" ON "tournament_groups" USING btree ("scoring_event_id");

File diff suppressed because it is too large Load diff

View file

@ -855,6 +855,13 @@
"when": 1781296473277, "when": 1781296473277,
"tag": "0121_even_gambit", "tag": "0121_even_gambit",
"breakpoints": true "breakpoints": true
},
{
"idx": 122,
"version": "7",
"when": 1782103950846,
"tag": "0122_soft_longshot",
"breakpoints": true
} }
] ]
} }

View file

@ -0,0 +1,234 @@
/**
* Backfill: unify existing majors for "score once, fan out everywhere".
*
* Existing data predates the primary-event model and the completion fan-out, so
* it's in three inconsistent states. This script reconciles them idempotently:
*
* 1. Link orphaned qualifying events (tournament_id IS NULL) to a canonical
* tournament via extractTournamentIdentity + upsertTournament. Same-major
* windows collapse onto one canonical row (unique sportId+name+year).
* 2. Designate a primary scoring_event per BRACKET major (tennis/CS2) the
* window holding the actual bracket/stage/results work. Golf is left with no
* primary (it's scored on the canonical tournament page).
* 3. Backfill canonical tournament_results from the chosen source window's
* event_results (placement canonical participant), and set tournament
* status from whether that window is complete.
* 4. Reconcile every sibling window via syncTournamentResults, then recompute
* each sports_season's majors_completed from its completed qualifying events.
*
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
*
* npx tsx scripts/backfill-major-linking.ts # apply
* npx tsx scripts/backfill-major-linking.ts --dry # report only
*/
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { and, eq, isNull } from "drizzle-orm";
import * as schema from "../database/schema.js";
import { DatabaseContext, database } from "../database/context.js";
import { extractTournamentIdentity } from "../app/lib/tournament-identity.js";
import { isBracketMajor } from "../app/lib/event-utils.js";
import { upsertTournament, updateTournamentStatus } from "../app/models/tournament.js";
import { upsertTournamentResult } from "../app/models/tournament-result.js";
import { getEventResults } from "../app/models/event-result.js";
import { findPlayoffMatchesByEventId } from "../app/models/playoff-match.js";
import { getCs2StageResultsMapForEvent } from "../app/models/cs2-major-stage.js";
import { syncTournamentResults, syncMajorFromPrimaryEvent } from "../app/services/sync-tournament-results.js";
const DRY = process.argv.includes("--dry");
const log = (...a: unknown[]) => console.log(...a);
async function run() {
const db = database();
// ── Step 1: link orphaned qualifying events ───────────────────────────────
const orphans = await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.isQualifyingEvent, true),
isNull(schema.scoringEvents.tournamentId)
),
with: { sportsSeason: true },
});
log(`\n[1] Orphaned qualifying events (no tournament link): ${orphans.length}`);
for (const ev of orphans) {
try {
const identity = extractTournamentIdentity({
name: ev.name,
eventDate: ev.eventDate,
eventType: ev.eventType,
});
log(` • "${ev.name}" → ${identity.name} ${identity.year}`);
if (!DRY) {
const tournament = await upsertTournament({
sportId: ev.sportsSeason.sportId,
name: identity.name,
year: identity.year,
startsAt: ev.eventStartsAt ?? null,
});
await db
.update(schema.scoringEvents)
.set({ tournamentId: tournament.id, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, ev.id));
}
} catch (e) {
log(` ! could not parse "${ev.name}" — link by hand: ${(e as Error).message}`);
}
}
// ── Gather all tournaments that now have linked events ─────────────────────
const linkedEvents = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.isQualifyingEvent, true),
with: { sportsSeason: { with: { sport: true } } },
});
const byTournament = new Map<string, typeof linkedEvents>();
for (const ev of linkedEvents) {
if (!ev.tournamentId) continue;
const arr = byTournament.get(ev.tournamentId) ?? [];
arr.push(ev);
byTournament.set(ev.tournamentId, arr);
}
log(`\n[2-4] Tournaments with linked events: ${byTournament.size}`);
// How "scored" a window is, used to pick the source/primary window.
async function scoredWeight(eventId: string): Promise<number> {
const [results, matches, stages] = await Promise.all([
getEventResults(eventId),
findPlayoffMatchesByEventId(eventId),
getCs2StageResultsMapForEvent(eventId),
]);
const placed = results.filter((r) => r.placement !== null).length;
return placed + matches.length + stages.size;
}
const touchedSportsSeasons = new Set<string>();
for (const [tournamentId, events] of byTournament) {
const simulatorType = events[0]?.sportsSeason?.sport?.simulatorType ?? null;
const bracketMajor = isBracketMajor(simulatorType);
// Choose the source window: most-scored, tie-break earliest created.
const weighted = await Promise.all(
events.map(async (e) => ({ e, w: await scoredWeight(e.id) }))
);
weighted.sort(
(a, b) =>
b.w - a.w ||
a.e.createdAt.getTime() - b.e.createdAt.getTime()
);
const source = weighted[0]?.e;
if (!source) continue;
const tName = `${source.name} [${bracketMajor ? simulatorType : "golf/placement"}]`;
log(`\n ${tName}: ${events.length} window(s), source=${source.id} (weight ${weighted[0].w})`);
// Step 2: designate primary for bracket majors only.
if (bracketMajor && !DRY) {
for (const { e } of weighted) {
const shouldBePrimary = e.id === source.id;
if (e.isPrimary !== shouldBePrimary) {
await db
.update(schema.scoringEvents)
.set({ isPrimary: shouldBePrimary, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, e.id));
}
}
log(` primary → ${source.id}`);
}
// Steps 3+4: backfill canonical results and reconcile siblings.
const isComplete = source.isComplete;
if (bracketMajor) {
// Bracket majors go through the exact live path: promote the primary's
// derived results to canonical (deleting any stale rows) and fan out to
// siblings, skipping the primary which is scored in place.
if (!DRY) {
const report = await syncMajorFromPrimaryEvent(source.id, {
markComplete: isComplete,
});
await updateTournamentStatus(
tournamentId,
report.windowsFailed === 0 && isComplete ? "completed" : "in_progress"
);
log(
` synced via primary: ${report.windowsSynced} ok, ${report.windowsFailed} failed`
);
} else {
log(` (dry) would sync via primary ${source.id}`);
}
} else {
// Golf/placement: promote the source window's placements to canonical,
// then fan to every window (no primary to skip).
const sourceResults = await getEventResults(source.id);
let promoted = 0;
for (const r of sourceResults) {
if (r.placement === null || r.notParticipating) continue;
const canonical = r.seasonParticipant?.participantId;
if (!canonical) continue;
if (!DRY) {
await upsertTournamentResult({
tournamentId,
participantId: canonical,
placement: r.placement,
rawScore: r.rawScore,
});
}
promoted += 1;
}
log(` canonical results promoted: ${promoted}`);
if (!DRY && promoted > 0) {
const report = await syncTournamentResults(tournamentId, {
markComplete: isComplete,
});
await updateTournamentStatus(
tournamentId,
report.windowsFailed === 0 && isComplete ? "completed" : "in_progress"
);
log(` synced: ${report.windowsSynced} ok, ${report.windowsFailed} failed`);
}
}
for (const e of events) touchedSportsSeasons.add(e.sportsSeasonId);
}
// ── Recompute majors_completed from completed qualifying events ────────────
log(`\n[5] Recomputing majors_completed for ${touchedSportsSeasons.size} sports season(s)`);
for (const sportsSeasonId of touchedSportsSeasons) {
const completed = await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isQualifyingEvent, true),
eq(schema.scoringEvents.isComplete, true)
),
});
if (!DRY) {
await db
.update(schema.sportsSeasons)
.set({ majorsCompleted: completed.length, updatedAt: new Date() })
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
}
log(`${sportsSeasonId}: majorsCompleted = ${completed.length}`);
}
log(`\nDone${DRY ? " (dry run — no writes)" : ""}.`);
}
async function main() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("ERROR: DATABASE_URL is required");
process.exit(1);
}
const client = postgres(dbUrl, { max: 1 });
const db = drizzle(client, { schema });
try {
await DatabaseContext.run(db, run);
} finally {
await client.end();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});