137 lines
4.9 KiB
TypeScript
137 lines
4.9 KiB
TypeScript
|
|
/**
|
|||
|
|
* Backfill: re-score qualifying majors so mis-split QP is corrected.
|
|||
|
|
*
|
|||
|
|
* Sibling/mirror windows used to compute a placement's tie span from the players
|
|||
|
|
* present on THAT window's roster (a subset of the field), so a tied group split
|
|||
|
|
* fewer ways and over-awarded — tennis Round-of-16 losers landed at 2 QP instead of
|
|||
|
|
* the correct 1.5 (positions 9–16: (2+2+2+2+1+1+1+1)/8). processQualifyingEvent now
|
|||
|
|
* derives the tie span from the canonical full field, so re-running the fan-out
|
|||
|
|
* rewrites the stored event_results QP, participant totals, and league standings.
|
|||
|
|
*
|
|||
|
|
* This is idempotent and SILENT: notifications are suppressed so re-scoring history
|
|||
|
|
* (2 → 1.5 for many participants) does not re-ping every league's Discord. It does
|
|||
|
|
* NOT re-link events or designate primaries — run backfill-major-linking.ts first if
|
|||
|
|
* the data predates the shared-major model. majorsCompleted needs no fix (it is now
|
|||
|
|
* derived on read from completed qualifying events).
|
|||
|
|
*
|
|||
|
|
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
|
|||
|
|
*
|
|||
|
|
* npx tsx scripts/backfill-qp-resplit.ts # apply
|
|||
|
|
* npx tsx scripts/backfill-qp-resplit.ts --dry # report only
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|||
|
|
import postgres from "postgres";
|
|||
|
|
import { eq } from "drizzle-orm";
|
|||
|
|
import * as schema from "../database/schema.js";
|
|||
|
|
import { DatabaseContext, database } from "../database/context.js";
|
|||
|
|
import { isBracketMajor } from "../app/lib/event-utils.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();
|
|||
|
|
|
|||
|
|
// Every tournament-linked qualifying event, grouped by canonical tournament.
|
|||
|
|
const events = await db.query.scoringEvents.findMany({
|
|||
|
|
where: eq(schema.scoringEvents.isQualifyingEvent, true),
|
|||
|
|
with: { sportsSeason: { with: { sport: true } } },
|
|||
|
|
});
|
|||
|
|
const byTournament = new Map<string, typeof events>();
|
|||
|
|
for (const ev of events) {
|
|||
|
|
if (!ev.tournamentId) continue; // standalone/manual events: nothing to fan out
|
|||
|
|
const arr = byTournament.get(ev.tournamentId) ?? [];
|
|||
|
|
arr.push(ev);
|
|||
|
|
byTournament.set(ev.tournamentId, arr);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
log(`Tournaments with linked qualifying events: ${byTournament.size}`);
|
|||
|
|
let ok = 0;
|
|||
|
|
let failed = 0;
|
|||
|
|
|
|||
|
|
for (const [tournamentId, evs] of byTournament) {
|
|||
|
|
const simulatorType = evs[0]?.sportsSeason?.sport?.simulatorType ?? null;
|
|||
|
|
const bracketMajor = isBracketMajor(simulatorType);
|
|||
|
|
const name = evs[0]?.name ?? tournamentId;
|
|||
|
|
|
|||
|
|
// Only re-score fully-scored majors so we don't mark in-progress ones complete.
|
|||
|
|
const anyComplete = evs.some((e) => e.isComplete);
|
|||
|
|
const markComplete = evs.every((e) => e.isComplete);
|
|||
|
|
|
|||
|
|
if (DRY) {
|
|||
|
|
log(
|
|||
|
|
` (dry) ${name}: ${evs.length} window(s), bracket=${bracketMajor}, ` +
|
|||
|
|
`complete=${markComplete} — would re-score silently`
|
|||
|
|
);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
if (bracketMajor) {
|
|||
|
|
// Re-derive canonical from the primary, then fan out to siblings (which now
|
|||
|
|
// split ties by the full field). Primary is scored in place and skipped.
|
|||
|
|
const primary = evs.find((e) => e.isPrimary);
|
|||
|
|
if (!primary) {
|
|||
|
|
log(` ! ${name}: no primary window — skipping (run backfill-major-linking.ts)`);
|
|||
|
|
failed += 1;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const report = await syncMajorFromPrimaryEvent(primary.id, {
|
|||
|
|
markComplete,
|
|||
|
|
skipNotifications: true,
|
|||
|
|
});
|
|||
|
|
ok += report.windowsSynced;
|
|||
|
|
failed += report.windowsFailed;
|
|||
|
|
log(
|
|||
|
|
` ${name}: re-scored via primary — ${report.windowsSynced} ok, ${report.windowsFailed} failed`
|
|||
|
|
);
|
|||
|
|
} else {
|
|||
|
|
// Golf/placement: canonical tournament_results already exist; just fan out.
|
|||
|
|
if (!anyComplete) {
|
|||
|
|
log(` ${name}: no completed window — skipping`);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const report = await syncTournamentResults(tournamentId, {
|
|||
|
|
markComplete,
|
|||
|
|
skipNotifications: true,
|
|||
|
|
});
|
|||
|
|
ok += report.windowsSynced;
|
|||
|
|
failed += report.windowsFailed;
|
|||
|
|
log(
|
|||
|
|
` ${name}: re-scored — ${report.windowsSynced} ok, ${report.windowsFailed} failed`
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
failed += 1;
|
|||
|
|
log(` ! ${name}: ${(e as Error).message}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
log(`\nDone${DRY ? " (dry run — no writes)" : ""}. windows ok=${ok}, failed=${failed}.`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
});
|