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
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:
parent
b960d39be3
commit
9480501932
23 changed files with 8689 additions and 150 deletions
|
|
@ -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 9–16; 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 9th–16th
|
||||
{ name: "Quarterfinals", matchCount: 4, feedsInto: "Semifinals", isScoring: true }, // losers share 5th–8th
|
||||
{ name: "Semifinals", matchCount: 2, feedsInto: "Final", isScoring: true }, // losers share 3rd–4th
|
||||
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true }, // winner 1st, loser 2nd
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* NCAA March Madness (68 teams)
|
||||
* 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,
|
||||
fifa_48: FIFA_48,
|
||||
darts_128: DARTS_128,
|
||||
tennis_128: TENNIS_128,
|
||||
cfp_12: CFP_12,
|
||||
nba_20: NBA_20,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
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 = {
|
||||
query: {
|
||||
scoringEvents: { findMany: mockFindMany },
|
||||
scoringEvents: { findMany: mockFindMany, findFirst: mockFindFirst },
|
||||
},
|
||||
update: mockUpdate,
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
|
|
@ -14,8 +26,10 @@ vi.mock("~/database/context", () => ({
|
|||
|
||||
vi.mock("~/database/schema", () => ({
|
||||
scoringEvents: {
|
||||
id: "se.id",
|
||||
sportsSeasonId: "se.sports_season_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 }),
|
||||
and: (...args: unknown[]) => ({ type: "and", args }),
|
||||
isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
|
||||
asc: (col: unknown) => ({ type: "asc", col }),
|
||||
}));
|
||||
|
||||
import {
|
||||
getTournamentsBySportsSeason,
|
||||
getSportsSeasonsByTournament,
|
||||
ensurePrimaryEvent,
|
||||
setPrimaryEvent,
|
||||
} from "../scoring-event";
|
||||
|
||||
const SEASON_ID = "ss-1";
|
||||
|
|
@ -39,6 +56,7 @@ const mockTournamentB = { id: TOURNAMENT_ID_B, name: "US Open", year: 2026 };
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
updateCalls.length = 0;
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -112,6 +112,16 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
|
|||
// 3rd place game finalizes both positions distinctly.
|
||||
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
||||
},
|
||||
tennis_128: {
|
||||
// R16 losers share 9th–16th; winner advances to QF (floor 5th–8th).
|
||||
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
|
||||
// QF losers share 5th–8th; winner advances to SF (floor 3rd–4th).
|
||||
Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
||||
// SF losers share 3rd–4th; 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 },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1067,3 +1067,94 @@ export async function getSportsSeasonsByTournament(tournamentId: string) {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.br
|
|||
|
||||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
|
||||
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
|
||||
import {
|
||||
findPlayoffMatchesByEventId,
|
||||
generateBracketFromTemplate,
|
||||
|
|
@ -61,6 +61,7 @@ import { database } from "~/database/context";
|
|||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
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.
|
||||
*/
|
||||
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>,
|
||||
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2]
|
||||
): Promise<void> {
|
||||
|
|
@ -130,12 +137,28 @@ async function scoreQualifyingBracket(
|
|||
db,
|
||||
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) {
|
||||
const formData = await request.formData();
|
||||
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") {
|
||||
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.
|
||||
if (!event.isQualifyingEvent) {
|
||||
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.
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
|
||||
}
|
||||
// Re-propagate corrected QP to sibling windows (data-correction; not final).
|
||||
await fanOutMajorIfPrimary(event, { markComplete: false });
|
||||
return {
|
||||
success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`,
|
||||
};
|
||||
|
|
@ -766,6 +794,8 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
eventId: params.eventId,
|
||||
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." };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { 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 {
|
||||
getCs2StageResultsForEvent,
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '~/models/cs2-major-stage';
|
||||
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
|
||||
import { syncMatches } from '~/services/match-sync';
|
||||
import { fanOutMajorIfPrimary } from '~/services/sync-tournament-results';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -61,7 +62,20 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const formData = await request.formData();
|
||||
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') {
|
||||
// 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);
|
||||
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 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.` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' };
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getScoringEventById,
|
||||
completeScoringEvent,
|
||||
updateScoringEvent,
|
||||
isReadOnlySibling,
|
||||
} from "~/models/scoring-event";
|
||||
import {
|
||||
getEventResults,
|
||||
|
|
@ -77,6 +78,9 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
)
|
||||
: 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 {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
|
||||
|
|
@ -89,13 +93,45 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
qpConfig,
|
||||
qpStandings,
|
||||
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) {
|
||||
const formData = await request.formData();
|
||||
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") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
|
|
|
|||
|
|
@ -95,9 +95,24 @@ export default function EventResults({
|
|||
loaderData,
|
||||
actionData,
|
||||
}: 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// Create a map of participants with results for easy lookup
|
||||
|
|
@ -218,6 +233,7 @@ export default function EventResults({
|
|||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-4xl">
|
||||
{readOnlySiblingBanner}
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" size="sm" asChild className="mb-2">
|
||||
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events`}>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import {
|
|||
createScoringEvent,
|
||||
deleteScoringEvent,
|
||||
bulkCreateScoringEvents,
|
||||
ensurePrimaryEvent,
|
||||
type CreateScoringEventData,
|
||||
} from "~/models/scoring-event";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
import { getQPStandings } from "~/models/qualifying-points";
|
||||
import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
|
||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||
|
|
@ -92,6 +94,12 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
eventDate,
|
||||
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}`);
|
||||
} catch (error) {
|
||||
logger.error("Error adding scoring event from tournament:", error);
|
||||
|
|
@ -185,7 +193,13 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
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` };
|
||||
} catch (error) {
|
||||
logger.error("Error bulk creating events:", error);
|
||||
|
|
@ -282,6 +296,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
|
||||
try {
|
||||
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}`);
|
||||
} catch (error) {
|
||||
logger.error("Error creating scoring event:", error);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ import {
|
|||
syncTournamentResults,
|
||||
type SyncReport,
|
||||
} 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 {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -52,13 +57,30 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
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),
|
||||
findCanonicalParticipantsBySport(tournament.sportId),
|
||||
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) {
|
||||
|
|
@ -127,11 +149,17 @@ export async function action(args: Route.ActionArgs) {
|
|||
});
|
||||
}
|
||||
|
||||
if (tournament.status !== "completed") {
|
||||
await updateTournamentStatus(tournament.id, "completed");
|
||||
const syncReport = await syncTournamentResults(tournament.id);
|
||||
|
||||
// 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 {
|
||||
success: true as const,
|
||||
error: null,
|
||||
|
|
@ -151,6 +179,11 @@ export async function action(args: Route.ActionArgs) {
|
|||
if (intent === "retry-window-sync") {
|
||||
try {
|
||||
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 };
|
||||
} catch (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 {
|
||||
success: false as const,
|
||||
error: "Invalid intent",
|
||||
|
|
@ -174,8 +225,25 @@ export default function AdminTournamentDetail({
|
|||
loaderData,
|
||||
actionData,
|
||||
}: Route.ComponentProps) {
|
||||
const { tournament, results, canonicalParticipants, linkedSportsSeasons } = loaderData;
|
||||
const {
|
||||
tournament,
|
||||
results,
|
||||
canonicalParticipants,
|
||||
linkedSportsSeasons,
|
||||
primaryEvent,
|
||||
bracketMajor,
|
||||
isCs2,
|
||||
} = loaderData;
|
||||
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
|
||||
const liveReport: SyncReport | null =
|
||||
|
|
@ -195,6 +263,7 @@ export default function AdminTournamentDetail({
|
|||
Back to tournaments
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold">{tournament.name}</h1>
|
||||
<Badge
|
||||
|
|
@ -209,6 +278,14 @@ export default function AdminTournamentDetail({
|
|||
{tournament.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{primaryScoringHref && (
|
||||
<Button asChild>
|
||||
<Link to={primaryScoringHref}>
|
||||
{isCs2 ? "Manage stages & bracket →" : "Manage bracket →"}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{tournament.year}
|
||||
{tournament.location ? ` — ${tournament.location}` : ""}
|
||||
|
|
@ -313,6 +390,24 @@ export default function AdminTournamentDetail({
|
|||
{link.sportsSeason.scoringType.replace("_", " ")}
|
||||
</p>
|
||||
</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
|
||||
variant={
|
||||
link.sportsSeason.status === "completed"
|
||||
|
|
@ -325,6 +420,7 @@ export default function AdminTournamentDetail({
|
|||
{link.sportsSeason.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -382,6 +478,31 @@ export default function AdminTournamentDetail({
|
|||
</CardContent>
|
||||
</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
|
||||
participants={canonicalParticipants.map((p) => ({
|
||||
id: p.id,
|
||||
|
|
@ -393,6 +514,7 @@ export default function AdminTournamentDetail({
|
|||
}
|
||||
intent="batch-upsert-results"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import {
|
|||
getUserDisplayName,
|
||||
} from "~/models";
|
||||
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 { findSeasonMatchesByScoringEventId } from "~/models/season-match";
|
||||
import { getEventResults } from "~/models/event-result";
|
||||
|
|
@ -99,6 +100,55 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
|
||||
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"> & {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
|
@ -118,10 +168,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
// CS2 Major — load Swiss stage data + bracket
|
||||
if (simulatorType === "cs2_major_qualifying_points") {
|
||||
const [cs2StageResults, seasonMatches, playoffMatchRows] = await Promise.all([
|
||||
getCs2StageResultsForEvent(eventId),
|
||||
findSeasonMatchesByScoringEventId(eventId),
|
||||
getCs2StageResultsForEvent(structureEventId),
|
||||
findSeasonMatchesByScoringEventId(structureEventId),
|
||||
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 },
|
||||
}),
|
||||
]);
|
||||
|
|
@ -131,7 +181,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
createdAt: m.createdAt.toISOString(),
|
||||
updatedAt: m.updatedAt.toISOString(),
|
||||
}));
|
||||
const templateId = scoringEvent.bracketTemplateId ?? undefined;
|
||||
const templateId = structureBracketTemplateId ?? undefined;
|
||||
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
||||
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
|
||||
|
||||
|
|
@ -140,8 +190,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
league,
|
||||
sportsSeason,
|
||||
scoringEvent,
|
||||
teamOwnerships,
|
||||
userParticipantIds,
|
||||
teamOwnerships: bracketTeamOwnerships,
|
||||
userParticipantIds: bracketUserParticipantIds,
|
||||
cs2StageResults,
|
||||
seasonMatches: seasonMatches.map((m) => ({
|
||||
...m,
|
||||
|
|
@ -158,7 +208,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
// Bracket events (tennis/golf bracket majors, soccer/UCL group+bracket)
|
||||
if (scoringEvent.eventType === "playoff_game") {
|
||||
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 },
|
||||
});
|
||||
const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({
|
||||
|
|
@ -166,13 +216,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
createdAt: m.createdAt.toISOString(),
|
||||
updatedAt: m.updatedAt.toISOString(),
|
||||
}));
|
||||
const templateId = scoringEvent.bracketTemplateId ?? undefined;
|
||||
const templateId = structureBracketTemplateId ?? undefined;
|
||||
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
||||
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
|
||||
|
||||
let groupStandings: GroupStandingData[] = [];
|
||||
if (template?.groupStage) {
|
||||
const groups = await findGroupsByEventId(eventId);
|
||||
const groups = await findGroupsByEventId(structureEventId);
|
||||
const matchesByGroupId = await findMatchesByGroupIds(groups.map((g) => g.id));
|
||||
groupStandings = groups.map((group) => {
|
||||
const groupMatches = matchesByGroupId.get(group.id) ?? [];
|
||||
|
|
@ -216,8 +266,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
league,
|
||||
sportsSeason,
|
||||
scoringEvent,
|
||||
teamOwnerships,
|
||||
userParticipantIds,
|
||||
teamOwnerships: bracketTeamOwnerships,
|
||||
userParticipantIds: bracketUserParticipantIds,
|
||||
playoffMatches,
|
||||
playoffRounds,
|
||||
bracketTemplateId: templateId ?? null,
|
||||
|
|
|
|||
|
|
@ -7,11 +7,38 @@ vi.mock("~/database/context", () => ({
|
|||
|
||||
vi.mock("~/models/scoring-calculator", () => ({
|
||||
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 { 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
|
||||
|
|
@ -28,6 +55,7 @@ interface FakeScoringEvent {
|
|||
id: string;
|
||||
sportsSeasonId: string;
|
||||
tournamentId: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
interface FakeSeasonParticipant {
|
||||
|
|
@ -574,4 +602,287 @@ describe("syncTournamentResults", () => {
|
|||
expect(c?.placement).toBeNull();
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
simulateOneMajor,
|
||||
reconcileAdvancers,
|
||||
} from "../cs-major-simulator";
|
||||
import { buildSpTranslator } from "../shared-major";
|
||||
import type { AdvancedTeam, BracketMatchInput, SwissMatchInput, StageResultsMap } from "../cs-major-simulator";
|
||||
|
||||
// ─── 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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -337,3 +347,187 @@ describe("QP accumulation ranking (unit)", () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,11 +46,18 @@ import { database } from "~/database/context";
|
|||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
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 };
|
||||
import { getExcludedByEventMap } from "~/models/event-result";
|
||||
import { findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||||
import { findSeasonMatchesByScoringEventId } from "~/models/season-match";
|
||||
import {
|
||||
resolveStructureSource,
|
||||
IDENTITY_TR,
|
||||
type IdTranslator,
|
||||
type StructureSource,
|
||||
} from "./shared-major";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
|
||||
// ─── 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.
|
||||
const completedEventIds = events.filter((e) => e.isComplete).map((e) => e.id);
|
||||
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(
|
||||
events.map(async (event) => {
|
||||
const results = await getCs2StageResultsMapForEvent(event.id);
|
||||
return [event.id, results] as const;
|
||||
const { sourceId, tr } = structureByEventId.get(event.id) ?? {
|
||||
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(
|
||||
|
|
@ -652,35 +693,49 @@ export class CSMajorSimulator implements Simulator {
|
|||
// 6b. For incomplete events, load already-known results so the simulation
|
||||
// conditions on them instead of re-simulating: the Champions Stage
|
||||
// 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([
|
||||
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(
|
||||
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[]>(
|
||||
bracketEntries.map(([id, matches]) => [
|
||||
bracketEntries.map(([id, tr, matches]) => [
|
||||
id,
|
||||
matches.map((m) => ({
|
||||
round: m.round,
|
||||
matchNumber: m.matchNumber,
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
winnerId: m.winnerId,
|
||||
participant1Id: tr(m.participant1Id),
|
||||
participant2Id: tr(m.participant2Id),
|
||||
winnerId: tr(m.winnerId),
|
||||
isComplete: m.isComplete,
|
||||
})),
|
||||
])
|
||||
);
|
||||
const eventSwiss = new Map<string, SwissMatchInput[]>(
|
||||
swissEntries.map(([id, matches]) => [
|
||||
swissEntries.map(([id, tr, matches]) => [
|
||||
id,
|
||||
matches.map((m) => ({
|
||||
matchStage: m.matchStage,
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
winnerId: m.winnerId,
|
||||
participant1Id: tr(m.participant1Id),
|
||||
participant2Id: tr(m.participant2Id),
|
||||
winnerId: tr(m.winnerId),
|
||||
})),
|
||||
])
|
||||
);
|
||||
|
|
|
|||
88
app/services/simulations/shared-major.ts
Normal file
88
app/services/simulations/shared-major.ts
Normal 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 primary→local.
|
||||
*
|
||||
* `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 };
|
||||
}
|
||||
|
|
@ -42,6 +42,11 @@ import { eq, and, inArray } from "drizzle-orm";
|
|||
import * as schema from "~/database/schema";
|
||||
import { getSurfaceEloMap } from "~/models/surface-elo";
|
||||
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";
|
||||
|
||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||
|
|
@ -180,12 +185,89 @@ interface QPResult {
|
|||
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.
|
||||
* 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.
|
||||
*/
|
||||
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 getElo = (id: string): number => {
|
||||
|
|
@ -201,60 +283,83 @@ export function simulateMajor(draw: string[], eloMap: Map<string, { worldRanking
|
|||
return { winner, loser: winner === p1 ? p2 : p1 };
|
||||
};
|
||||
|
||||
// 7 rounds: R1(64 matches) R2(32) R3(16) R16(8) QF(4) SF(2) Final(1)
|
||||
let current = [...draw]; // 128 players
|
||||
// round name → (matchNumber → completed winner id). Prefer the prebuilt map
|
||||
// (hoisted out of the Monte Carlo loop by the caller); otherwise derive it.
|
||||
const honored =
|
||||
opts.honored ?? (opts.realBracket ? buildHonoredMap(opts.realBracket) : undefined);
|
||||
|
||||
// Rounds 1–3 award 0 QP; just advance winners.
|
||||
for (let round = 0; round < 3; round++) {
|
||||
// Per-round loser QP, mapped positionally from the template's scoring rounds.
|
||||
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[] = [];
|
||||
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);
|
||||
}
|
||||
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 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class TennisSimulator implements Simulator {
|
||||
|
|
@ -327,6 +432,74 @@ export class TennisSimulator implements Simulator {
|
|||
// Load not-participating exclusions for each incomplete major.
|
||||
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.
|
||||
// For each player, count how many times they finish 1st–8th by QP rank.
|
||||
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);
|
||||
|
||||
// Simulate each incomplete major and accumulate QP.
|
||||
for (const event of incompleteMajors) {
|
||||
const surface = getSurfaceForEvent(event.name);
|
||||
const excluded = excludedByEvent.get(event.id) ?? new Set<string>();
|
||||
const activeIds = participantIds.filter((id) => !excluded.has(id));
|
||||
for (const plan of majorPlans) {
|
||||
let results: QPResult[];
|
||||
if (plan.fixedDraw && plan.honored) {
|
||||
// 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
|
||||
// (buildDraw requires >= 128 players to fill all bracket slots).
|
||||
const drawIds = activeIds.length >= 128 ? activeIds : participantIds;
|
||||
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) {
|
||||
simQP.set(participantId, (simQP.get(participantId) ?? 0) + qp);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
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 {
|
||||
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
|
||||
* points at this tournament. For each linked window:
|
||||
|
|
@ -30,9 +53,11 @@ export interface SyncReport {
|
|||
* results is owned by processQualifyingEvent.
|
||||
*/
|
||||
export async function syncTournamentResults(
|
||||
tournamentId: string
|
||||
tournamentId: string,
|
||||
options: SyncOptions = {}
|
||||
): Promise<SyncReport> {
|
||||
const db = database();
|
||||
const { markComplete = true, skipEventId } = options;
|
||||
|
||||
const report: SyncReport = {
|
||||
tournamentId,
|
||||
|
|
@ -48,10 +73,24 @@ export async function syncTournamentResults(
|
|||
.where(eq(schema.tournamentResults.tournamentId, tournamentId));
|
||||
|
||||
// 2. Load every scoring_event that points at this tournament.
|
||||
const linkedEvents = await db
|
||||
const allLinkedEvents = await db
|
||||
.select()
|
||||
.from(schema.scoringEvents)
|
||||
.where(eq(schema.scoringEvents.tournamentId, tournamentId));
|
||||
// The primary window (when fanning out from a bracket/stage major) is already
|
||||
// scored in place — skip it so we don't redo its work.
|
||||
const linkedEvents = skipEventId
|
||||
? allLinkedEvents.filter((ev) => ev.id !== skipEventId)
|
||||
: allLinkedEvents;
|
||||
|
||||
// Windows that synced cleanly — used to propagate to leagues after the loop
|
||||
// (kept out of the per-window transaction so a recalc failure can't roll back
|
||||
// the result write).
|
||||
const syncedWindows: Array<{
|
||||
sportsSeasonId: string;
|
||||
eventId: string;
|
||||
eventName: string | null;
|
||||
}> = [];
|
||||
|
||||
// 3. Fan out — each event gets its own transaction.
|
||||
for (const ev of linkedEvents) {
|
||||
|
|
@ -67,9 +106,11 @@ export async function syncTournamentResults(
|
|||
// upsert the matching event_results row. Only placement / rawScore
|
||||
// are copied; qualifying_points_awarded is left for
|
||||
// processQualifyingEvent to recompute.
|
||||
const canonicalSpIds = new Set<string>();
|
||||
for (const cr of canonicalResults) {
|
||||
const sp = rosters.find((r) => r.participantId === cr.participantId);
|
||||
if (!sp) continue;
|
||||
canonicalSpIds.add(sp.id);
|
||||
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
|
|
@ -100,28 +141,56 @@ export async function syncTournamentResults(
|
|||
}
|
||||
}
|
||||
|
||||
// 3c. 0-QP filler pass: every roster member without an event_results
|
||||
// row for this event gets one with placement=null, QP=0. This matches
|
||||
// the behavior from the per-window batch-qualifying-results design.
|
||||
// 3c. Reconcile every roster member NOT in the canonical results to a
|
||||
// 0-QP filler row (placement=null, QP=0). This both creates missing rows
|
||||
// AND resets rows for participants whose canonical placement was removed
|
||||
// (a correction) — without this, a stale placement/rawScore would persist
|
||||
// and keep fanning out. notParticipating markers are left untouched.
|
||||
const afterRows = await tx
|
||||
.select()
|
||||
.from(schema.eventResults)
|
||||
.where(eq(schema.eventResults.scoringEventId, ev.id));
|
||||
const covered = new Set(afterRows.map((r) => r.seasonParticipantId));
|
||||
const existingBySp = new Map(afterRows.map((r) => [r.seasonParticipantId, r]));
|
||||
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({
|
||||
scoringEventId: ev.id,
|
||||
seasonParticipantId: sp.id,
|
||||
placement: null,
|
||||
qualifyingPointsAwarded: "0",
|
||||
});
|
||||
} else if (!existing.notParticipating && existing.placement !== null) {
|
||||
// Stale placement from a prior sync — reset to filler.
|
||||
await tx
|
||||
.update(schema.eventResults)
|
||||
.set({
|
||||
placement: null,
|
||||
rawScore: null,
|
||||
qualifyingPointsAwarded: "0",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.eventResults.id, existing.id));
|
||||
}
|
||||
}
|
||||
|
||||
// 3d. Delegate to scoring engine inside the same transaction.
|
||||
await processQualifyingEvent(ev.id, tx);
|
||||
|
||||
// 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;
|
||||
syncedWindows.push({
|
||||
sportsSeasonId: ev.sportsSeasonId,
|
||||
eventId: ev.id,
|
||||
eventName: ev.name,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
report.windowsFailed += 1;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -643,6 +643,11 @@ export const scoringEvents = pgTable("scoring_events", {
|
|||
bracketRegionConfig: jsonb("bracket_region_config"), // BracketRegion[] | null
|
||||
isComplete: boolean("is_complete").notNull().default(false),
|
||||
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(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
}, (t) => ({
|
||||
|
|
|
|||
2
drizzle/0122_soft_longshot.sql
Normal file
2
drizzle/0122_soft_longshot.sql
Normal 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");
|
||||
6741
drizzle/meta/0122_snapshot.json
Normal file
6741
drizzle/meta/0122_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -855,6 +855,13 @@
|
|||
"when": 1781296473277,
|
||||
"tag": "0121_even_gambit",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 122,
|
||||
"version": "7",
|
||||
"when": 1782103950846,
|
||||
"tag": "0122_soft_longshot",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
234
scripts/backfill-major-linking.ts
Normal file
234
scripts/backfill-major-linking.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue