mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-25 07:32:19 -05:00
259 lines
6.7 KiB
TypeScript
259 lines
6.7 KiB
TypeScript
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
|
/**
|
|
* Backfill Tournament Tiers Script
|
|
*
|
|
* Run with: npx tsx scripts/backfill-tournament-tiers.ts
|
|
*
|
|
* Retroactively calculates and sets tiers for all finalized tournaments,
|
|
* then populates series tier history based on those tiers.
|
|
*/
|
|
|
|
import { sql } from "~/db/sql";
|
|
import {
|
|
calculateAdjustedScore,
|
|
calculateTierNumber,
|
|
MIN_TEAMS_FOR_TIERING,
|
|
TIER_HISTORY_LENGTH,
|
|
TOP_TEAMS_COUNT,
|
|
} from "../app/features/tournament/core/tiering";
|
|
|
|
const dryRun = process.argv.includes("--dry-run");
|
|
|
|
interface TournamentScore {
|
|
tournamentId: number;
|
|
teamCount: number;
|
|
top8AvgOrdinal: number | null;
|
|
}
|
|
|
|
interface TournamentWithOrg {
|
|
tournamentId: number;
|
|
name: string;
|
|
organizationId: number | null;
|
|
startTime: number;
|
|
}
|
|
|
|
interface Series {
|
|
id: number;
|
|
organizationId: number;
|
|
substringMatches: string;
|
|
}
|
|
|
|
function getTournamentScores(): TournamentScore[] {
|
|
const query = `
|
|
WITH TeamSkills AS (
|
|
SELECT
|
|
tt.tournamentId,
|
|
tt.id as teamId,
|
|
AVG(ss.ordinal) as avg_team_ordinal
|
|
FROM TournamentTeam tt
|
|
JOIN TournamentTeamMember ttm ON ttm.tournamentTeamId = tt.id
|
|
LEFT JOIN SeedingSkill ss ON ss.userId = ttm.userId AND ss.type = 'RANKED'
|
|
WHERE tt.droppedOut = 0
|
|
GROUP BY tt.tournamentId, tt.id
|
|
),
|
|
TeamCounts AS (
|
|
SELECT tournamentId, COUNT(*) as team_count
|
|
FROM TeamSkills
|
|
WHERE avg_team_ordinal IS NOT NULL
|
|
GROUP BY tournamentId
|
|
),
|
|
RankedTeams AS (
|
|
SELECT
|
|
ts.tournamentId,
|
|
ts.avg_team_ordinal,
|
|
tc.team_count,
|
|
ROW_NUMBER() OVER (PARTITION BY ts.tournamentId ORDER BY ts.avg_team_ordinal DESC) as rank
|
|
FROM TeamSkills ts
|
|
JOIN TeamCounts tc ON tc.tournamentId = ts.tournamentId
|
|
WHERE ts.avg_team_ordinal IS NOT NULL
|
|
),
|
|
TournamentScores AS (
|
|
SELECT
|
|
tournamentId,
|
|
AVG(avg_team_ordinal) as top8_avg_ordinal,
|
|
MAX(team_count) as team_count
|
|
FROM RankedTeams
|
|
WHERE rank <= ${TOP_TEAMS_COUNT}
|
|
GROUP BY tournamentId
|
|
)
|
|
SELECT
|
|
t.id as tournamentId,
|
|
COALESCE(ts.team_count, 0) as teamCount,
|
|
ts.top8_avg_ordinal as top8AvgOrdinal
|
|
FROM Tournament t
|
|
LEFT JOIN TournamentScores ts ON ts.tournamentId = t.id
|
|
WHERE t.isFinalized = 1
|
|
`;
|
|
|
|
return sql.prepare(query).all() as TournamentScore[];
|
|
}
|
|
|
|
function getTournamentsWithOrg(): TournamentWithOrg[] {
|
|
const query = /* sql */ `
|
|
SELECT
|
|
t.id as tournamentId,
|
|
ce.name,
|
|
ce.organizationId,
|
|
ced.startTime
|
|
FROM Tournament t
|
|
INNER JOIN CalendarEvent ce ON ce.tournamentId = t.id
|
|
INNER JOIN CalendarEventDate ced ON ced.eventId = ce.id
|
|
WHERE t.isFinalized = 1
|
|
AND ce.hidden = 0
|
|
ORDER BY ced.startTime ASC
|
|
`;
|
|
return sql.prepare(query).all() as TournamentWithOrg[];
|
|
}
|
|
|
|
function getAllSeries(): Series[] {
|
|
const query = /* sql */ `
|
|
SELECT id, organizationId, substringMatches
|
|
FROM TournamentOrganizationSeries
|
|
`;
|
|
return sql.prepare(query).all() as Series[];
|
|
}
|
|
|
|
function matchesSubstring(
|
|
eventName: string,
|
|
substringMatches: string[],
|
|
): boolean {
|
|
const eventNameLower = eventName.toLowerCase();
|
|
return substringMatches.some((match) =>
|
|
eventNameLower.includes(match.toLowerCase()),
|
|
);
|
|
}
|
|
|
|
function main() {
|
|
console.log("=== Backfilling Tournament Tiers ===\n");
|
|
if (dryRun) {
|
|
console.log("DRY RUN - no changes will be made\n");
|
|
}
|
|
|
|
const tournaments = getTournamentScores();
|
|
console.log(`Found ${tournaments.length} finalized tournaments\n`);
|
|
|
|
const updateTierStatement = sql.prepare(
|
|
/* sql */ `UPDATE "Tournament" SET tier = @tier WHERE id = @tournamentId`,
|
|
);
|
|
|
|
const tierCounts: Record<string, number> = {};
|
|
const tournamentTiers = new Map<number, number>();
|
|
let updatedCount = 0;
|
|
let skippedCount = 0;
|
|
|
|
for (const t of tournaments) {
|
|
const meetsMinTeams = t.teamCount >= MIN_TEAMS_FOR_TIERING;
|
|
|
|
let tierNumber: number | null = null;
|
|
if (t.top8AvgOrdinal !== null && meetsMinTeams) {
|
|
const adjustedScore = calculateAdjustedScore(
|
|
t.top8AvgOrdinal,
|
|
t.teamCount,
|
|
);
|
|
tierNumber = calculateTierNumber(adjustedScore);
|
|
}
|
|
|
|
if (tierNumber !== null) {
|
|
tierCounts[tierNumber] = (tierCounts[tierNumber] || 0) + 1;
|
|
tournamentTiers.set(t.tournamentId, tierNumber);
|
|
updatedCount++;
|
|
} else {
|
|
skippedCount++;
|
|
}
|
|
|
|
if (!dryRun) {
|
|
updateTierStatement.run({
|
|
tier: tierNumber,
|
|
tournamentId: t.tournamentId,
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log("Tier distribution:");
|
|
const tierNames: Record<number, string> = {
|
|
1: "X",
|
|
2: "S+",
|
|
3: "S",
|
|
4: "A+",
|
|
5: "A",
|
|
6: "B+",
|
|
7: "B",
|
|
8: "C+",
|
|
9: "C",
|
|
};
|
|
for (let i = 1; i <= 9; i++) {
|
|
console.log(` ${tierNames[i]}: ${tierCounts[i] || 0}`);
|
|
}
|
|
|
|
console.log(`\nUpdated: ${updatedCount} tournaments`);
|
|
console.log(`Skipped (untiered): ${skippedCount} tournaments`);
|
|
|
|
console.log("\n=== Backfilling Series Tier History ===\n");
|
|
|
|
const allSeries = getAllSeries();
|
|
const tournamentsWithOrg = getTournamentsWithOrg();
|
|
console.log(`Found ${allSeries.length} series`);
|
|
console.log(
|
|
`Found ${tournamentsWithOrg.filter((t) => t.organizationId !== null).length} tournaments with organizations\n`,
|
|
);
|
|
|
|
const updateSeriesStatement = sql.prepare(
|
|
/* sql */ "UPDATE TournamentOrganizationSeries SET tierHistory = @tierHistory WHERE id = @seriesId",
|
|
);
|
|
|
|
const seriesByOrg = new Map<number, Series[]>();
|
|
for (const series of allSeries) {
|
|
const existing = seriesByOrg.get(series.organizationId) ?? [];
|
|
existing.push(series);
|
|
seriesByOrg.set(series.organizationId, existing);
|
|
}
|
|
|
|
let seriesUpdatedCount = 0;
|
|
let seriesSkippedCount = 0;
|
|
|
|
for (const [organizationId, orgSeries] of seriesByOrg.entries()) {
|
|
const orgTournaments = tournamentsWithOrg.filter(
|
|
(t) => t.organizationId === organizationId,
|
|
);
|
|
|
|
for (const series of orgSeries) {
|
|
const substringMatches = JSON.parse(series.substringMatches) as string[];
|
|
const matchingTournaments = orgTournaments
|
|
.filter((t) => matchesSubstring(t.name, substringMatches))
|
|
.filter((t) => tournamentTiers.has(t.tournamentId));
|
|
|
|
if (matchingTournaments.length === 0) {
|
|
seriesSkippedCount++;
|
|
continue;
|
|
}
|
|
|
|
const tierHistory = matchingTournaments
|
|
.slice(-TIER_HISTORY_LENGTH)
|
|
.map((t) => tournamentTiers.get(t.tournamentId)!);
|
|
|
|
console.log(
|
|
`Series ${series.id} (org ${organizationId}): ${matchingTournaments.length} matching tournaments, tierHistory = [${tierHistory.join(", ")}]`,
|
|
);
|
|
|
|
if (!dryRun) {
|
|
updateSeriesStatement.run({
|
|
seriesId: series.id,
|
|
tierHistory: JSON.stringify(tierHistory),
|
|
});
|
|
}
|
|
seriesUpdatedCount++;
|
|
}
|
|
}
|
|
|
|
console.log(`\nSeries updated: ${seriesUpdatedCount}`);
|
|
console.log(
|
|
`Series skipped (no matching tournaments): ${seriesSkippedCount}`,
|
|
);
|
|
|
|
if (dryRun) {
|
|
console.log("\nRun without --dry-run to apply changes");
|
|
}
|
|
}
|
|
|
|
main();
|