Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,35 @@
import { db } from "~/db/sql";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const badgeId = process.argv[2]?.trim();
const discordIds = process.argv[3]?.trim();
invariant(discordIds, "id list of discord ids required (argument 1)");
invariant(badgeId, "display name of badge is required (argument 2)");
invariant(
discordIds.includes(","),
"discordIds must be a comma separated list of discord ids",
);
const users = discordIds.split(",");
for (const discordId of users) {
const user = await db
.selectFrom("User")
.select("id")
.where("discordId", "=", discordId)
.executeTakeFirst();
if (!user) {
logger.info(`User with discord id ${discordId} not found`);
continue;
}
await db
.insertInto("TournamentBadgeOwner")
.values({ badgeId: Number(badgeId), userId: user.id })
.execute();
}
logger.info(`Added ${users.length} owners to the badge`);

View File

@@ -0,0 +1,30 @@
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const code = process.argv[2]?.trim();
const displayName = process.argv[3]?.trim();
const rawHue = process.argv[4]?.trim();
const parsedHue = rawHue ? Number(rawHue) : undefined;
invariant(code, "code of badge is required (argument 1)");
invariant(code === code.toLocaleLowerCase(), "code of badge must be lowercase");
invariant(displayName, "display name of badge is required (argument 2)");
invariant(
displayName !== displayName.toLocaleLowerCase(),
"displayName of badge must have at least one uppercase letter",
);
invariant(
!parsedHue ||
(parsedHue >= -360 && parsedHue <= 360 && Number.isInteger(parsedHue)),
"hue must be between -360 and 360",
);
await BadgeRepository.insert({
code,
displayName,
hue: parsedHue ?? null,
authorId: null,
});
logger.info(`Added new badge: ${displayName}`);

View File

@@ -0,0 +1,142 @@
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { userAsyncLocalStorage } from "~/features/auth/core/user-context.server";
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const tournamentIdArg = process.argv[2]?.trim();
const placementsArg = process.argv[3]?.trim();
invariant(tournamentIdArg, "tournamentId is required (argument 1)");
invariant(placementsArg, "placements are required (argument 2), e.g. 1,2,3");
const tournamentId = Number(tournamentIdArg);
invariant(
Number.isInteger(tournamentId) && tournamentId > 0,
"tournamentId must be a positive integer",
);
const placements = placementsArg.split(",").map((p) => Number(p.trim()));
for (const p of placements) {
invariant(
Number.isInteger(p) && p > 0,
`each placement must be a positive integer, got: ${p}`,
);
}
async function loadTournament() {
try {
return await tournamentFromDB({
user: undefined,
tournamentId,
});
} catch {
throw new Error(`Tournament with id ${tournamentId} not found`);
}
}
async function main() {
const tournament = await loadTournament();
invariant(!tournament.hasStarted, "Tournament has already started");
const adminUser = await UserRepository.findLeanById(ADMIN_ID);
invariant(adminUser, `Admin user with id ${ADMIN_ID} not found`);
const season = Seasons.currentOrPrevious();
invariant(season, "No current or previous season found");
const leaderboard = await LeaderboardRepository.findTeamLeaderboardBySeason({
season: season.nth,
onlyOneEntryPerUser: true,
});
const entries = placements.map((placement) => {
const entry = leaderboard.find((e) => e.placementRank === placement);
invariant(entry, `No leaderboard entry found for placement #${placement}`);
return entry;
});
const existingTeamNames = new Set(tournament.ctx.teams.map((t) => t.name));
let teamNameCounter = 1;
const resolvedNames = entries.map((entry) => {
return entry.team?.name ?? `Team ${teamNameCounter++}`;
});
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const teamName = resolvedNames[i];
for (const member of entry.members) {
const existingTeam = tournament.teamMemberOfByUser({ id: member.id });
invariant(
!existingTeam,
`User ${member.username} (id: ${member.id}) is already on team "${existingTeam?.name}"`,
);
const user = await UserRepository.findLeanById(member.id);
invariant(user, `User with id ${member.id} not found`);
invariant(user.friendCode, `User ${member.username} has no friend code`);
if (tournament.ctx.settings.requireInGameNames) {
const inGameName = await UserRepository.findInGameNameByUserId(
member.id,
);
invariant(
inGameName,
`User ${member.username} has no in-game name (required by tournament)`,
);
}
}
invariant(
!existingTeamNames.has(teamName),
`Team name "${teamName}" is already taken in the tournament`,
);
existingTeamNames.add(teamName);
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const teamName = resolvedNames[i];
const owner = entry.members[0];
const tournamentTeam = await userAsyncLocalStorage.run(
{ user: adminUser },
() =>
TournamentTeamRepository.insert({
team: {
name: teamName,
prefersNotToHost: 0,
teamId: entry.team?.id ?? null,
},
userId: owner.id,
tournamentId,
}),
);
for (const member of entry.members.slice(1)) {
await userAsyncLocalStorage.run({ user: adminUser }, () =>
TournamentTeamRepository.join({
newTeamId: tournamentTeam.id,
userId: member.id,
}),
);
}
logger.info(
`Created team "${teamName}" (placement #${entry.placementRank}) with members: ${entry.members.map((m) => m.username).join(", ")}`,
);
}
logger.info(
`Done. Added ${entries.length} teams to tournament ${tournamentId}`,
);
}
void main();

View File

@@ -0,0 +1,240 @@
import { userAsyncLocalStorage } from "~/features/auth/core/user-context.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { logger } from "~/utils/logger";
import { roundToNDecimalPlaces } from "~/utils/number";
import { type BenchmarkCase, buildCases } from "./benchmark-db/cases";
import { resolveFixtures } from "./benchmark-db/fixtures";
const DEFAULT_ITERATIONS = 10;
const SUMMARY_BUCKETS = [
{ label: ">=100ms", minMs: 100 },
{ label: "10-100ms", minMs: 10 },
{ label: "1-10ms", minMs: 1 },
{ label: "<1ms", minMs: 0 },
];
interface CaseResult {
name: string;
min: number;
mean: number;
p95: number;
error?: string;
}
async function main() {
const { filter, iterations } = parseArgs(process.argv.slice(2));
if (process.env.SQL_LOG && process.env.SQL_LOG !== "none") {
logger.warn(
"SQL_LOG is set which adds per-query logging overhead, consider unsetting it for accurate results",
);
}
logger.info(`Resolving fixtures... (DB_PATH=${process.env.DB_PATH})`);
const fixturesStart = performance.now();
const fixtures = await resolveFixtures();
logger.info(
`Fixtures resolved in ${formatMs(performance.now() - fixturesStart)}`,
);
const { cases, skipped } = buildCases(fixtures);
const filteredCases = filter
? cases.filter((benchmarkCase) =>
benchmarkCase.name.toLowerCase().includes(filter.toLowerCase()),
)
: cases;
if (filteredCases.length === 0) {
logger.error(`No benchmark cases match the filter "${filter}"`);
process.exit(1);
}
logger.info(
`Running ${filteredCases.length} cases with ${iterations} iterations each (1 warmup)`,
);
const actor = fixtures.heavyUser
? await UserRepository.findLeanById(fixtures.heavyUser.id)
: undefined;
const results = await userAsyncLocalStorage.run(
{ user: actor ?? undefined },
() => runAll(filteredCases, iterations),
);
printResults(results, skipped);
printSummary(results, skipped);
}
main()
.then(() => process.exit(0))
.catch((error) => {
logger.error("Benchmark failed", error);
process.exit(1);
});
function parseArgs(argv: string[]) {
let filter: string | null = null;
let iterations = DEFAULT_ITERATIONS;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--filter") {
filter = argv[i + 1] ?? null;
}
if (argv[i] === "--iterations") {
iterations = Number(argv[i + 1]);
}
}
if (!Number.isInteger(iterations) || iterations < 1) {
throw new Error("--iterations must be a positive integer");
}
return { filter, iterations };
}
async function runAll(cases: BenchmarkCase[], iterations: number) {
const results: CaseResult[] = [];
for (const [index, benchmarkCase] of cases.entries()) {
const result = await benchmarkCase.run().then(
() => timeCase(benchmarkCase, iterations),
(error) => erroredResult(benchmarkCase.name, error),
);
results.push(result);
const progress = `[${index + 1}/${cases.length}]`;
if (result.error) {
logger.warn(`${progress} ${result.name} ERRORED: ${result.error}`);
} else {
logger.info(`${progress} ${result.name} mean ${formatMs(result.mean)}`);
}
}
return results;
}
async function timeCase(
benchmarkCase: BenchmarkCase,
iterations: number,
): Promise<CaseResult> {
const durations: number[] = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
try {
await benchmarkCase.run();
} catch (error) {
return erroredResult(benchmarkCase.name, error);
}
durations.push(performance.now() - start);
}
return { name: benchmarkCase.name, ...stats(durations) };
}
function erroredResult(name: string, error: unknown): CaseResult {
return { name, min: 0, mean: 0, p95: 0, error: String(error) };
}
function stats(durations: number[]) {
const sorted = [...durations].sort((a, b) => a - b);
const mean = sorted.reduce((acc, cur) => acc + cur, 0) / sorted.length;
return { min: sorted[0], mean, p95: percentile(sorted, 0.95) };
}
function percentile(sortedValues: number[], fraction: number) {
const index = Math.ceil(sortedValues.length * fraction) - 1;
return sortedValues[Math.min(sortedValues.length - 1, Math.max(0, index))];
}
function geometricMean(values: number[]) {
const logSum = values.reduce(
(acc, cur) => acc + Math.log(Math.max(cur, Number.EPSILON)),
0,
);
return Math.exp(logSum / values.length);
}
function printResults(results: CaseResult[], skipped: string[]) {
const rows = results
.filter((result) => !result.error)
.sort((a, b) => b.mean - a.mean);
const errored = results.filter((result) => result.error);
const nameWidth = Math.max(
...rows.map((row) => row.name.length),
"case".length,
);
logger.info("");
logger.info("Results (sorted by mean, slowest first)");
logger.info(
`${"case".padEnd(nameWidth)} ${"min".padStart(10)} ${"mean".padStart(10)} ${"p95".padStart(10)}`,
);
for (const row of rows) {
logger.info(
`${row.name.padEnd(nameWidth)} ${formatMs(row.min).padStart(10)} ${formatMs(row.mean).padStart(10)} ${formatMs(row.p95).padStart(10)}`,
);
}
if (skipped.length > 0) {
logger.warn("");
logger.warn(`Skipped ${skipped.length} cases (missing fixture):`);
for (const name of skipped) {
logger.warn(` ${name}`);
}
}
if (errored.length > 0) {
logger.warn("");
logger.warn(`${errored.length} cases errored:`);
for (const result of errored) {
logger.warn(` ${result.name}: ${result.error}`);
}
}
}
function printSummary(results: CaseResult[], skipped: string[]) {
const timed = results.filter((result) => !result.error);
if (timed.length === 0) return;
const means = timed.map((result) => result.mean).sort((a, b) => a - b);
const total = means.reduce((acc, cur) => acc + cur, 0);
const slowest = timed.reduce((acc, cur) => (cur.mean > acc.mean ? cur : acc));
const rows: Array<[string, string]> = [
[
"cases timed",
`${timed.length} (${skipped.length} skipped, ${results.length - timed.length} errored)`,
],
["total", formatMs(total)],
["geometric mean", formatMs(geometricMean(means))],
["median", formatMs(percentile(means, 0.5))],
["p95", formatMs(percentile(means, 0.95))],
["slowest", `${formatMs(slowest.mean)} (${slowest.name})`],
];
let unbucketed = means;
for (const bucket of SUMMARY_BUCKETS) {
const inBucket = unbucketed.filter((mean) => mean >= bucket.minMs);
rows.push([bucket.label, `${inBucket.length} cases`]);
unbucketed = unbucketed.filter((mean) => mean < bucket.minMs);
}
const labelWidth = Math.max(...rows.map(([label]) => label.length));
logger.info("");
logger.info("Summary (mean per case)");
for (const [label, value] of rows) {
logger.info(`${label.padEnd(labelWidth)} ${value}`);
}
logger.info(
"total is dominated by the slowest cases, geometric mean weighs every case equally",
);
}
function formatMs(ms: number) {
return `${roundToNDecimalPlaces(ms)}ms`;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
// validates all articles files in the project are following the correct .md + grey matter format
import { mostRecentArticles } from "~/features/articles/core/list.server";
import { logger } from "~/utils/logger";
async function main() {
await mostRecentArticles(1000);
}
main()
.then(() => {
logger.info("Articles are valid.");
process.exit(0);
})
.catch((error) => {
logger.error("Error validating articles:", error);
process.exit(1);
});

View File

@@ -0,0 +1,85 @@
import { db } from "~/db/sql";
import * as Seasons from "~/features/mmr/core/Seasons";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const rawNth = process.argv[2]?.trim();
const season = rawNth
? (() => {
const nth = Number(rawNth);
invariant(!Number.isNaN(nth), "nth must be a number");
const seasonData = Seasons.list.find((s) => s.nth === nth);
invariant(seasonData, `Season ${nth} not found`);
return seasonData;
})()
: Seasons.currentOrPrevious();
invariant(season, "No season found");
logger.info(`Checking status for season ${season.nth}`);
logger.info(
`Season period: ${season.starts.toISOString()} - ${season.ends.toISOString()}`,
);
const activeMatches = await db
.selectFrom("GroupMatch")
.leftJoin("Skill", "Skill.groupMatchId", "GroupMatch.id")
.select("GroupMatch.id")
.where("Skill.id", "is", null)
.execute();
const activeMatchIds = activeMatches.map((row) => row.id);
if (activeMatchIds.length > 0) {
logger.info(
`Active matches (${activeMatchIds.length}): ${activeMatchIds.join(", ")}`,
);
} else {
logger.info("No active matches found");
}
const seasonStartTimestamp = Math.floor(season.starts.getTime() / 1000);
const seasonEndTimestamp = Math.floor(season.ends.getTime() / 1000);
const tournaments = await db
.selectFrom("Tournament")
.innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select([
"Tournament.id",
"Tournament.settings",
"Tournament.isFinalized",
"CalendarEvent.name",
"CalendarEventDate.startsAt",
])
.where("CalendarEventDate.startsAt", ">=", seasonStartTimestamp)
.where("CalendarEventDate.startsAt", "<=", seasonEndTimestamp)
.orderBy("CalendarEventDate.startsAt")
.execute();
const unfinalizedTournaments = tournaments.filter(
(t) => t.settings.isRanked && !t.settings.isTest && !t.isFinalized,
);
if (unfinalizedTournaments.length > 0) {
logger.info(
`Unfinalized ranked tournaments (${unfinalizedTournaments.length}):`,
);
for (const tournament of unfinalizedTournaments) {
const date = new Date(tournament.startTime * 1000).toISOString();
logger.info(` - ID ${tournament.id}: "${tournament.name}" (${date})`);
}
} else {
logger.info("No unfinalized ranked tournaments found");
}
if (activeMatchIds.length > 0 || unfinalizedTournaments.length > 0) {
logger.warn("Season cannot be closed yet");
} else {
logger.info("Season is ready to be closed");
}

View File

@@ -0,0 +1,347 @@
/** biome-ignore-all lint/suspicious/noConsole: Biome v2 migration */
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const NO_WRITE_KEY = "--no-write";
const dontWrite = process.argv.includes(NO_WRITE_KEY);
const KNOWN_SUFFIXES = ["_zero", "_one", "_two", "_few", "_many", "_other"];
// `_zero` is an i18next `count === 0` override, not a CLDR plural category. It
// may legitimately sit alongside the bare singular key that single-plural
// languages (zh, ja, ko) use after `i18n:sync` collapses their plural forms, so
// unlike the real plural suffixes it never counts as a with/without-suffix clash.
const ZERO_SUFFIX = "_zero";
const PLURAL_SUFFIXES = KNOWN_SUFFIXES.filter((sfx) => sfx !== ZERO_SUFFIX);
const REPO_TRANSLATIONS_INFO_URL =
"https://github.com/sendou-ink/sendou.ink/blob/main/docs/translation.md";
const MD = {
inlineCode: (s: string) => `\`${s}\``,
strong: (s: string) => `**${s}**`,
};
const otherLanguageTranslationPath = (code?: string, fileName?: string) =>
path.join(
...[__dirname, "..", "locales", code, fileName].filter(
(val): val is string => !!val,
),
);
const allOtherLanguages = fs
.readdirSync(otherLanguageTranslationPath())
.filter((lang) => lang !== "en");
const missingTranslations: Record<
string,
Record<string, Array<string>>
> = Object.fromEntries(allOtherLanguages.map((lang) => [lang, {}]));
const totalTranslationCounts: Record<string, number> = {};
const fileNames: string[] = fs.readdirSync(otherLanguageTranslationPath("en"));
for (const file of fileNames) {
const englishContent = JSON.parse(
fs.readFileSync(otherLanguageTranslationPath("en", file), "utf8").trim(),
) as Record<string, string>;
const key = file.replace(".json", "");
const englishContentKeys = getKeysWithoutSuffix(englishContent, "en", file);
if (file !== "gear.json" && file !== "weapons.json") {
totalTranslationCounts[key] = englishContentKeys.length;
}
for (const lang of allOtherLanguages) {
// .DS_STORE
if (lang.startsWith(".")) continue;
try {
const otherRawContent = fs
.readFileSync(otherLanguageTranslationPath(lang, file), "utf8")
.trim();
let otherLanguageContent: Record<string, string>;
try {
otherLanguageContent = JSON.parse(otherRawContent);
} catch {
throw new Error(`failed to parse ${lang}/${file}`);
}
const otherLanguageContentKeys = getKeysWithoutSuffix(
otherLanguageContent,
lang,
file,
);
validateVariables({
english: englishContent,
other: otherLanguageContent,
lang,
file,
});
validateNoDuplicateKeys({
otherRawContent,
file,
lang,
});
const missingKeys = englishContentKeys.filter(
(key) => !otherLanguageContentKeys.includes(key),
);
if (key === "weapons" || key === "gear") {
if (missingKeys.length > 0) {
throw new Error(`missing keys in ${lang}/${file}`);
}
} else {
missingTranslations[lang][key] = missingKeys;
}
} catch (e) {
if ((e as { code: string }).code !== "ENOENT") throw e;
missingTranslations[lang][key] = englishContentKeys;
}
}
}
console.info("no issues found inside translation files");
if (dontWrite) {
process.exit(0);
}
const markdown = createTranslationProgessMarkdown({
totalTranslationCounts,
});
const translationProgressPath = path.join(
__dirname,
"..",
"translation-progress.md",
);
fs.writeFileSync(translationProgressPath, markdown);
function validateVariables({
english,
other,
lang,
file,
}: {
english: Record<string, string>;
other: Record<string, string>;
lang: string;
file: string;
}) {
for (const [key, value] of Object.entries(english)) {
const otherValue = other[key];
if (!otherValue) continue;
const englishMatches = value.match(/{{(.*?)}}/g);
const otherMatches = otherValue.match(/{{(.*?)}}/g);
if (!englishMatches && !otherMatches) continue;
for (const englishVar of englishMatches ?? []) {
if (!otherMatches?.includes(englishVar)) {
throw new Error(
`variable mismatch in ${lang}/${file}: ${englishVar} is missing in ${otherValue}`,
);
}
}
}
}
function validateNoDuplicateKeys({
otherRawContent,
lang,
file,
}: {
otherRawContent: string;
lang: string;
file: string;
}) {
const keys = new Set<string>();
const duplicateKeys = new Set<string>();
for (const line of otherRawContent.split("\n")) {
const key = line.trim().split(":")[0];
if (!key) continue;
if (keys.has(key)) {
duplicateKeys.add(key);
}
keys.add(key);
}
if (duplicateKeys.size > 0) {
throw new Error(
`duplicate key(s) in ${lang}/${file}: ${Array.from(duplicateKeys).join(
", ",
)}`,
);
}
}
// get keys while respecting different plural/context key suffixes in different languages.
function getKeysWithoutSuffix(
translations: Record<string, string>,
lang: string,
file: string,
): string[] {
const pluralBaseKeys = new Set<string>();
const bareKeys = new Set<string>();
const keys: string[] = [];
const pushOnce = (key: string) => {
if (!keys.includes(key)) keys.push(key);
};
for (const [key, value] of Object.entries(translations)) {
if (value === "") {
continue; // Consider key missing if untranslated
}
if (key.endsWith(ZERO_SUFFIX)) {
// `_zero` override always maps to its base key and never clashes with it
pushOnce(key.slice(0, -ZERO_SUFFIX.length));
continue;
}
const suffix = PLURAL_SUFFIXES.find((sfx) => key.endsWith(sfx));
if (!suffix) {
if (pluralBaseKeys.has(key)) {
throw new Error(
`Found same key with and without suffixes in ${lang}/${file}: ${key}`,
);
}
bareKeys.add(key);
pushOnce(key);
continue;
}
const baseKey = key.slice(0, -suffix.length);
if (bareKeys.has(baseKey)) {
throw new Error(
`Found same key with and without suffixes in ${lang}/${file}: ${baseKey}`,
);
}
pluralBaseKeys.add(baseKey);
pushOnce(baseKey);
}
return keys;
}
type StatusProps = {
totalCount: number;
missingCount: number;
percentage?: boolean;
};
function MDCompletionStatus({
totalCount,
missingCount,
percentage,
}: StatusProps) {
const circle =
missingCount === 0 ? "🟢" : missingCount === totalCount ? "🔴" : "🟡";
const nonMissingCount = totalCount - missingCount;
if (!percentage) {
return `${circle} ${nonMissingCount}/${totalCount}`;
}
const percent =
totalCount === 0 ? 100 : Math.floor((nonMissingCount / totalCount) * 100);
return `${circle} ${percent}%`;
}
function MDOverviewTable({
totalTranslationCounts,
}: {
totalTranslationCounts: Record<string, number>;
}) {
const totalKeysCount = Object.values(totalTranslationCounts).reduce(
(a, b) => a + b,
0,
);
const relevantFiles = fileNames.filter(
(name) => name !== "weapons.json" && name !== "gear.json",
);
const rows = [];
rows.push(
`| Language | Total | ${relevantFiles.map(MD.inlineCode).join(" | ")} |`,
);
rows.push(`| :-- | :-: | ${relevantFiles.map(() => ":-:").join(" | ")} |`);
for (const [lang, missingKeysObj] of Object.entries(missingTranslations)) {
const cells = [];
cells.push(MD.strong(lang));
const totalAmountOfMissingKeys = Object.values(missingKeysObj).reduce(
(a, b) => a + b.length,
0,
);
cells.push(
MD.strong(
MDCompletionStatus({
totalCount: totalKeysCount,
missingCount: totalAmountOfMissingKeys,
percentage: true,
}),
),
);
for (const file of relevantFiles) {
const fileKey = file.replace(".json", "");
const missingKeysInFile = missingKeysObj[fileKey];
if (!missingKeysInFile) {
return "";
}
cells.push(
MDCompletionStatus({
totalCount: totalTranslationCounts[fileKey],
missingCount: missingKeysInFile.length,
}),
);
}
rows.push(`| ${cells.join(" | ")} |`);
}
return rows.join("\n");
}
function createTranslationProgessMarkdown({
totalTranslationCounts,
}: {
totalTranslationCounts: Record<string, number>;
}) {
return `
> 🤖 This issue is fully automated, it should always be up-to-date.
# Translation Progress
If you want to contribute by adding missing translations, make sure to read the [project description](${REPO_TRANSLATIONS_INFO_URL}) on how to do this 💚
## Overview
Key: 🟢 = Done, 🟡 = In progress, 🔴 = Not started
${MDOverviewTable({ totalTranslationCounts })}`;
}

View File

@@ -0,0 +1,122 @@
/** biome-ignore-all lint/suspicious/noConsole: script */
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const LOCALES_PATH = path.join(__dirname, "..", "locales");
const PRIMARY_LANGUAGE = "en";
// When passed, no files are written. Instead the script exits non-zero if any
// file would be collapsed, so CI can fail a PR that committed suffixed plural
// keys in a single-plural language without running `pnpm run i18n:sync`.
const CHECK_KEY = "--check";
const checkOnly = process.argv.includes(CHECK_KEY);
// `_zero` is intentionally excluded: it is not a CLDR plural category but an
// i18next special-case override for `count === 0`. `i18next-locales-sync` treats
// e.g. `foo_zero` as a plain key (English has no `_zero` plural form), so it is
// preserved across every language and must not be collapsed into `foo`.
const COLLAPSIBLE_PLURAL_SUFFIXES = ["_one", "_two", "_few", "_many", "_other"];
// The plural form whose value we keep when collapsing. `_other` is preferred
// because it is the form i18next resolves at runtime for these languages and it
// typically carries the `{{count}}` interpolation. The remaining forms are only
// used as a fallback when `_other` has no translated value.
const PREFERRED_SUFFIX = "_other";
// `i18next-locales-sync` stores plural keys for languages whose CLDR cardinal
// rule has a single category ("other" only, e.g. zh, ja, ko) under the bare
// singular key instead of suffixed `_one`/`_other` keys. When a translator adds
// a value under a suffixed key for one of these languages, the sync tool can't
// find the singular key it expects and silently drops the translation. We run
// this before the sync to collapse those suffixed keys into the singular key,
// preserving the (non-empty) value so it survives the sync.
const languages = fs
.readdirSync(LOCALES_PATH)
.filter((lang) => lang !== PRIMARY_LANGUAGE && !lang.startsWith("."));
const wouldCollapse: Array<{ lang: string; file: string; keys: string[] }> = [];
for (const lang of languages) {
if (!isSinglePluralLanguage(lang)) continue;
const langPath = path.join(LOCALES_PATH, lang);
const files = fs
.readdirSync(langPath)
.filter((file) => file.endsWith(".json"));
for (const file of files) {
const filePath = path.join(langPath, file);
const content = JSON.parse(fs.readFileSync(filePath, "utf8")) as Record<
string,
string
>;
const { collapsed, collapsedBaseKeys } = collapsePluralKeys(content);
if (collapsedBaseKeys.length === 0) continue;
if (checkOnly) {
wouldCollapse.push({ lang, file, keys: collapsedBaseKeys });
continue;
}
fs.writeFileSync(filePath, `${JSON.stringify(collapsed, null, "\t")}\n`);
console.info(`collapsed plural keys in ${lang}/${file}`);
}
}
if (checkOnly) {
if (wouldCollapse.length > 0) {
console.error(
"Found suffixed plural keys in single-plural languages that `pnpm run i18n:sync` would collapse. Run it and commit the result.",
);
for (const { lang, file, keys } of wouldCollapse) {
console.error(` ${lang}/${file}: ${keys.join(", ")}`);
}
process.exit(1);
}
console.info("no plural keys to collapse in single-plural languages");
}
function isSinglePluralLanguage(lang: string) {
return (
new Intl.PluralRules(lang).resolvedOptions().pluralCategories.length === 1
);
}
function collapsePluralKeys(content: Record<string, string>) {
const collapsed: Record<string, string> = {};
const chosenFromPreferred = new Set<string>();
const collapsedBaseKeys = new Set<string>();
for (const [key, value] of Object.entries(content)) {
const suffix = COLLAPSIBLE_PLURAL_SUFFIXES.find((sfx) => key.endsWith(sfx));
if (!suffix) {
collapsed[key] = value;
continue;
}
const baseKey = key.slice(0, -suffix.length);
collapsedBaseKeys.add(baseKey);
const isPreferred = suffix === PREFERRED_SUFFIX;
// prefer the `_other` value (it carries {{count}}); otherwise keep the
// first non-empty value found across the remaining plural forms
const shouldReplace =
!(baseKey in collapsed) ||
(!collapsed[baseKey] && !!value) ||
(isPreferred && !!value && !chosenFromPreferred.has(baseKey));
if (shouldReplace) {
collapsed[baseKey] = value;
if (isPreferred && value) {
chosenFromPreferred.add(baseKey);
}
}
}
return { collapsed, collapsedBaseKeys: Array.from(collapsedBaseKeys) };
}

View File

@@ -0,0 +1,3 @@
import { ComputeLutiDivsRoutine } from "~/routines/computeLutiDivs";
await ComputeLutiDivsRoutine.run();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { brandIds } from "~/modules/in-game-lists/brand-ids";
import invariant from "~/utils/invariant";
import {
LANG_JSONS_TO_CREATE,
loadGearInfoClothes,
loadGearInfoHead,
loadGearInfoShoes,
loadLangDicts,
translationJsonFolderName,
} from "./utils";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const clothes = loadGearInfoClothes();
const head = loadGearInfoHead();
const shoes = loadGearInfoShoes();
const CURRENT_SEASON = 9;
const OUTPUT_DIR_PATH = path.join(__dirname, "output");
const LEAN_HEAD_CODE = "Hed";
const LEAN_CLOTHES_CODE = "Clt";
const LEAN_SHOES_CODE = "Shs";
async function main() {
const allGear: Array<{
id: number;
internalName: string;
type: string;
brand: string;
translations: Array<{ language: string; name: string }>;
}> = [];
const langDicts = await loadLangDicts();
for (const gear of [...head, ...clothes, ...shoes]) {
if (gear.Season > CURRENT_SEASON || gear.HowToGet === "Impossible") {
continue;
}
const [type, internalName] = gear.__RowId.split("_");
invariant(type);
invariant(internalName);
const categoryKey = `CommonMsg/Gear/GearName_${
type === LEAN_CLOTHES_CODE
? "Clothes"
: type === LEAN_SHOES_CODE
? "Shoes"
: "Head"
}`;
allGear.push({
id: gear.Id,
type,
internalName,
brand: gear.Brand,
translations: langDicts.map(([langCode, translations]) => {
const category = (
translations as unknown as Record<
string,
Record<string, string> | undefined
>
)[categoryKey];
const name = category?.[internalName];
invariant(name, `Missing translation for ${internalName}`);
return {
language: langCode,
name,
};
}),
});
}
allGear.sort((a, b) => a.id - b.id);
fs.writeFileSync(
path.join(OUTPUT_DIR_PATH, "gear.json"),
JSON.stringify(allGear, null, 2),
);
const headGear = allGear.filter((g) => g.type === LEAN_HEAD_CODE);
const clothesGear = allGear.filter((g) => g.type === LEAN_CLOTHES_CODE);
const shoesGear = allGear.filter((g) => g.type === LEAN_SHOES_CODE);
invariant(headGear.length);
invariant(clothesGear.length);
invariant(shoesGear.length);
const headIds = Object.fromEntries(
brandIds.map((brand) => [
brand,
headGear.filter((w) => w.brand === brand).map((w) => w.id),
]),
);
const clothesIds = Object.fromEntries(
brandIds.map((brand) => [
brand,
clothesGear.filter((w) => w.brand === brand).map((w) => w.id),
]),
);
const shoesIds = Object.fromEntries(
brandIds.map((brand) => [
brand,
shoesGear.filter((w) => w.brand === brand).map((w) => w.id),
]),
);
fs.writeFileSync(
path.join(OUTPUT_DIR_PATH, "head-ids.json"),
JSON.stringify(headIds, null, 2),
);
fs.writeFileSync(
path.join(OUTPUT_DIR_PATH, "clothes-ids.json"),
JSON.stringify(clothesIds, null, 2),
);
fs.writeFileSync(
path.join(OUTPUT_DIR_PATH, "shoes-ids.json"),
JSON.stringify(shoesIds, null, 2),
);
for (const langCode of LANG_JSONS_TO_CREATE) {
const translationsMap = Object.fromEntries(
allGear.map((gear) => {
const translation = gear.translations.find(
(t) => t.language === langCode,
)?.name;
invariant(
translation,
`No translation for ${gear.internalName} in ${langCode}`,
);
return [`${gear.type.charAt(0).toUpperCase()}_${gear.id}`, translation];
}),
);
fs.writeFileSync(
path.join(
__dirname,
"..",
"locales",
translationJsonFolderName(langCode),
"gear.json",
),
`${JSON.stringify(translationsMap, null, 2)}\n`,
);
}
}
void main();

View File

@@ -0,0 +1,214 @@
// for testing use the command `pnpm exec vite-node ./scripts/create-league-divisions.ts 6 'https://gist.githubusercontent.com/sendou-ink/38aa4d5d8426035ce178c09598ae627f/raw/17be9bb53a9f017c2097d0624f365d1c5a029f01/league.csv'`
import { z } from "zod";
import { db } from "~/db/sql";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const tournamentId = Number(process.argv[2]?.trim());
invariant(
tournamentId && !Number.isNaN(tournamentId),
"tournament id is required (argument 1)",
);
const csvUrl = process.argv[3]?.trim();
invariant(z.string().url().parse(csvUrl), "csv url is required (argument 2)");
async function main() {
const tournament = await tournamentFromDB({
tournamentId,
user: { id: ADMIN_ID },
});
invariant(tournament.isLeagueSignup, "Tournament is not a league signup");
const csv = await loadCsv();
const teams = parseCsv(csv);
for (const team of teams) {
validateTeam(team, tournament);
}
validateDivs(teams);
const grouped = Object.entries(Object.groupBy(teams, (t) => t.division)).sort(
(a, b) => {
const divAIndex = teams.findIndex((t) => t.division === a[0]);
const divBIndex = teams.findIndex((t) => t.division === b[0]);
return divAIndex - divBIndex;
},
);
for (const [, divsTeams] of grouped) {
divsTeams!.sort((a, b) => {
const teamAIndex = teams.findIndex((t) => t.id === a.id);
const teamBIndex = teams.findIndex((t) => t.id === b.id);
return teamAIndex - teamBIndex;
});
}
const calendarEvent = await db
.selectFrom("CalendarEvent")
.selectAll()
.where("CalendarEvent.id", "=", tournament.ctx.eventId)
.executeTakeFirstOrThrow();
for (const [div, divsTeams] of grouped) {
logger.info(`Creating division ${div}...`);
const createdEvent = await CalendarRepository.insert({
parentTournamentId: tournament.ctx.id,
authorId: tournament.ctx.author.id,
bracketProgression: tournament.ctx.settings.bracketProgression,
description: tournament.ctx.description,
discordInviteCode:
tournament.ctx.discordUrl?.replace("https://discord.gg/", "") ?? null,
mapPickingStyle: tournament.ctx.mapPickingStyle,
name: `${tournament.ctx.name} - ${div.startsWith("Division") ? div : `Division ${div}`}`,
organizationId: tournament.ctx.organization?.id ?? null,
rules: tournament.ctx.rules,
startTimes: [dateToDatabaseTimestamp(tournament.ctx.startsAt)],
tags: null,
tournamentToCopyId: tournament.ctx.id,
avatarImgId: calendarEvent.avatarImgId ?? undefined,
avatarFileName: undefined,
mapPoolMaps:
tournament.ctx.mapPickingStyle !== "TO"
? tournament.ctx.tieBreakerMapPool
: tournament.ctx.toSetMapPool,
badges: [],
enableNoScreenToggle: tournament.ctx.settings.enableNoScreenToggle,
enableSubs: false,
isInvitational: true,
autonomousSubs: false,
isRanked: tournament.ctx.settings.isRanked,
minMembersPerTeam: tournament.ctx.settings.minMembersPerTeam,
maxMembersPerTeam: tournament.ctx.settings.maxMembersPerTeam,
regClosesAt: tournament.ctx.settings.regClosesAt,
requireInGameNames: tournament.ctx.settings.requireInGameNames,
bracketUrl: "https://sendou.ink",
isFullTournament: true,
autoValidateAvatar: true,
// these come from progression
swissGroupCount: undefined,
swissRoundCount: undefined,
teamsPerGroup: undefined,
thirdPlaceMatch: undefined,
});
for (const [idx, team] of divsTeams!.entries()) {
await TournamentTeamRepository.copyFromAnotherTournament({
destinationTournamentId: createdEvent.tournamentId!,
tournamentTeamId: team.id,
seed: idx + 1,
defaultCheckedIn: true,
});
}
logger.info(`Created division ${div} (id: ${createdEvent.tournamentId})`);
}
}
async function loadCsv() {
const response = await fetch(csvUrl);
return response.text();
}
const csvSchema = z.array(
z.object({
"Team id": z.coerce.number(),
Div: z.string(),
}),
);
type ParsedTeam = ReturnType<typeof parseCsv>[number];
function parseCsv(csv: string) {
const lines = csv.trim().split("\n");
const headers = splitCsvRow(lines[0]).map((h) => h.trim());
const rows = lines.slice(1).map((line) => {
const row = splitCsvRow(line);
return headers.reduce(
(acc, header, i) => {
acc[header] = row[i];
return acc;
},
{} as Record<string, string>,
);
});
const validated = csvSchema.parse(rows);
return validated.map((row) => ({
id: row["Team id"],
division: row.Div,
}));
}
function validateTeam(team: ParsedTeam, tournament: Tournament) {
invariant(
tournament.ctx.teams.some((t) => t.id === team.id),
`Team with id ${team.id} not found in tournament`,
);
}
const MIN_TEAMS_COUNT_PER_DIV = 6;
function validateDivs(teams: ParsedTeam[]) {
const counts = teams.reduce(
(acc, team) => {
acc[team.division] = (acc[team.division] ?? 0) + 1;
return acc;
},
{} as Record<string, number>,
);
for (const [div, count] of Object.entries(counts)) {
invariant(
count >= MIN_TEAMS_COUNT_PER_DIV,
`Division ${div} has ${count} teams, expected at least ${MIN_TEAMS_COUNT_PER_DIV}`,
);
}
}
function splitCsvRow(line: string) {
const fields: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (inQuotes) {
if (char === '"') {
if (line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = false;
}
} else {
current += char;
}
} else if (char === '"') {
inQuotes = true;
} else if (char === ",") {
fields.push(current);
current = "";
} else {
current += char;
}
}
fields.push(current);
return fields;
}
main();

View File

@@ -0,0 +1,66 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const MIGRATION_FOLDER = fileURLToPath(
new URL("../migrations", import.meta.url),
);
const TEMPLATE = `import type { Kysely } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema.alterTable("TODO").addColumn("TODO", "text").execute();
});
}
`;
function main() {
const description = toKebabCase(process.argv.slice(2).join(" "));
if (!description) {
throw new Error(
'Missing migration description e.g. pnpm run migrate:new "add user pronouns"',
);
}
const fileName = `${timestamp(new Date())}-${description}.ts`;
const filePath = path.join(MIGRATION_FOLDER, fileName);
if (fs.existsSync(filePath)) {
throw new Error(`${fileName} already exists`);
}
fs.writeFileSync(filePath, TEMPLATE);
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(`Created migrations/${fileName}`);
}
/** Sortable UTC stamp, so two branches can never claim the same slot. */
function timestamp(date: Date) {
const pad = (value: number) => String(value).padStart(2, "0");
return [
date.getUTCFullYear(),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes()),
pad(date.getUTCSeconds()),
].join("");
}
function toKebabCase(input: string) {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
try {
main();
} catch (error) {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.error((error as Error).message);
process.exit(1);
}

View File

@@ -0,0 +1,342 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { abilitiesShort } from "~/modules/in-game-lists/abilities";
import { brandIds } from "~/modules/in-game-lists/brand-ids";
import invariant from "~/utils/invariant";
import {
LANG_JSONS_TO_CREATE,
loadLangDicts,
translationJsonFolderName,
} from "./utils";
type LangDicts = Awaited<ReturnType<typeof loadLangDicts>>;
type LangDict = LangDicts[number][1];
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const BADGE_DIR = path.join(__dirname, "badge");
const OUTPUT_DIR = path.join(__dirname, "output");
const ALL_LOCALE_FOLDERS = [
"da",
"de",
"en",
"es-ES",
"es-US",
"fr-CA",
"fr-EU",
"he",
"it",
"ja",
"ko",
"nl",
"pl",
"pt-BR",
"ru",
"zh",
];
const BADGE_TEMPLATE_RULES = [
{
prefix: "CoopGrade_Normal_",
templateKey: "CoopGrade_Normal_",
lookupDict: "CommonMsg/Coop/CoopStageName",
},
{
prefix: "CoopGrade_Pair_",
templateKey: "CoopGrade_Normal_",
lookupDict: "CommonMsg/Coop/CoopStageName",
},
{
prefix: "CoopGrade_Underground_",
templateKey: "CoopGrade_Normal_",
lookupDict: "CommonMsg/Coop/CoopStageName",
},
{
prefix: "CoopBossKillNum_",
templateKey: "CoopBossKillNum_",
lookupDict: "CommonMsg/Coop/CoopEnemy",
},
{
prefix: "CoopRareEnemyKillNum_",
templateKey: "CoopRareEnemyKillNum_",
lookupDict: "CommonMsg/Coop/CoopEnemy",
},
{
prefix: "GearTotalRarity_",
templateKey: "GearTotalRarity_",
lookupDict: "CommonMsg/Gear/GearBrandName",
},
{
prefix: "WinCount_WeaponSp_",
templateKey: "WinCount_WeaponSp_",
lookupDict: "CommonMsg/Weapon/WeaponName_Special",
},
{
prefix: "WeaponLevel_",
templateKey: "WeaponLevel_",
lookupDict: "CommonMsg/Weapon/WeaponName_Main",
},
];
// ⚠️ keep same order as https://github.com/IPLSplatoon/IPLMapGen2/blob/main/data.js
const stages = [
"Scorch Gorge",
"Eeltail Alley",
"Hagglefish Market",
"Undertow Spillway",
"Mincemeat Metalworks",
"Hammerhead Bridge",
"Museum d'Alfonsino",
"Mahi-Mahi Resort",
"Inkblot Art Academy",
"Sturgeon Shipyard",
"MakoMart",
"Wahoo World",
"Flounder Heights",
"Brinewater Springs",
"Manta Maria",
"Um'ami Ruins",
"Humpback Pump Track",
"Barnacle & Dime",
"Crableg Capital",
"Shipshape Cargo Co.",
"Bluefin Depot",
"Robo ROM-en",
"Marlin Airport",
"Lemuria Hub",
"Urchin Underpass",
] as const;
const abilityShortToInternalName = new Map([
["ISM", "MainInk_Save"],
["ISS", "SubInk_Save"],
["IRU", "InkRecovery_Up"],
["RSU", "HumanMove_Up"],
["SSU", "SquidMove_Up"],
["SCU", "SpecialIncrease_Up"],
["SS", "RespawnSpecialGauge_Save"],
["SPU", "SpecialSpec_Up"],
["QR", "RespawnTime_Save"],
["QSJ", "JumpTime_Save"],
["BRU", "SubSpec_Up"],
["RES", "OpInkEffect_Reduction"],
["SRU", "SubEffect_Reduction"],
["IA", "Action_Up"],
["OG", "StartAllUp"],
["LDE", "EndAllUp"],
["T", "MinorityUp"],
["CB", "ComeBack"],
["NS", "SquidMoveSpatter_Reduction"],
["H", "DeathMarking"],
["TI", "ThermalInk"],
["RP", "Exorcist"],
["AD", "ExSkillDouble"],
["SJ", "SuperJumpSign_Hide"],
["OS", "ObjectEffect_Up"],
["DR", "SomersaultLanding"],
]);
async function main() {
const langDicts = await loadLangDicts();
const englishLangDict = langDicts.find(
([langCode]) => langCode === "EUen",
)?.[1];
invariant(englishLangDict);
const codeNames = stages.map((stage) => {
const codeName = Object.entries(
englishLangDict["CommonMsg/VS/VSStageName"],
).find(([_key, value]) => value === stage)?.[0];
invariant(codeName, `Could not find code name for stage ${stage}`);
return codeName;
});
for (const langCode of LANG_JSONS_TO_CREATE) {
const langDict = langDicts.find(([code]) => code === langCode)?.[1];
invariant(langDict, `Missing translations for ${langCode}`);
const translationsMap = Object.fromEntries(
stages.map((_, i) => {
const codeName = codeNames[
i
] as keyof (typeof langDict)["CommonMsg/VS/VSStageName"];
invariant(codeName);
return [`STAGE_${i}`, langDict["CommonMsg/VS/VSStageName"][codeName]];
}),
);
for (const ability of abilitiesShort) {
const internalName = abilityShortToInternalName.get(ability);
invariant(internalName, `Missing internal name for ${ability}`);
const translation = decodeURIComponent(
(langDict["CommonMsg/Gear/GearPowerName"] as Record<string, string>)[
internalName
],
);
translationsMap[`ABILITY_${ability}`] = translation;
}
for (const brandCode of brandIds) {
const translation = decodeURIComponent(
langDict["CommonMsg/Gear/GearBrandName"][brandCode],
);
translationsMap[`BRAND_${brandCode}`] = translation;
}
const jsonPath = path.join(
__dirname,
"..",
"locales",
translationJsonFolderName(langCode),
"game-misc.json",
);
const jsonCurrentContents = fs.readFileSync(jsonPath, "utf-8");
const jsonCurrent = JSON.parse(jsonCurrentContents);
fs.writeFileSync(
path.join(
__dirname,
"..",
"locales",
translationJsonFolderName(langCode),
"game-misc.json",
),
`${JSON.stringify({ ...jsonCurrent, ...translationsMap }, null, 2)}\n`,
);
}
generateBadgeData(langDicts);
}
function generateBadgeData(langDicts: LangDicts) {
const badgeFiles = fs
.readdirSync(BADGE_DIR)
.filter((f) => f.endsWith(".png"));
const badgeIds = badgeFiles
.map((f) => f.replace("Badge_", "").replace(".png", ""))
.sort();
const englishDict = langDicts.find(([code]) => code === "EUen")?.[1];
invariant(englishDict, "Missing English language dict");
const englishBadgeTranslations = buildBadgeTranslations(
badgeIds,
englishDict,
);
for (const langCode of LANG_JSONS_TO_CREATE) {
const langDict = langDicts.find(([code]) => code === langCode)?.[1];
invariant(langDict, `Missing translations for ${langCode}`);
const translationsMap = buildBadgeTranslations(badgeIds, langDict);
const folder = translationJsonFolderName(langCode);
writeBadgeJson(folder, translationsMap);
}
for (const folder of ALL_LOCALE_FOLDERS) {
const jsonPath = path.join(
__dirname,
"..",
"locales",
folder,
"game-badges.json",
);
if (fs.existsSync(jsonPath)) continue;
writeBadgeJson(folder, englishBadgeTranslations);
}
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
const tsContent = [
"export const GAME_BADGE_IDS = [",
...badgeIds.map((id) => `\t"${id}",`),
"] as const;",
"",
"export type GameBadgeId = (typeof GAME_BADGE_IDS)[number];",
"",
].join("\n");
fs.writeFileSync(path.join(OUTPUT_DIR, "game-badge-ids.ts"), tsContent);
}
function buildBadgeTranslations(badgeIds: string[], langDict: LangDict) {
const badgeMsg = langDict["CommonMsg/Badge/BadgeMsg"] as Record<
string,
string
>;
const translationsMap: Record<string, string> = {};
for (const id of badgeIds) {
if (badgeMsg[id] && !badgeMsg[id].includes("[group=")) {
translationsMap[id] = badgeMsg[id];
continue;
}
const lvMatch = id.match(/_Lv(\d+)$/);
if (!lvMatch) {
translationsMap[id] = badgeMsg[id] ?? id;
continue;
}
const lvSuffix = `Lv${lvMatch[1]}`;
const rule = BADGE_TEMPLATE_RULES.find((r) => id.startsWith(r.prefix));
if (!rule) {
translationsMap[id] = badgeMsg[id] ?? id;
continue;
}
const variantName = id
.slice(rule.prefix.length)
.replace(`_${lvSuffix}`, "");
const templateKey = `${rule.templateKey}${lvSuffix}`;
const template = badgeMsg[templateKey];
if (!template) {
translationsMap[id] = id;
continue;
}
const lookupValue = (
langDict as unknown as Record<string, Record<string, string> | undefined>
)[rule.lookupDict]?.[variantName];
if (!lookupValue) {
translationsMap[id] = id;
continue;
}
translationsMap[id] = template.replace(
/\[group=\d+ type=\w+ params=[\w ]+\]/,
lookupValue,
);
}
return translationsMap;
}
function writeBadgeJson(
folder: string,
translationsMap: Record<string, string>,
) {
fs.writeFileSync(
path.join(__dirname, "..", "locales", folder, "game-badges.json"),
`${JSON.stringify(translationsMap, null, 2)}\n`,
);
}
void main();

View File

@@ -0,0 +1,289 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DAMAGE_RECEIVERS } from "~/features/object-damage-calculator/calculator-constants";
import type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import {
mainWeaponIds,
specialWeaponIds,
subWeaponIds,
} from "~/modules/in-game-lists/weapon-ids";
// To run this script drop the https://github.com/Leanny/splat3 repo into scripts/dicts/splat3
import {
loadDamageRateInfo,
loadWeaponInfoMain,
loadWeaponInfoSpecial,
loadWeaponInfoSub,
PARAMETER_DIR,
} from "./utils";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const params = loadDamageRateInfo();
const weapons = loadWeaponInfoMain();
const subWeapons = loadWeaponInfoSub();
const specialWeapons = loadWeaponInfoSpecial();
const OBJECT_DMG_OUTPUT_PATH = path.join(
__dirname,
"..",
"app",
"features",
"object-damage-calculator",
"data",
"object-dmg.json",
);
const DAMAGE_RATE_CONFIG_FILE_NAME =
"spl__DamageRateInfoConfig.pp__CombinationDataTableData.json";
const HISTORY_OUTPUT_PATH = path.join(
__dirname,
"..",
"app",
"features",
"params",
"data",
"damage-rate-history.json",
);
type DamageReceiver = (typeof DAMAGE_RECEIVERS)[number];
type ResultEntry = {
mainWeaponIds: MainWeaponId[];
subWeaponIds: SubWeaponId[];
specialWeaponIds: SpecialWeaponId[];
rates: { target: string; rate: number }[];
};
type DamageRateCell = {
ColumnKey: string;
RowKey: string;
DamageRate?: number;
};
type DamageRateConfig = { CellList: Record<string, DamageRateCell> };
type TargetHistory = {
target: string;
current: number;
history: Array<{ version: string; value: number }>;
};
type DamageRateHistoryRow = {
mainWeaponIds: MainWeaponId[];
subWeaponIds: SubWeaponId[];
specialWeaponIds: SpecialWeaponId[];
targets: TargetHistory[];
};
const weaponParamsToWeaponIds = (
params: typeof weapons | typeof subWeapons | typeof specialWeapons,
key: string,
) => {
return params
.filter((param) => {
return (
param.DefaultDamageRateInfoRow === key ||
param.ExtraDamageRateInfoRowSet?.some(
(row) => row.DamageRateInfoRow === key,
)
);
})
.map((weapon) => weapon.Id);
};
const isDamageReceiver = (key: string): key is DamageReceiver =>
(DAMAGE_RECEIVERS as readonly string[]).includes(key);
const mainIdsForRow = (rowKey: string) =>
weaponParamsToWeaponIds(weapons, rowKey).filter((id): id is MainWeaponId =>
(mainWeaponIds as readonly number[]).includes(id),
);
const subIdsForRow = (rowKey: string) =>
weaponParamsToWeaponIds(subWeapons, rowKey).filter((id): id is SubWeaponId =>
(subWeaponIds as readonly number[]).includes(id),
);
const specialIdsForRow = (rowKey: string) =>
weaponParamsToWeaponIds(specialWeapons, rowKey).filter(
(id): id is SpecialWeaponId =>
(specialWeaponIds as readonly number[]).includes(id),
);
/**
* Resolves the per-target damage rate of every damage rate info row in a single config dump.
* Only the PvP-relevant receivers are kept and the synthetic launched/Recycled Brella canopy
* targets are derived the same way the live object damage calculator expects them.
*/
const damageRatesByRow = (
config: DamageRateConfig,
): Map<string, Map<string, number>> => {
const result = new Map<string, Map<string, number>>();
for (const cell of Object.values(config.CellList)) {
if (!isDamageReceiver(cell.ColumnKey)) continue;
if (!cell.DamageRate) continue;
let row = result.get(cell.RowKey);
if (!row) {
row = new Map();
result.set(cell.RowKey, row);
}
row.set(cell.ColumnKey, cell.DamageRate);
// launched versions have double health but share the same rate
if (
cell.ColumnKey.includes("BulletUmbrellaCanopyNormal") ||
cell.ColumnKey.includes("BulletUmbrellaCanopyWide")
) {
row.set(`${cell.ColumnKey}_Launched`, cell.DamageRate);
}
// Recycled Brella reuses Splat Brella's special damage rates
if (cell.ColumnKey === "BulletUmbrellaCanopyNormal") {
row.set("BulletShelterCanopyFocus", cell.DamageRate);
row.set("BulletShelterCanopyFocus_Launched", cell.DamageRate);
}
}
return result;
};
const result: Record<string, ResultEntry | undefined> = {};
for (const [rowKey, rates] of damageRatesByRow(params)) {
const mainWeaponIdsForRow = mainIdsForRow(rowKey);
const subWeaponIdsForRow = subIdsForRow(rowKey);
const specialWeaponIdsForRow = specialIdsForRow(rowKey);
// if it applies to no PvP weapons, we don't care about it
if (
mainWeaponIdsForRow.length === 0 &&
subWeaponIdsForRow.length === 0 &&
specialWeaponIdsForRow.length === 0 &&
rowKey !== "ObjectEffect_Up"
) {
continue;
}
result[rowKey] = {
mainWeaponIds: mainWeaponIdsForRow,
subWeaponIds: subWeaponIdsForRow,
specialWeaponIds: specialWeaponIdsForRow,
rates: [...rates].map(([target, rate]) => ({ target, rate })),
};
}
fs.writeFileSync(OBJECT_DMG_OUTPUT_PATH, JSON.stringify(result, null, 2));
writeDamageRateHistory();
function versionDirToDisplay(version: string): string {
const num = Number.parseInt(version, 10);
const major = Math.floor(num / 100);
const minor = Math.floor((num % 100) / 10);
const patch = num % 10;
return `${major}.${minor}.${patch}`;
}
/**
* Builds the per-row, per-target damage rate history across every versioned config dump and
* writes it for the params page to surface in its patch history. Only PvP-relevant rows and
* only targets whose rate actually changed at some point are kept, so the output stays small.
*/
function writeDamageRateHistory() {
const versionDirs = fs
.readdirSync(PARAMETER_DIR)
.filter((dir) => /^\d+$/.test(dir))
.sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10));
const ratesByVersion = new Map<string, Map<string, Map<string, number>>>();
for (const version of versionDirs) {
const filePath = path.join(
PARAMETER_DIR,
version,
"misc",
DAMAGE_RATE_CONFIG_FILE_NAME,
);
if (!fs.existsSync(filePath)) continue;
const config = JSON.parse(
fs.readFileSync(filePath, "utf8"),
) as DamageRateConfig;
ratesByVersion.set(version, damageRatesByRow(config));
}
const presentVersions = versionDirs.filter((version) =>
ratesByVersion.has(version),
);
const latestVersion = presentVersions[presentVersions.length - 1];
const rows: Record<string, DamageRateHistoryRow> = {};
for (const [rowKey, latestRates] of ratesByVersion.get(latestVersion) ?? []) {
const mainWeaponIdsForRow = mainIdsForRow(rowKey);
const subWeaponIdsForRow = subIdsForRow(rowKey);
const specialWeaponIdsForRow = specialIdsForRow(rowKey);
if (
mainWeaponIdsForRow.length === 0 &&
subWeaponIdsForRow.length === 0 &&
specialWeaponIdsForRow.length === 0
) {
continue;
}
const targets: TargetHistory[] = [];
for (const [target, current] of latestRates) {
const presentForTarget = presentVersions.filter(
(version) =>
ratesByVersion.get(version)?.get(rowKey)?.get(target) !== undefined,
);
const history: Array<{ version: string; value: number }> = [];
for (let i = 0; i < presentForTarget.length - 1; i++) {
const value = ratesByVersion
.get(presentForTarget[i])!
.get(rowKey)!
.get(target)!;
const nextValue = ratesByVersion
.get(presentForTarget[i + 1])!
.get(rowKey)!
.get(target)!;
if (value !== nextValue) {
history.push({
version: versionDirToDisplay(presentForTarget[i]),
value,
});
}
}
if (history.length > 0) {
targets.push({ target, current, history });
}
}
if (targets.length > 0) {
rows[rowKey] = {
mainWeaponIds: mainWeaponIdsForRow,
subWeaponIds: subWeaponIdsForRow,
specialWeaponIds: specialWeaponIdsForRow,
targets,
};
}
}
fs.writeFileSync(
HISTORY_OUTPUT_PATH,
JSON.stringify(
{
metadata: { versions: presentVersions.map(versionDirToDisplay) },
rows,
},
null,
2,
),
);
}

View File

@@ -0,0 +1,27 @@
import { db } from "~/db/sql";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const rawId = process.argv[2]?.trim();
invariant(rawId, "id of badge is required (argument 1)");
const id = Number(rawId);
invariant(!Number.isNaN(id), "id must be a number");
async function main() {
const owners = await db
.selectFrom("BadgeOwner")
.select(["badgeId"])
.where("BadgeOwner.badgeId", "=", id)
.execute();
invariant(owners.length === 0, "Badge is still owned by someone");
await db.deleteFrom("Badge").where("id", "=", id).execute();
logger.info("done with deleting the badge");
}
void main();

View File

@@ -0,0 +1,22 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const pathToDbFile = (file) => path.resolve(__dirname, "..", file);
const filesToDeleteIfExists = [
"db.sqlite3",
"db.sqlite3-shm",
"db.sqlite3-wal",
];
for (const file of filesToDeleteIfExists) {
try {
fs.unlinkSync(pathToDbFile(file));
} catch (err) {
// if file doesn't exist err.code = ENOENT is thrown
if (err.code !== "ENOENT") throw err;
}
}

View File

@@ -0,0 +1,16 @@
import { db } from "~/db/sql";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const discordId = process.argv[2]?.trim();
invariant(discordId, "discord id is required (argument 1)");
await db
.deleteFrom("Skill")
.where("userId", "in", (eb) =>
eb.selectFrom("User").select("User.id").where("discordId", "=", discordId),
)
.execute();
logger.info(`Deleted skill of user with discord id: ${discordId}`);

View File

@@ -0,0 +1,23 @@
import { db } from "~/db/sql";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const discordId = process.argv[2]?.trim();
invariant(discordId, "discord id is required (argument 1)");
const user = await db
.selectFrom("User")
.select("id")
.where("discordId", "=", discordId)
.executeTakeFirst();
invariant(user, `user with discord id ${discordId} not found`);
const userId = user.id;
await db.deleteFrom("Build").where("ownerId", "=", userId).execute();
await db.deleteFrom("UserWeapon").where("userId", "=", userId).execute();
await db.deleteFrom("User").where("id", "=", userId).execute();
logger.info(`Deleted user with discord id: ${discordId}`);

View File

View File

@@ -0,0 +1,134 @@
import { execSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.join(__dirname, "..");
const MIGRATIONS_DIR = path.join(ROOT_DIR, "migrations");
const TEST_DB_PATH = path.join(ROOT_DIR, "db-test.sqlite3");
export function setup() {
ensureMigratedDb(TEST_DB_PATH);
// Test workers only ever read this file, to copy its schema into their own
// in-memory database. Taking it out of WAL drops the -wal/-shm sidecars, so
// those concurrent opens need no write access and cannot contend.
const db = new DatabaseSync(TEST_DB_PATH);
db.exec("PRAGMA journal_mode = DELETE");
db.close();
}
/**
* Ensures the SQLite file at `dbPath` has every migration applied: creates it
* if missing, applies pending migrations, and rebuilds it from scratch if an
* already applied migration no longer exists on disk or its contents changed
* since it was applied.
*
* Rebuilding on changed contents matters because a branch edits the migration
* it added rather than stacking a new one, so the file name kysely tracks stays
* the same while the schema it produces does not.
*/
export function ensureMigratedDb(dbPath: string) {
const resolvedPath = path.resolve(ROOT_DIR, dbPath);
if (!fs.existsSync(resolvedPath)) {
migrateUp(resolvedPath);
return;
}
const applied = appliedMigrations(resolvedPath);
const onDisk = migrationFilesOnDisk();
const hasDrift =
applied === null ||
applied.some((name) => !onDisk.has(name)) ||
readContentsMarker(resolvedPath) !== migrationContentsHash();
if (hasDrift) {
deleteDbFiles(resolvedPath);
migrateUp(resolvedPath);
return;
}
const appliedSet = new Set(applied);
const hasPending = [...onDisk].some((name) => !appliedSet.has(name));
if (hasPending) {
migrateUp(resolvedPath);
}
}
/** Fingerprint of every migration's name and contents. */
function migrationContentsHash() {
const hash = createHash("sha256");
for (const file of fs.readdirSync(MIGRATIONS_DIR).sort()) {
hash.update(file);
hash.update(fs.readFileSync(path.join(MIGRATIONS_DIR, file)));
}
return hash.digest("hex");
}
/** Sidecar recording the migration contents the database was built from. */
function contentsMarkerPath(dbPath: string) {
return `${dbPath}.migrations`;
}
function readContentsMarker(dbPath: string) {
try {
return fs.readFileSync(contentsMarkerPath(dbPath), "utf8");
} catch {
return null;
}
}
function migrationFilesOnDisk() {
return new Set(
fs
.readdirSync(MIGRATIONS_DIR)
.filter((file) => file.endsWith(".ts"))
// kysely tracks migrations by file name without the extension
.map((file) => file.slice(0, -".ts".length)),
);
}
function appliedMigrations(dbPath: string) {
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
const hasMigrationsTable = db
.prepare(
"select 1 from sqlite_master where type = 'table' and name = 'kysely_migration'",
)
.get();
if (!hasMigrationsTable) return null;
const rows = db
.prepare("select name from kysely_migration")
.all() as Array<{
name: string;
}>;
return rows.map((row) => row.name);
} catch {
return null;
} finally {
db.close();
}
}
function deleteDbFiles(dbPath: string) {
for (const suffix of ["", "-shm", "-wal"]) {
fs.rmSync(`${dbPath}${suffix}`, { force: true });
}
fs.rmSync(contentsMarkerPath(dbPath), { force: true });
}
function migrateUp(dbPath: string) {
execSync("pnpm run migrate up", {
cwd: ROOT_DIR,
stdio: "inherit",
env: { ...process.env, DB_PATH: dbPath },
});
fs.writeFileSync(contentsMarkerPath(dbPath), migrationContentsHash());
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
import { db } from "~/db/sql";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
async function main() {
const name = process.argv[2]?.trim();
const orgIdRaw = process.argv[3]?.trim();
const orgId = Number(orgIdRaw);
invariant(name, "name of tournament is required (argument 1)");
invariant(orgIdRaw, "org id is required (argument 2)");
invariant(!Number.isNaN(orgId), "org id must be a number");
await db
.selectFrom("TournamentOrganization")
.select(["TournamentOrganization.id"])
.where("id", "=", orgId)
.executeTakeFirstOrThrow();
const allEvents = await db
.selectFrom("CalendarEvent")
.select(["CalendarEvent.name", "CalendarEvent.id"])
.execute();
const filtered = allEvents.filter((event) =>
event.name.toLowerCase().includes(name.toLowerCase()),
);
await db
.updateTable("CalendarEvent")
.set({ organizationId: orgId })
.where(
"id",
"in",
filtered.map((event) => event.id),
)
.execute();
logger.info(`Added ${filtered.length} tournaments to organization ${orgId}`);
}
main();

View File

@@ -0,0 +1,80 @@
import fs from "node:fs/promises";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Kysely } from "kysely";
import { FileMigrationProvider, Migrator } from "kysely/migration";
import { NodeSqliteDialect } from "../app/db/node-sqlite-dialect.ts";
const MIGRATION_FOLDER = fileURLToPath(
new URL("../migrations", import.meta.url),
);
try {
process.loadEnvFile();
} catch {
// .env is optional; in production DB_PATH comes from the host environment
}
async function main() {
const command = process.argv[2] ?? "up";
if (command !== "up") {
throw new Error(
`Unknown command "${command}". Only "up" is supported; migrations are never rolled back.`,
);
}
const dbPath = process.env.DB_PATH;
if (!dbPath) {
throw new Error("DB_PATH is not set");
}
const database = new DatabaseSync(dbPath);
database.exec("PRAGMA journal_mode = WAL");
database.exec("PRAGMA foreign_keys = ON");
database.exec("PRAGMA busy_timeout = 5000");
const db = new Kysely<any>({
dialect: new NodeSqliteDialect({ database }),
});
const migrator = new Migrator({
db,
allowUnorderedMigrations: true,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: MIGRATION_FOLDER,
import: (filePath) => import(pathToFileURL(filePath).href),
}),
});
const { error, results } = await migrator.migrateToLatest();
for (const result of results ?? []) {
if (result.status === "Success") {
log(`↑ ${result.migrationName}`);
} else if (result.status === "Error") {
log(`✗ ${result.migrationName}`);
}
}
await db.destroy();
if (error) throw error;
if (!results?.length) {
log(`No pending migrations for ${dbPath}`);
}
}
function log(message: string) {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(message);
}
main().catch((error) => {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,76 @@
import { db } from "~/db/sql";
import * as Seasons from "~/features/mmr/core/Seasons";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const discordId = process.argv[2]?.trim();
invariant(discordId, "discord id is required (argument 1)");
async function main() {
const currentSeason = Seasons.current();
if (!currentSeason) {
logger.info("No current season found");
return;
}
const user = await db
.selectFrom("User")
.select(["User.id"])
.where("User.discordId", "=", discordId)
.executeTakeFirstOrThrow();
const playedMaps = await db
.selectFrom("GroupMember")
.innerJoin("Group", "Group.id", "GroupMember.groupId")
.innerJoin("GroupMatch", (join) =>
join.on((eb) =>
eb.or([
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
]),
),
)
.innerJoin("GroupMatchMap", "GroupMatchMap.matchId", "GroupMatch.id")
.select(["GroupMatchMap.matchId", "GroupMatchMap.index"])
.where(
"GroupMatch.createdAt",
">",
dateToDatabaseTimestamp(currentSeason.starts),
)
.where(
"GroupMatch.createdAt",
"<",
dateToDatabaseTimestamp(currentSeason.ends),
)
.where("GroupMember.userId", "=", user.id)
.where("GroupMatchMap.winnerGroupId", "is not", null)
.execute();
if (playedMaps.length === 0) {
logger.info(`No reported weapons to delete for user ${discordId}`);
return;
}
await db
.deleteFrom("ReportedWeapon")
.where("userId", "=", user.id)
.where((eb) =>
eb.or(
playedMaps.map((m) =>
eb.and([
eb("ReportedWeapon.groupMatchId", "=", m.matchId),
eb("ReportedWeapon.mapIndex", "=", m.index),
]),
),
),
)
.execute();
logger.info(
`Deleted reported weapons across ${playedMaps.length} maps for user ${discordId}`,
);
}
void main();

View File

@@ -0,0 +1,190 @@
// calculates org stats for the last 6 finished months
// used to decide which orgs are considered "established"
// you need at least 150 average monthly active players to be considered established
// if you drop below 100 you lose it
import { db } from "~/db/sql";
import { logger } from "~/utils/logger";
interface MonthData {
year: number;
month: number;
label: string;
startTimestamp: number;
endTimestamp: number;
}
interface OrgMonthlyData {
orgId: number;
orgName: string;
monthlyParticipants: number[];
totalUniqueParticipants: number;
averageMonthlyParticipants: number;
}
function getLastSixFinishedMonths(): MonthData[] {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth();
const months: MonthData[] = [];
for (let i = 1; i <= 6; i++) {
const date = new Date(currentYear, currentMonth - i, 1);
const year = date.getFullYear();
const month = date.getMonth();
const startTimestamp = Math.floor(
new Date(year, month, 1).getTime() / 1000,
);
const endTimestamp = Math.floor(
new Date(year, month + 1, 1).getTime() / 1000,
);
months.push({
year,
month,
label: date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
}),
startTimestamp,
endTimestamp,
});
}
return months;
}
async function getParticipantsForOrgInMonth(
organizationId: number,
startTimestamp: number,
endTimestamp: number,
): Promise<number> {
const result = await db
.selectFrom("CalendarEvent as ce")
.innerJoin("CalendarEventDate as ced", "ced.eventId", "ce.id")
.innerJoin("Tournament as t", "t.id", "ce.tournamentId")
.innerJoin("TournamentTeam as tt", "tt.tournamentId", "t.id")
.innerJoin(
"TournamentTeamCheckIn as ttci",
"ttci.tournamentTeamId",
"tt.id",
)
.innerJoin(
"TournamentMatchGameResultParticipant as tmgrp",
"tmgrp.tournamentTeamId",
"tt.id",
)
.select(({ fn }) => fn.count<number>("tmgrp.userId").distinct().as("count"))
.where("ce.organizationId", "=", organizationId)
.where("ced.startsAt", ">=", startTimestamp)
.where("ced.startsAt", "<", endTimestamp)
.where("ttci.checkedInAt", "is not", null)
.where("ttci.isCheckOut", "=", 0)
.executeTakeFirst();
return result?.count ?? 0;
}
async function getOrgsWithRecentTournaments(
months: MonthData[],
): Promise<number[]> {
const earliestTimestamp = months[months.length - 1].startTimestamp;
const orgs = await db
.selectFrom("CalendarEvent as ce")
.innerJoin("CalendarEventDate as ced", "ced.eventId", "ce.id")
.select("ce.organizationId")
.distinct()
.where("ce.organizationId", "is not", null)
.where("ced.startsAt", ">=", earliestTimestamp)
.execute();
return orgs.map((org) => org.organizationId!);
}
async function main() {
const months = getLastSixFinishedMonths();
logger.info(
`Analyzing last 6 finished months: ${months.map((m) => m.label).join(", ")}\n`,
);
const orgIds = await getOrgsWithRecentTournaments(months);
logger.info(
`Found ${orgIds.length} organizations with tournaments in this period\n`,
);
const orgData: OrgMonthlyData[] = [];
for (const orgId of orgIds) {
const org = await db
.selectFrom("TournamentOrganization")
.select(["id", "name"])
.where("id", "=", orgId)
.executeTakeFirst();
if (!org) continue;
const monthlyParticipants: number[] = [];
for (const month of months) {
const count = await getParticipantsForOrgInMonth(
orgId,
month.startTimestamp,
month.endTimestamp,
);
monthlyParticipants.push(count);
}
const totalUniqueParticipants = monthlyParticipants.reduce(
(sum, count) => sum + count,
0,
);
const averageMonthlyParticipants = totalUniqueParticipants / 6;
orgData.push({
orgId: org.id,
orgName: org.name,
monthlyParticipants,
totalUniqueParticipants,
averageMonthlyParticipants,
});
}
orgData.sort((a, b) => {
if (b.averageMonthlyParticipants !== a.averageMonthlyParticipants) {
return b.averageMonthlyParticipants - a.averageMonthlyParticipants;
}
return b.totalUniqueParticipants - a.totalUniqueParticipants;
});
logger.info("=".repeat(80));
logger.info("ORGANIZATION MONTHLY ACTIVE PLAYERS REPORT");
logger.info("=".repeat(80));
logger.info();
const headers = ["Org", "Avg", ...months.map((m) => m.label)];
logger.info(headers.join(" | "));
logger.info("-".repeat(80));
for (const org of orgData) {
const monthValues = org.monthlyParticipants.map((count) =>
count === 0 ? "-" : count.toString(),
);
const row = [
org.orgName,
org.averageMonthlyParticipants.toFixed(1),
...monthValues,
];
logger.info(row.join(" | "));
}
logger.info();
logger.info(`Total organizations: ${orgData.length}`);
}
main().catch((err) => {
logger.error("Error in org-monthly-active-players.ts", err);
process.exit(1);
});

View File

View File

@@ -0,0 +1,139 @@
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { xRankSchema } from "./schemas";
const rawJsonNumber = process.argv[2]?.trim();
invariant(rawJsonNumber, "jsonNumber is required (argument 1)");
const jsonNumber = Number(rawJsonNumber);
invariant(
Number.isInteger(jsonNumber),
"jsonNumber must be an integer (argument 1)",
);
type Placements = XRankPlacementRepository.XRankPlacementInsertArgs[];
const modes = ["splatzones", "towercontrol", "rainmaker", "clamblitz"] as const;
const modeToShort = {
splatzones: "SZ",
towercontrol: "TC",
rainmaker: "RM",
clamblitz: "CB",
} as const;
const regions = ["a", "p"] as const;
void main();
async function main() {
const placements: Placements = [];
await XRankPlacementRepository.deleteAllByMonthYear(
resolveMonthYear(jsonNumber),
);
for (const mode of modes) {
for (const region of regions) {
for (const includeWeapon of [false]) {
placements.push(
...(await processJson({
includeWeapon,
mode,
region,
number: jsonNumber,
})),
);
}
}
}
await XRankPlacementRepository.insertMany(placements);
await XRankPlacementRepository.refreshAllPeakXp();
await BadgeRepository.syncXPBadges();
await BuildRepository.recalculateAllSortValues();
await XRankPlacementRepository.refreshTenStarWeapons();
logger.info(`done reading in ${placements.length} placements`);
}
async function processJson(args: {
mode: (typeof modes)[number];
region: (typeof regions)[number];
includeWeapon: boolean;
number: number;
}) {
const result: Placements = [];
const url = `https://splatoon3.ink/data/xrank/xrank.detail.${args.region}-${
args.number
}.${args.mode}${args.includeWeapon ? ".weapons" : ""}.json`;
logger.info(`reading in ${url}...`);
const json = await fetch(url).then((res) => res.json());
const validated = xRankSchema.parse(json);
const array =
validated.data.node.xRankingAr ??
validated.data.node.xRankingCl ??
validated.data.node.xRankingLf ??
validated.data.node.xRankingGl;
invariant(array, "array is null");
for (const { node: placement } of array.edges) {
const weaponId = Number(atob(placement.weapon.id).replace("Weapon-", ""));
if (!mainWeaponIds.includes(weaponId as MainWeaponId)) {
throw new Error(`Invalid weapon ID: ${weaponId}`);
}
const { month, year } = resolveMonthYear(args.number);
result.push({
name: placement.name,
badges: placement.nameplate.badges
.map((badge) => (badge ? atob(badge.id).replace("Badge-", "") : "null"))
.join(","),
bannerSplId: Number(
atob(placement.nameplate.background.id).replace(
"NameplateBackground-",
"",
),
),
nameDiscriminator: placement.nameId,
power: placement.xPower,
rank: placement.rank,
region: args.region === "p" ? "JPN" : "WEST",
title: placement.byname,
weaponSplId: weaponId as MainWeaponId,
month,
year,
mode: modeToShort[args.mode],
playerSplId: parsePlayerId(placement.id),
});
}
return result;
}
function parsePlayerId(encoded: string) {
const parts = atob(encoded).split("-");
const last = parts[parts.length - 1];
invariant(last, "last is null");
return last;
}
function resolveMonthYear(number: number) {
const start = new Date("2023-03-15");
// 2 is the first X Rank month
// 3 is the length of x rank season
const monthsToAdd = (number - 2) * 3;
start.setMonth(start.getMonth() + monthsToAdd);
return {
month: start.getMonth() + 1,
year: start.getFullYear(),
};
}

View File

@@ -0,0 +1,76 @@
import { z } from "zod";
const placements = z.object({
edges: z.array(
z.object({
node: z.object({
id: z.string(),
name: z.string(),
rank: z.number(),
rankDiff: z.union([z.string(), z.null()]),
xPower: z.number(),
weapon: z.object({
name: z.string(),
image: z.object({ url: z.string() }),
id: z.string(),
image3d: z.object({ url: z.string() }),
image2d: z.object({ url: z.string() }),
image3dThumbnail: z.object({ url: z.string() }),
image2dThumbnail: z.object({ url: z.string() }),
subWeapon: z.object({
name: z.string(),
image: z.object({ url: z.string() }),
id: z.string(),
}),
specialWeapon: z.object({
name: z.string(),
image: z.object({ url: z.string() }),
id: z.string(),
}),
}),
weaponTop: z.boolean(),
__isPlayer: z.string(),
byname: z.string(),
nameId: z.string(),
nameplate: z.object({
badges: z.array(
z.union([
z.object({
image: z.object({ url: z.string() }),
id: z.string(),
}),
z.null(),
]),
),
background: z.object({
textColor: z.object({
a: z.number(),
b: z.number(),
g: z.number(),
r: z.number(),
}),
image: z.object({ url: z.string() }),
id: z.string(),
}),
}),
__typename: z.string(),
}),
cursor: z.string(),
}),
),
pageInfo: z.object({ endCursor: z.string(), hasNextPage: z.boolean() }),
});
// e.g. https://splatoon3.ink/data/xrank/xrank.detail.a-2.clamblitz.json
export const xRankSchema = z.object({
data: z.object({
node: z.object({
__typename: z.string(),
xRankingAr: placements.optional(),
xRankingCl: placements.optional(),
xRankingLf: placements.optional(),
xRankingGl: placements.optional(),
id: z.string(),
}),
}),
});

View File

@@ -0,0 +1,40 @@
/** biome-ignore-all lint/suspicious/noConsole: Biome v2 migration */
import fs from "node:fs";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import path from "node:path";
function main() {
const dbProdPath = path.join(__dirname, "..", "db-prod.sqlite3");
const dbProdShmPath = path.join(__dirname, "..", "db-prod.sqlite3-shm");
const dbProdWalPath = path.join(__dirname, "..", "db-prod.sqlite3-wal");
const dbCopyPath = path.join(__dirname, "..", "db-copy.sqlite3");
if (!fs.existsSync(dbCopyPath)) {
console.error(`File ${dbCopyPath} does not exist`);
process.exit(1);
}
// delete db-prod.sqlite3-shm file if exists
if (fs.existsSync(dbProdShmPath)) {
fs.unlinkSync(dbProdShmPath);
}
// delete db-prod.sqlite3-wal file if exists
if (fs.existsSync(dbProdWalPath)) {
fs.unlinkSync(dbProdWalPath);
}
// delete db-prod.sqlite3 if exists
if (fs.existsSync(dbProdPath)) {
fs.unlinkSync(dbProdPath);
}
// copy db-copy.sqlite3 to db-prod.sqlite3
fs.copyFileSync(dbCopyPath, dbProdPath);
}
main();

View File

@@ -0,0 +1,21 @@
import { db } from "~/db/sql";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const id = process.argv[2]?.trim();
const newName = process.argv[3]?.trim();
invariant(id, "id of badge is required (argument 1)");
invariant(newName, "display name of badge is required (argument 2)");
invariant(
newName !== newName.toLocaleLowerCase(),
"displayName of badge must have at least one uppercase letter",
);
await db
.updateTable("Badge")
.set({ displayName: newName })
.where("id", "=", Number(id))
.execute();
logger.info(`Added updated name. New name: ${newName}`);

View File

@@ -0,0 +1,66 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const GEAR_IMAGES_DIR_PATH = path.join(
__dirname,
"..",
"public",
"static-assets",
"img",
"gear",
);
const GEAR_JSON_PATH = path.join(__dirname, "output", "gear.json");
async function main() {
const gear = JSON.parse(fs.readFileSync(GEAR_JSON_PATH, "utf8"));
for (const gearSlot of ["head", "clothes", "shoes"] as const) {
const gearSlotDirPath = path.join(GEAR_IMAGES_DIR_PATH, gearSlot);
const files = await fs.promises.readdir(gearSlotDirPath);
const type =
gearSlot === "head" ? "Hed" : gearSlot === "shoes" ? "Shs" : "Clt";
for (const file of files) {
// did we already replace the name
if (
!file.startsWith("Shs") &&
!file.startsWith("Clt") &&
!file.startsWith("Hed")
) {
continue;
}
if (file.endsWith(".webp")) {
fs.unlinkSync(path.join(gearSlotDirPath, file));
continue;
}
const internalName = file.replace(".png", "").split("_")[1];
invariant(internalName);
const gearId = gear.find(
(g: any) => g.internalName === internalName && g.type === type,
)?.id;
if (typeof gearId !== "number") {
fs.unlinkSync(path.join(gearSlotDirPath, file));
continue;
}
fs.renameSync(
path.join(gearSlotDirPath, file),
path.join(gearSlotDirPath, `${gearId}.png`),
);
}
}
logger.info("done with all");
}
void main();

View File

@@ -0,0 +1,100 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { logger } from "~/utils/logger";
import { loadWeaponInfoMain } from "./utils";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const weapons = loadWeaponInfoMain();
const DIR_PATH_1 = path.join(
__dirname,
"..",
"public",
"static-assets",
"img",
"main-weapons",
);
const DIR_PATH_2 = path.join(
__dirname,
"..",
"public",
"static-assets",
"img",
"main-weapons-outlined",
);
const DIR_PATH_3 = path.join(
__dirname,
"..",
"public",
"static-assets",
"img",
"main-weapons-outlined-2",
);
const DIR_PATH_4 = path.join(
__dirname,
"..",
"public",
"static-assets",
"img",
"main-weapons-outlined-3",
);
async function main() {
for (const [i, dir] of [
DIR_PATH_1,
DIR_PATH_2,
DIR_PATH_3,
DIR_PATH_4,
].entries()) {
const files = await fs.promises.readdir(dir);
for (const file of files) {
// skip if already replaced
if (file.length <= 8) continue;
const differentLevelBadge = (fileName: string) => {
if (i === 1 && fileName.includes("Lv01")) return true;
if (i === 2 && fileName.includes("Lv00")) return true;
if (i === 3 && fileName.includes("Lv00")) return true;
if (i === 3 && fileName.includes("Lv01")) return true;
// ver 10.0.0
if (fileName.includes("Lv02")) return true;
if (fileName.includes("Lv03")) return true;
if (fileName.includes("Lv04")) return true;
if (fileName.includes("Lv05")) return true;
if (i !== 3 && fileName.includes("Lv06")) return true;
return false;
};
if (file.includes(".webp") || differentLevelBadge(file)) {
await fs.promises.unlink(path.join(dir, file));
continue;
}
const weapon = (weapons as Array<{ __RowId: string; Id: number }>).find(
(weapon) =>
file.includes(`${weapon.__RowId}.`) ||
file.includes(`${weapon.__RowId}_`),
);
if (!weapon) {
await fs.promises.unlink(path.join(dir, file));
continue;
}
fs.renameSync(path.join(dir, file), path.join(dir, `${weapon.Id}.png`));
}
}
logger.info("done with all");
}
void main();

View File

@@ -0,0 +1,776 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Bootstrap glyph atlases from the labeled reference fixtures: slices glyph
* templates straight out of the reference frames, since the same scoreboard
* captured through different pipelines (OBS virtual camera 720p, game
* capture 1080p) yields subtly different pixels — every source contributes
* its own crop per character and recognition takes the best-scoring one.
* build-glyph-atlas.ts then fills in the rest of the charset from the
* fonts, preserving these fixture-tagged glyphs.
*
* Usage: pnpm scanner:bootstrap-atlas
* Writes public/assets/glyphs/scoreboard-{names,paint-digits,stat-digits}.{png,json}
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadOpenCV, type Mat } from "../../app/features/scanner/core/cv";
import {
TAG_NAME_INNER,
TAG_NAME_OUTER,
TAG_NAME_TEXT_HEIGHT,
TAG_TILT_DEG,
} from "../../app/features/scanner/core/detectors/death/rois";
import {
nameRoi,
paintRoi,
ROW_CENTERS,
statRoi,
TEAM_SCORE_ROIS,
} from "../../app/features/scanner/core/detectors/scoreboard/rois";
import {
CODE_TEXT_HEIGHT,
REPLAY_CODE_ROI,
} from "../../app/features/scanner/core/detectors/scoreboard-battle-log-replay/rois";
import type { AtlasMeta } from "../../app/features/scanner/core/glyphs";
import {
normalizeFrame,
type Roi,
toMat,
} from "../../app/features/scanner/core/image";
import { SCANNER_ASSETS_DIR } from "../../app/features/scanner/node/assets-dir";
import { FIXTURES_DIR } from "../../app/features/scanner/node/fixtures";
import { readImage, writePng } from "../../app/features/scanner/node/image-io";
const OUT_DIR = join(SCANNER_ASSETS_DIR, "glyphs");
const BIN_THRESHOLD = 150;
/** A labeled header tag region (positions are fixture-specific: tags size to their text). */
interface HeaderSpec {
roi: Roi;
label: string;
splitHints?: number[];
threshold?: number;
}
interface Source {
frame: string;
/** row labels, top to bottom; "" skips a row (e.g. unverifiable glyphs) */
names: string[];
paints: string[];
/** per row: [ka, deaths, specials], zero-padded 2 digits as rendered */
stats: string[][];
teamScores: string[];
/**
* Split hints are absolute x positions where merged segments must be cut
* (keyed by row index) — e.g. the "Te" in Teddy renders as one segment,
* with the exact boundary shifting a couple of pixels between sources.
*/
nameSplitHints: Record<number, number[]>;
/** omit to skip header harvest for this source */
header?: { lobby: HeaderSpec; mode: HeaderSpec; stage: HeaderSpec };
}
/** Ground-truth labels for the reference match (captured through two pipelines). */
const REFERENCE = {
names: [
"Pinhole",
"Sunshield",
"Headphones",
"Now or Never Seven",
"Charms",
"Teddy",
"Circle",
"Fleece",
],
paints: ["842", "1217", "1768", "693", "1422", "980", "1204", "1053"],
stats: [
["12", "07", "02"],
["07", "06", "03"],
["04", "04", "05"],
["04", "04", "02"],
["09", "06", "05"],
["07", "07", "05"],
["08", "06", "03"],
["05", "02", "04"],
],
/**
* Team totals render with an outline on the colored team box, so they get
* their own atlas rather than reusing paint digits. This fixture only
* contributes '5' and '0' — later fixtures extend the set (harvest keeps
* the first instance per char, so rerunning with more fixtures is additive).
*/
teamScores: ["500", "0"],
header: {
lobby: { roi: { x: 838, y: 44, w: 82, h: 26 }, label: "X Battle" },
// mode is bold, stage is regular — harvested separately so shared letters
// (e.g. both have an 'S') keep one template per face; bold text bridges
// at threshold 150, 170 separates all but "pl"
mode: {
roi: { x: 832, y: 88, w: 216, h: 40 },
label: "Splat Zones",
splitHints: [877],
threshold: 170,
},
stage: { roi: { x: 1078, y: 88, w: 160, h: 40 }, label: "Scorch Gorge" },
},
};
/** Labeled source frames; every source contributes its own crop per character. */
const SOURCES: Source[] = [
{
frame: "scoreboard/xbattle-splat-zones-ko/frame.jpg",
...REFERENCE,
nameSplitHints: { 5: [1137] },
},
{
frame: "scoreboard/xbattle-splat-zones-ko-capture/frame.png",
...REFERENCE,
nameSplitHints: { 5: [1135] },
},
{
frame: "scoreboard/private-battle-splat-zones-ko-kera/frame.png",
names: [
"Mongering",
"y0s",
"fuzzy",
"kera",
"sigma",
"Reefslider",
"Cucumber",
"tomato",
],
paints: ["503", "766", "624", "323", "500", "503", "427", "414"],
stats: [
["11", "01", "02"],
["08", "00", "03"],
["05", "02", "02"],
["05", "03", "00"],
["04", "03", "02"],
["03", "06", "01"],
["02", "05", "02"],
["01", "06", "01"],
],
teamScores: ["500", "0"],
nameSplitHints: {},
},
{
// This capture renders names slightly smaller than the reference: its
// 'T' (9x15) loses to the taller reference crops, and its baseline dots
// are pure homoglyphs the font templates can't split — '.' renders 4px,
// '・' 5px, so exact crops separate them via the ink-coverage penalty.
// Only the dot/T rows are labeled; the rest add nothing new.
frame: "scoreboard/robot/frame.png",
names: ["R.O.B.O.T", "", "", "", "", "Rαι×ι..・", "", ""],
paints: ["", "", "", "", "", "", "", ""],
stats: [
["", "", ""],
["", "", ""],
["", "", ""],
["", "", ""],
["", "", ""],
["", "", ""],
["", "", ""],
["", "", ""],
],
teamScores: ["", ""],
nameSplitHints: {},
},
{
frame: "scoreboard/splash-sploosh/frame.png",
// rows 2 and 7 hold kana/symbol glyphs not yet verified char-by-char
names: [
"Bocchi",
"have faith",
"",
"Florescent",
"Elis",
"Jrod_14",
"GOLD SHIP",
"",
],
paints: ["1422", "1161", "993", "1046", "1662", "1105", "799", "1155"],
stats: [
["15", "10", "06"],
["13", "06", "04"],
["06", "02", "05"],
["13", "06", "05"],
["15", "02", "06"],
["10", "10", "03"],
["02", "02", "04"],
["08", "12", "06"],
],
teamScores: ["500", "0"],
nameSplitHints: {},
},
];
/** Team totals sit on the light team-color pattern; binarize higher. */
const TEAM_BIN_THRESHOLD = 175;
const cv = await loadOpenCV();
/** Normalized grayscale of the source frame currently being harvested. */
let gray: Mat;
async function loadGray(framePath: string): Promise<Mat> {
const srcMat = toMat(await readImage(join(FIXTURES_DIR, framePath)));
const frame = normalizeFrame(srcMat);
srcMat.delete();
const out = new cv.Mat();
cv.cvtColor(frame, out, cv.COLOR_RGBA2GRAY);
frame.delete();
return out;
}
/** Green channel instead of luminance — the replay code is green-on-dark. */
async function loadGreen(framePath: string): Promise<Mat> {
const srcMat = toMat(await readImage(join(FIXTURES_DIR, framePath)));
const frame = normalizeFrame(srcMat);
srcMat.delete();
const channels = new cv.MatVector();
cv.split(frame, channels);
frame.delete();
const g = channels.get(1);
const out = new cv.Mat();
g.copyTo(out);
g.delete();
channels.delete();
return out;
}
/**
* Death splash-tag name band, prepared the way the detector reads it
* (see src/core/detectors/death/index.ts): crop the tilted tag, rotate it
* level, crop the name band, then map each pixel to its max-channel
* distance from the median banner color, normalized to 0-255.
*/
async function loadTagBand(framePath: string): Promise<Mat> {
const srcMat = toMat(await readImage(join(FIXTURES_DIR, framePath)));
const frame = normalizeFrame(srcMat);
srcMat.delete();
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
frame.delete();
const outerView = rgb.roi(
new cv.Rect(
TAG_NAME_OUTER.x,
TAG_NAME_OUTER.y,
TAG_NAME_OUTER.w,
TAG_NAME_OUTER.h,
),
);
const outer = new cv.Mat();
outerView.copyTo(outer);
outerView.delete();
rgb.delete();
const center = new cv.Point(outer.cols / 2, outer.rows / 2);
const m = cv.getRotationMatrix2D(center, -TAG_TILT_DEG, 1);
const rotated = new cv.Mat();
cv.warpAffine(
outer,
rotated,
m,
new cv.Size(outer.cols, outer.rows),
cv.INTER_LINEAR,
cv.BORDER_REPLICATE,
new cv.Scalar(),
);
m.delete();
outer.delete();
const innerView = rotated.roi(
new cv.Rect(
TAG_NAME_INNER.x,
TAG_NAME_INNER.y,
TAG_NAME_INNER.w,
TAG_NAME_INNER.h,
),
);
const inner = new cv.Mat();
innerView.copyTo(inner);
innerView.delete();
rotated.delete();
const n = inner.rows * inner.cols;
const px = inner.data;
const background: number[] = [];
for (let c = 0; c < 3; c++) {
const hist = new Array<number>(256).fill(0);
for (let i = 0; i < n; i++) hist[px[i * 3 + c]!]!++;
let acc = 0;
let v = 0;
for (; v < 255; v++) {
acc += hist[v]!;
if (acc >= n / 2) break;
}
background.push(v);
}
const band = new cv.Mat(inner.rows, inner.cols, cv.CV_8UC1);
const out = band.data;
for (let i = 0; i < n; i++) {
out[i] = Math.max(
Math.abs(px[i * 3]! - background[0]!),
Math.abs(px[i * 3 + 1]! - background[1]!),
Math.abs(px[i * 3 + 2]! - background[2]!),
);
}
inner.delete();
cv.normalize(band, band, 0, 255, cv.NORM_MINMAX);
clearBorderBlobs(band, TAG_BIN_THRESHOLD);
return band;
}
/**
* Zero out ink components touching the band border, exactly as the
* detector does before parsing (see clearBorderBlobs in death/index.ts):
* banner art and title-line slivers enter at the band edges, and without
* this they join the column runs (mismatching the label) or push a crop's
* tight box past the band edge.
*/
function clearBorderBlobs(band: Mat, threshold: number): void {
const bin = new cv.Mat();
cv.threshold(band, bin, threshold, 255, cv.THRESH_BINARY);
const labels = new cv.Mat();
const stats = new cv.Mat();
const centroids = new cv.Mat();
const count = cv.connectedComponentsWithStats(
bin,
labels,
stats,
centroids,
8,
);
bin.delete();
centroids.delete();
const s = stats.data32S;
const touchesBorder = new Uint8Array(count);
for (let i = 1; i < count; i++) {
const left = s[i * 5 + cv.CC_STAT_LEFT]!;
const top = s[i * 5 + cv.CC_STAT_TOP]!;
const right = left + s[i * 5 + cv.CC_STAT_WIDTH]!;
const bottom = top + s[i * 5 + cv.CC_STAT_HEIGHT]!;
touchesBorder[i] =
left === 0 || top === 0 || right === band.cols || bottom === band.rows
? 1
: 0;
}
stats.delete();
const lab = labels.data32S;
const out = band.data;
for (let i = 0; i < out.length; i++) {
if (touchesBorder[lab[i]!]!) out[i] = 0;
}
labels.delete();
}
function columnRuns(
roi: Roi,
splitHints: number[] = [],
threshold = BIN_THRESHOLD,
mergeHints: number[] = [],
): { x0: number; x1: number }[] {
let runs: { x0: number; x1: number }[] = [];
let start = -1;
for (let x = roi.x; x < roi.x + roi.w; x++) {
let count = 0;
for (let y = roi.y; y < roi.y + roi.h; y++) {
if (gray.ucharPtr(y, x)[0]! > threshold) count++;
}
const on = count >= 1;
if (on && start < 0) start = x;
if (!on && start >= 0) {
if (x - start >= 2) runs.push({ x0: start, x1: x });
start = -1;
}
}
if (start >= 0) runs.push({ x0: start, x1: roi.x + roi.w });
// Merge hints bridge runs that belong to one glyph (multi-stroke kana
// like パ segments as two strokes): a hint x inside the gap between two
// consecutive runs joins them into a single crop.
for (const hint of mergeHints) {
const i = runs.findIndex(
(r, idx) =>
idx + 1 < runs.length && r.x1 <= hint && runs[idx + 1]!.x0 >= hint,
);
if (i >= 0) {
runs = [
...runs.slice(0, i),
{ x0: runs[i]!.x0, x1: runs[i + 1]!.x1 },
...runs.slice(i + 2),
];
}
}
return runs.flatMap((run) => {
const cuts = splitHints
.filter((h) => h > run.x0 + 1 && h < run.x1 - 1)
.sort((a, b) => a - b);
if (cuts.length === 0) return [run];
const parts: { x0: number; x1: number }[] = [];
let x0 = run.x0;
for (const cut of cuts) {
parts.push({ x0, x1: cut });
x0 = cut;
}
parts.push({ x0, x1: run.x1 });
return parts;
});
}
function cropGlyph(
run: { x0: number; x1: number },
roi: Roi,
threshold = BIN_THRESHOLD,
): Mat {
// tight vertical bounds within the run
let yMin = roi.y + roi.h;
let yMax = -1;
for (let y = roi.y; y < roi.y + roi.h; y++) {
for (let x = run.x0; x < run.x1; x++) {
if (gray.ucharPtr(y, x)[0]! > threshold) {
if (y < yMin) yMin = y;
if (y > yMax) yMax = y;
}
}
}
const pad = 1;
const rect = new cv.Rect(
run.x0 - pad,
yMin - pad,
run.x1 - run.x0 + 2 * pad,
yMax - yMin + 1 + 2 * pad,
);
const view = gray.roi(rect);
const out = new cv.Mat();
view.copyTo(out);
view.delete();
// Apply the same background masking recognizeText uses at match time, so
// templates harvested off colored backgrounds stay comparable.
const binary = new cv.Mat();
cv.threshold(out, binary, threshold, 255, cv.THRESH_BINARY);
const mask = new cv.Mat();
const kernel = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3));
cv.dilate(binary, mask, kernel, new cv.Point(-1, -1), 2);
kernel.delete();
binary.delete();
const masked = new cv.Mat(out.rows, out.cols, cv.CV_8UC1, new cv.Scalar(0));
out.copyTo(masked, mask);
out.delete();
mask.delete();
return masked;
}
/**
* Slice a labeled text ROI into per-char crops. A Map collector keeps the
* first instance per char (digits render identically everywhere); an array
* collector keeps every instance — letters land on different subpixel
* phases, so each occurrence is a distinct, equally-authoritative exemplar.
*/
function harvest(
roi: Roi,
label: string,
collected: Map<string, Mat> | [string, Mat][],
splitHints: number[] = [],
threshold = BIN_THRESHOLD,
mergeHints: number[] = [],
): void {
if (label === "") return;
const chars = [...label.replace(/ /g, "")];
const runs = columnRuns(roi, splitHints, threshold, mergeHints);
if (runs.length !== chars.length) {
console.warn(
`segment mismatch for "${label}": ${runs.length} segments vs ${chars.length} chars — skipped`,
);
return;
}
runs.forEach((run, i) => {
const ch = chars[i]!;
if (Array.isArray(collected)) {
collected.push([ch, cropGlyph(run, roi, threshold)]);
} else if (!collected.has(ch)) {
collected.set(ch, cropGlyph(run, roi, threshold));
}
});
}
function writeAtlas(
name: string,
height: number,
collected: Map<string, Mat> | [string, Mat][],
): void {
const entries =
collected instanceof Map ? [...collected.entries()] : collected;
const glyphs = entries.sort(([a], [b]) => a.localeCompare(b));
if (glyphs.length === 0) {
console.warn(`${name}: nothing harvested, atlas not written`);
return;
}
const spacing = 2;
const totalW =
glyphs.reduce((acc, [, m]) => acc + m.cols, 0) +
spacing * (glyphs.length + 1);
const maxH = Math.max(...glyphs.map(([, m]) => m.rows)) + 2 * spacing;
const atlas = new cv.Mat(maxH, totalW, cv.CV_8UC1, new cv.Scalar(0));
const meta: AtlasMeta = { height, glyphs: [] };
let x = spacing;
for (const [char, m] of glyphs) {
const view = atlas.roi(new cv.Rect(x, spacing, m.cols, m.rows));
m.copyTo(view);
view.delete();
meta.glyphs.push({
char,
x,
y: spacing,
w: m.cols,
h: m.rows,
source: "fixture",
});
x += m.cols + spacing;
}
const rgba = new cv.Mat();
cv.cvtColor(atlas, rgba, cv.COLOR_GRAY2RGBA);
writePng(join(OUT_DIR, `${name}.png`), {
width: rgba.cols,
height: rgba.rows,
data: new Uint8ClampedArray(rgba.data),
});
writeFileSync(join(OUT_DIR, `${name}.json`), JSON.stringify(meta, null, 2));
atlas.delete();
rgba.delete();
console.info(
`${name}: ${glyphs.length} glyphs -> ${OUT_DIR}/${name}.{png,json}`,
);
}
mkdirSync(OUT_DIR, { recursive: true });
// One crop per char per source: each source keeps its own first-instance map,
// and the atlases carry every source's crops side by side.
const nameEntries: [string, Mat][] = [];
const paintEntries: [string, Mat][] = [];
const statEntries: [string, Mat][] = [];
const teamEntries: [string, Mat][] = [];
const lobbyEntries: [string, Mat][] = [];
const lineEntries: [string, Mat][] = [];
for (const source of SOURCES) {
console.info(`harvesting ${source.frame}`);
gray = await loadGray(source.frame);
const nameGlyphs: [string, Mat][] = [];
ROW_CENTERS.forEach((cy, row) => {
// name region may include paint digits for long names; stop where digits start
harvest(
{ ...nameRoi(cy), w: 196 },
source.names[row]!,
nameGlyphs,
source.nameSplitHints[row] ?? [],
);
});
nameEntries.push(...nameGlyphs);
const paintGlyphs = new Map<string, Mat>();
ROW_CENTERS.forEach((cy, row) => {
harvest(paintRoi(cy), source.paints[row]!, paintGlyphs);
});
paintEntries.push(...paintGlyphs.entries());
const statGlyphs = new Map<string, Mat>();
ROW_CENTERS.forEach((cy, row) => {
for (const i of [0, 1, 2] as const) {
harvest(statRoi(cy, i), source.stats[row]![i]!, statGlyphs);
}
});
statEntries.push(...statGlyphs.entries());
const teamGlyphs = new Map<string, Mat>();
TEAM_SCORE_ROIS.forEach((roi, i) => {
harvest(roi, source.teamScores[i]!, teamGlyphs, [], TEAM_BIN_THRESHOLD);
});
teamEntries.push(...teamGlyphs.entries());
// Header tags: lobby line (BlitzMain ~24px), then mode (BlitzBold ~35px) and
// stage (BlitzMain ~22px) side by side.
if (source.header) {
const { lobby, mode, stage } = source.header;
const lobbyGlyphs = new Map<string, Mat>();
harvest(
lobby.roi,
lobby.label,
lobbyGlyphs,
lobby.splitHints,
lobby.threshold,
);
lobbyEntries.push(...lobbyGlyphs.entries());
const modeGlyphs = new Map<string, Mat>();
harvest(mode.roi, mode.label, modeGlyphs, mode.splitHints, mode.threshold);
const stageGlyphs = new Map<string, Mat>();
harvest(
stage.roi,
stage.label,
stageGlyphs,
stage.splitHints,
stage.threshold,
);
lineEntries.push(...modeGlyphs.entries(), ...stageGlyphs.entries());
}
gray.delete();
}
// Replay-browser fixtures: the code line renders in FOT-RowdyStd, which no
// live-scoreboard atlas covers. Letters land on different subpixel phases,
// so keep every occurrence (array collector), like names.
const REPLAY_SOURCES: { frame: string; code: string }[] = [
{
frame:
"scoreboard-battle-log-replay/private-battle-splat-zones-hagglefish/frame.jpeg",
code: "R6KE-D064-3CXD-XVKL",
},
{
frame:
"scoreboard-battle-log-replay/anarchy-open-rainmaker-knockout-museum/frame.png",
code: "RWYQ-4X37-M1EL-EGGQ",
},
{
frame:
"scoreboard-battle-log-replay/private-battle-crableg-capital/frame.png",
code: "R1V4-PAHW-GGM2-PD9S",
},
// Heavily compressed stream captures: font-rendered templates lose to
// fixture crops of lookalikes on these (E beat a real F by 0.1+), so the
// chars they cover (8 B F J N T U 5 among them) need crops at this
// fidelity. brinewater-1411/marlin stay out as generalization checks.
{
frame:
"scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1404/frame.png",
code: "R80B-00DL-WF4X-V3CA",
},
{
frame:
"scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1416/frame.png",
code: "RUH3-3NEF-F5FY-PAJL",
},
{
frame:
"scoreboard-battle-log-replay/x-battle-rainmaker-urchin-1434/frame.png",
code: "RUCT-5HNH-XWDC-J51U",
},
];
const codeEntries: [string, Mat][] = [];
for (const source of REPLAY_SOURCES) {
console.info(`harvesting ${source.frame}`);
gray = await loadGreen(source.frame);
const codeGlyphs: [string, Mat][] = [];
harvest(REPLAY_CODE_ROI, source.code, codeGlyphs);
codeEntries.push(...codeGlyphs);
gray.delete();
}
// Death splash-tag names (BlitzBold + Rowdy kana at ~46px). The atlas is
// nominal 42 with the detector upscaling to 46 at load (see the builder
// comment in scripts/scanner/build-glyph-atlas.ts), so native-size crops shrink to
// 42 here and come back to native after the load-time upscale.
const TAG_BIN_THRESHOLD = 160; // TAG_NAME_BIN_THRESHOLD in death/index.ts
const TAG_ATLAS_HEIGHT = 42;
const DEATH_TAG_SOURCES: {
frame: string;
label: string;
mergeHints?: number[];
}[] = [
{
frame: "death/fxg-supaatan-slosher/frame.png",
label: "FxG スパータン",
// パ renders as two disconnected strokes; bridge them into one crop
mergeHints: [340],
},
{
frame: "death/classic-squiffer-jp/frame.png",
label: "ごあんぜんに",
// に's bar and body never connect; bridge them into one crop
mergeHints: [431],
},
{
frame: "death/wipeout-52-gal/frame.png",
label: "さんだいめドパかげ",
// だ (1px column gap), い, ド, パ, げ all segment as disconnected
// strokes/marks; bridge each into one crop
mergeHints: [176, 236, 366, 408, 528],
},
];
const tagEntries: [string, Mat][] = [];
for (const source of DEATH_TAG_SOURCES) {
console.info(`harvesting ${source.frame}`);
gray = await loadTagBand(source.frame);
const tagGlyphs: [string, Mat][] = [];
harvest(
{ x: 0, y: 0, w: gray.cols, h: gray.rows },
source.label,
tagGlyphs,
[],
TAG_BIN_THRESHOLD,
source.mergeHints ?? [],
);
gray.delete();
const factor = TAG_ATLAS_HEIGHT / TAG_NAME_TEXT_HEIGHT;
for (const [char, mat] of tagGlyphs) {
const scaledMat = new cv.Mat();
cv.resize(mat, scaledMat, new cv.Size(0, 0), factor, factor, cv.INTER_AREA);
mat.delete();
tagEntries.push([char, scaledMat]);
}
}
// JA death-message lines (condensed Kurokane/Rowdy blend at ~40px; the
// atlas is native-size, matched unscaled by the detector's JA read path).
// ROIs are fixture-specific tight boxes around each line so scene ink
// outside the burst stays out of the runs.
const DEATH_JA_BIN_THRESHOLD = 190; // SPLAT_TEXT_BIN_THRESHOLD in death/rois.ts
const DEATH_JA_SOURCES: {
frame: string;
lines: { roi: Roi; label: string; mergeHints?: number[] }[];
}[] = [
{
frame: "death/classic-squiffer-jp/frame.png",
lines: [
// ッ renders as two disconnected strokes; bridge them into one crop
{
roi: { x: 825, y: 358, w: 275, h: 58 },
label: "スクイックリンα で",
mergeHints: [917],
},
{ roi: { x: 880, y: 410, w: 155, h: 56 }, label: "やられた!" },
],
},
];
const deathJaEntries: [string, Mat][] = [];
for (const source of DEATH_JA_SOURCES) {
console.info(`harvesting ${source.frame}`);
gray = await loadGray(source.frame);
for (const line of source.lines) {
harvest(
line.roi,
line.label,
deathJaEntries,
[],
DEATH_JA_BIN_THRESHOLD,
line.mergeHints ?? [],
);
}
gray.delete();
}
writeAtlas("scoreboard-names", 22, nameEntries);
writeAtlas("scoreboard-paint-digits", 28, paintEntries);
writeAtlas("scoreboard-stat-digits", 17, statEntries);
writeAtlas("scoreboard-team-digits", 33, teamEntries);
writeAtlas("scoreboard-header-lobby", 19, lobbyEntries);
writeAtlas("scoreboard-header-line", 24, lineEntries);
writeAtlas("scoreboard-replay-code", CODE_TEXT_HEIGHT, codeEntries);
writeAtlas("death-tag-name", TAG_ATLAS_HEIGHT, tagEntries);
writeAtlas("death-weapon-ja", 40, deathJaEntries);

View File

@@ -0,0 +1,481 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Build glyph atlases by rendering the Splatoon fonts at the exact
* scoreboard render sizes, calibrated against the reference fixture:
*
* paint digits BlitzMain 34px (tight '0' height ~29px at 1080p)
* stat digits BlitzMain 20px (~17px)
* team digits BlitzBold 36px (~28px — team totals use the bold face,
* which is why they can't reuse paint glyphs)
* names BlitzMain 20px (cap height ~17px), full charset:
* ASCII + Latin-1 + kana + common symbols (~330 glyphs)
*
* The fonts ship with the game and are not committed — drop them into
* assets/fonts/ (see README). Fixture-crop bootstrapping
* (scripts/scanner/bootstrap-atlas-from-fixture.ts) remains as a cross-check and as
* the fallback when the fonts are unavailable.
*
* Usage: pnpm scanner:build-glyph-atlas
* Writes public/assets/glyphs/scoreboard-*.{png,json}
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
import {
DEATH_MESSAGE_TEMPLATES,
LOCALIZED_WEAPON_NAMES,
} from "../../app/features/scanner/core/detectors/death/localized-messages";
import { ALL_WEAPON_ENTRIES } from "../../app/features/scanner/core/detectors/death/weapon-names";
import type { AtlasMeta } from "../../app/features/scanner/core/glyphs";
import {
ALL_LOBBY_ENTRIES,
ALL_MODE_ENTRIES,
ALL_MODE_LABELS,
ALL_STAGE_ENTRIES,
RESULT_TAG_ENTRIES,
} from "../../app/features/scanner/core/localized";
import { SCANNER_ASSETS_DIR } from "../../app/features/scanner/node/assets-dir";
import { readImage, writePng } from "../../app/features/scanner/node/image-io";
import { readFontCoverage } from "./otf-cmap";
/**
* Atlases are hybrids: glyphs harvested from labeled fixtures
* (scripts/scanner/bootstrap-atlas-from-fixture.ts, tagged source:"fixture") are exact
* in-game pixels and are preserved across rebuilds; font-rendered glyphs
* fill in the rest of the charset. Recognition takes the best-scoring glyph,
* so fixture crops dominate wherever they exist.
*/
const FONTS_DIR = new URL("../../assets/fonts", import.meta.url).pathname;
const OUT_DIR = join(SCANNER_ASSETS_DIR, "glyphs");
const FONT_FILES = {
BlitzMain: "BlitzMain.otf",
BlitzBold: "BlitzBold.otf",
/** replay-browser code line and VICTORY/DEFEAT panel tags */
Rowdy: "FOT-RowdyStd-EB.otf",
/** JA death message (together with Rowdy — see death-weapon-ja) */
Kurokane: "FOT-KurokaneStd-EB.otf",
} as const;
/**
* Which codepoints each font actually maps: canvas silently substitutes a
* system font for the rest (the Blitz cuts have no kanji/hangul/hanzi), so
* charsets are filtered through this before rendering — the CJK languages'
* ideographs drop out of the localized charsets instead of baking
* wrong-font glyphs into the atlases.
*/
const fontCoverage: Record<string, (codepoint: number) => boolean> = {};
for (const [family, file] of Object.entries(FONT_FILES)) {
const path = join(FONTS_DIR, file);
if (!existsSync(path)) {
console.error(
[
`font not found: ${path}`,
"",
"The Splatoon fonts are proprietary and not committed. Get",
"Decrypted/BlitzMain.otf and Decrypted/BlitzBold.otf (e.g. from the",
"splatoon3-fonts repo) into assets/fonts/, or fall back to",
" pnpm scanner:bootstrap-atlas",
].join("\n"),
);
process.exit(1);
}
GlobalFonts.registerFromPath(path, family);
fontCoverage[family] = readFontCoverage(path);
}
const DIGITS = "0123456789";
function nameCharset(): string[] {
const chars: string[] = [];
for (let c = 33; c <= 126; c++) chars.push(String.fromCharCode(c)); // ASCII
for (let c = 0xa1; c <= 0xff; c++) chars.push(String.fromCharCode(c)); // Latin-1
for (let c = 0x3041; c <= 0x3096; c++) chars.push(String.fromCharCode(c)); // hiragana
for (let c = 0x30a1; c <= 0x30fa; c++) chars.push(String.fromCharCode(c)); // katakana
chars.push("・", "ー", "、", "。", "「", "」");
chars.push("★", "☆", "●", "♪"); // name decorations
return [...new Set(chars)];
}
/**
* Greek letters players stylize scoreboard names with, added per attested
* fixture need only ("Rιppιng_H"): most of the block are homoglyphs of latin
* or kana at capture fidelity (η~n, ε~c, Γ~か strokes...) and displace
* correct matches on ranking noise, so it is not included wholesale — and
* even a few extra narrow glyphs shift an atlas's median width enough to
* change wide-segment splitting, so it stays out of the death-tag charset
* until a death fixture attests it.
*/
const NAME_GREEK = "ια"; // ι: "Rιppιng_H", α: "◇Dαrz™" (special-symbols fixture)
/**
* The rest of the in-game name editor's symbol pickers (sendou.ink's
* IN_GAME_NAME_CHARACTER_CATEGORIES: "symbols" + "cjk-symbols"), minus what
* nameCharset() already carries via ASCII/Latin-1/kana and minus chars the
* Blitz cmap doesn't map (ˊˋ𝑓⁀⚪⚫◻◼⍑ — canvas would render a system-font
* substitute; nameSymbols() re-checks at build time). "•" stays out too:
* BlitzMain's own bullet is a 4px dot, the on-screen full-size circle comes
* from "●" via RENDER_ALIASES. The tilde is the fullwidth "~" (U+FF5E) only
* — the wave dash "〜" (U+301C) is a pixel-identical homoglyph that would
* duel it on ranking noise, and FF5E is the form the fixture labels attest.
* Like NAME_GREEK, scoreboard-names only (not death-tag) until attested.
*/
const NAME_SYMBOLS =
"′‘’‚‛…″“”„←→↑↓⇒⇔˜€∞√∀⊂⊃∴∵∂№♭♀♂◎◇◆△▲▽▼†※™" + "『』【】〈〉《》〔〕々〆〇〃~";
/**
* Render the key char's glyph but emit it as the value char: in-game names
* show "•" as the full-size filled circle (BlitzMain's own "•" is a 4px dot
* that never appears on screen), and fixture labels write it as "•".
*/
const RENDER_ALIASES: Record<string, string> = { "●": "•" };
interface GlyphBitmap {
char: string;
w: number;
h: number;
/** grayscale, white-on-black (alpha coverage) */
data: Uint8Array;
source: "fixture" | "font";
}
/** Carry fixture-harvested glyphs over from the existing atlas, if any. */
async function readFixtureGlyphs(name: string): Promise<GlyphBitmap[]> {
const pngPath = join(OUT_DIR, `${name}.png`);
const jsonPath = join(OUT_DIR, `${name}.json`);
if (!existsSync(pngPath) || !existsSync(jsonPath)) return [];
const meta = JSON.parse(readFileSync(jsonPath, "utf8")) as AtlasMeta;
const png = await readImage(pngPath);
return meta.glyphs
.filter((g) => g.source === "fixture")
.map((g) => {
const data = new Uint8Array(g.w * g.h);
for (let y = 0; y < g.h; y++) {
for (let x = 0; x < g.w; x++) {
data[y * g.w + x] =
png.data[((g.y + y) * png.width + (g.x + x)) * 4]!;
}
}
return { char: g.char, w: g.w, h: g.h, data, source: "fixture" as const };
});
}
/**
* Render one glyph and tight-crop it via the alpha channel. xScale < 1
* condenses the glyph horizontally (the JA death message renders its font
* squeezed to ~3/4 width in-game).
*/
function renderGlyph(
family: string,
px: number,
char: string,
xScale = 1,
): GlyphBitmap | null {
const size = px * 4;
const canvas = createCanvas(size, size);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "white";
ctx.font = `${px}px ${family}`;
ctx.textBaseline = "middle";
ctx.scale(xScale, 1);
ctx.fillText(char, px / xScale, size / 2);
const img = ctx.getImageData(0, 0, size, size);
let x0 = size;
let x1 = -1;
let y0 = size;
let y1 = -1;
let ink = 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (img.data[(y * size + x) * 4 + 3]! > 128) {
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
ink++;
}
}
}
if (x1 < 0 || ink < 4) return null;
const pad = 1;
const w = x1 - x0 + 1 + 2 * pad;
const h = y1 - y0 + 1 + 2 * pad;
const data = new Uint8Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const sx = x0 - pad + x;
const sy = y0 - pad + y;
if (sx < 0 || sy < 0 || sx >= size || sy >= size) continue;
data[y * w + x] = img.data[(sy * size + sx) * 4 + 3]!;
}
}
return { char, w, h, data, source: "font" };
}
/** Pack glyphs into rows (wrapping at maxWidth) and write PNG + JSON. */
function writeAtlas(name: string, height: number, glyphs: GlyphBitmap[]): void {
const spacing = 2;
const maxWidth = 1600;
const meta: AtlasMeta = { height, glyphs: [] };
let x = spacing;
let y = spacing;
let rowH = 0;
let atlasW = 0;
for (const g of glyphs) {
if (x + g.w + spacing > maxWidth) {
x = spacing;
y += rowH + spacing;
rowH = 0;
}
meta.glyphs.push({ char: g.char, x, y, w: g.w, h: g.h, source: g.source });
x += g.w + spacing;
rowH = Math.max(rowH, g.h);
atlasW = Math.max(atlasW, x);
}
const atlasH = y + rowH + spacing;
const pixels = new Uint8ClampedArray(atlasW * atlasH * 4);
for (let i = 3; i < pixels.length; i += 4) pixels[i] = 255;
meta.glyphs.forEach((m, i) => {
const g = glyphs[i]!;
for (let gy = 0; gy < g.h; gy++) {
for (let gx = 0; gx < g.w; gx++) {
const v = g.data[gy * g.w + gx]!;
const o = ((m.y + gy) * atlasW + (m.x + gx)) * 4;
pixels[o] = v;
pixels[o + 1] = v;
pixels[o + 2] = v;
}
}
});
writePng(join(OUT_DIR, `${name}.png`), {
width: atlasW,
height: atlasH,
data: pixels,
});
writeFileSync(join(OUT_DIR, `${name}.json`), JSON.stringify(meta, null, 2));
console.info(
`${name}: ${glyphs.length} glyphs (${atlasW}x${atlasH}) -> ${name}.{png,json}`,
);
}
interface AtlasPart {
family: string;
/** in-game render size varies by ±1px; multiple template sizes per char */
pxs: number[];
chars: string[];
/** horizontal condensation applied in-game (default 1 = none) */
xScale?: number;
}
async function build(
name: string,
height: number,
parts: AtlasPart[],
): Promise<void> {
const glyphs: GlyphBitmap[] = await readFixtureGlyphs(name);
const fixtureCount = glyphs.length;
const missing: string[] = [];
for (const { family, pxs, chars, xScale } of parts) {
for (const ch of chars) {
let found = false;
for (const px of pxs) {
const g = renderGlyph(family, px, ch, xScale ?? 1);
if (g) {
glyphs.push({ ...g, char: RENDER_ALIASES[ch] ?? ch });
found = true;
}
}
if (!found) missing.push(ch);
}
}
if (missing.length > 0) {
console.info(
`${name}: ${missing.length} chars not renderable, skipped: ${missing.join("")}`,
);
}
console.info(
`${name}: carrying over ${fixtureCount} fixture-harvested glyphs`,
);
writeAtlas(name, height, glyphs);
}
/** NAME_SYMBOLS chars the font actually maps (see the constant's comment). */
function nameSymbols(family: string): string[] {
const covers = fontCoverage[family]!;
return [...NAME_SYMBOLS].filter((ch) => covers(ch.codePointAt(0)!));
}
/** Unique non-space characters across a set of known strings. */
function charsOf(entries: readonly string[]): string[] {
return [...new Set(entries.flatMap((e) => [...e.replace(/ /g, "")]))];
}
mkdirSync(OUT_DIR, { recursive: true });
await build("scoreboard-paint-digits", 29, [
{ family: "BlitzMain", pxs: [34], chars: [...DIGITS] },
]);
await build("scoreboard-stat-digits", 17, [
{ family: "BlitzMain", pxs: [20], chars: [...DIGITS] },
]);
await build("scoreboard-team-digits", 28, [
{ family: "BlitzBold", pxs: [36], chars: [...DIGITS] },
]);
await build("scoreboard-names", 17, [
{
family: "BlitzMain",
pxs: [19, 20],
chars: [...nameCharset(), ...NAME_GREEK, ...nameSymbols("BlitzMain")],
},
]);
/**
* Localized closed-set charsets (all 14 game languages; the canonical
* English strings are included by construction), restricted to the Latin
* scripts (< U+0250) the fixtures attest: like the Greek block in the name
* charset, wholesale Cyrillic/kana/ideograph glyphs displace Latin matches
* on ranking noise (adding them regressed the English fixtures), so the
* non-Latin languages' entries stay in the closed sets — ready to snap —
* but their glyphs wait for fixtures to tune against. Chars the font
* doesn't map are dropped too: canvas would silently render a system-font
* substitute into the atlas.
*/
function localizedChars(texts: readonly string[], family: string): string[] {
const covers = fontCoverage[family]!;
return charsOf(texts).filter((ch) => {
const cp = ch.codePointAt(0)!;
return cp < 0x250 && covers(cp);
});
}
const lobbyTexts = ALL_LOBBY_ENTRIES.map((e) => e.text);
const modeTexts = ALL_MODE_ENTRIES.map((e) => e.text);
const stageTexts = ALL_STAGE_ENTRIES.map((e) => e.text);
const resultTexts = RESULT_TAG_ENTRIES.map((e) => e.text);
// header: lobby tag is BlitzMain ~24px; the mode/stage line mixes bold mode
// text with regular stage text, so its atlas carries both faces
await build("scoreboard-header-lobby", 19, [
{
family: "BlitzMain",
pxs: [23, 24],
chars: localizedChars(lobbyTexts, "BlitzMain"),
},
]);
await build("scoreboard-header-line", 24, [
{
family: "BlitzBold",
pxs: [34, 35],
chars: localizedChars(modeTexts, "BlitzBold"),
},
{
family: "BlitzMain",
pxs: [22, 23],
chars: localizedChars(stageTexts, "BlitzMain"),
},
]);
// replay browser: the code line and the VICTORY/DEFEAT panel tags render in
// FOT-RowdyStd-EB (tight height 25px at ~32px, 30px at ~38px)
await build("scoreboard-replay-code", 25, [
{
family: "Rowdy",
pxs: [31, 32, 33],
chars: [...DIGITS, ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "-"],
},
]);
await build("scoreboard-replay-result", 30, [
{
family: "Rowdy",
pxs: [37, 38],
chars: localizedChars(resultTexts, "Rowdy"),
},
]);
// map-start intro splash: the big mode title on the center splat is
// BlitzBold (~76px tight caps at 1080p; a px sweep against the fixture reads
// best at 99-101), the stage name bottom-right is BlitzMain (~40px tight;
// 46-48px render). The constant "MODE" label ("Kampfart", ...) is BlitzMain
// too and is read with the stage atlas rescaled to its ~48px height, so its
// chars ride along.
await build("map-start-mode", 76, [
{
family: "BlitzBold",
pxs: [99, 100, 101],
chars: localizedChars(modeTexts, "BlitzBold"),
},
]);
await build("map-start-stage", 40, [
{
family: "BlitzMain",
pxs: [46, 47, 48],
chars: localizedChars([...stageTexts, ...ALL_MODE_LABELS], "BlitzMain"),
},
]);
// death screen: the localized "Splatted by <weapon>!" burst message (tight
// caps ~28px; the face reads between the two Blitz cuts at capture
// fidelity, so carry both and let recognition take the max) and the
// splash-tag name, which is BlitzBold for latin but the angular Rowdy face
// for kana
const deathWeaponTexts = [
...ALL_WEAPON_ENTRIES.map((e) => e.name),
...Object.values(LOCALIZED_WEAPON_NAMES).flatMap((names) =>
names.map((n) => n.text),
),
...DEATH_MESSAGE_TEMPLATES.flatMap((t) => [
t.constText,
t.weaponPre,
t.weaponPost,
]),
];
await build("death-weapon", 34, [
{
family: "BlitzMain",
pxs: [40, 41],
chars: localizedChars(deathWeaponTexts, "BlitzMain"),
},
{
family: "BlitzBold",
pxs: [43, 44],
chars: localizedChars(deathWeaponTexts, "BlitzBold"),
},
]);
// JA death message, a separate atlas read only by the JA line ROIs: mixing
// kana into the Latin set would shift its median width (breaking wide-
// segment splitting) and displace Latin matches on ranking noise. The
// in-game JP face renders horizontally condensed and sits between the two
// FOT cuts at capture fidelity — ス/ク read as Kurokane, で as Rowdy — so
// carry both, at the (px, xScale) pairs that peaked in an NCC sweep against
// the classic-squiffer-jp fixture. Latin/digit chars inside JP weapon names
// (LACT-450, .52ガロン) ride along in both faces. Attested-fixture rule as
// everywhere: KO/ZH names stay in the closed sets without an atlas.
const deathJaTexts = [
...(LOCALIZED_WEAPON_NAMES.JPja ?? []).map((n) => n.text),
...DEATH_MESSAGE_TEMPLATES.filter((t) =>
t.langs.some((l) => l.endsWith("ja")),
).flatMap((t) => [t.constText, t.weaponPre, t.weaponPost]),
];
function coveredChars(texts: readonly string[], family: string): string[] {
const covers = fontCoverage[family]!;
return charsOf(texts).filter((ch) => covers(ch.codePointAt(0)!));
}
await build("death-weapon-ja", 40, [
{
family: "Kurokane",
pxs: [44, 46],
xScale: 0.75,
chars: coveredChars(deathJaTexts, "Kurokane"),
},
{
family: "Rowdy",
pxs: [38, 40],
xScale: 0.8,
chars: coveredChars(deathJaTexts, "Rowdy"),
},
]);
// nominal 42 with the detector scaling to 46 reads strictly better than a
// native-46 render (the cubic upscale slightly fattens strokes the way the
// in-game compositing does; a native render splits か into two segments)
await build("death-tag-name", 42, [
{ family: "BlitzBold", pxs: [53, 54], chars: nameCharset() },
{ family: "Rowdy", pxs: [52, 54], chars: nameCharset() },
]);

View File

@@ -0,0 +1,436 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Generate the localized closed sets from the splat3 repo's language dumps
* (https://github.com/Leanny/splat3, data/language/<Lang>_full.json), so
* ingestion works no matter which language the player runs the game in.
* Detectors OCR whatever is on screen, snap it against every language's
* entries, and always emit the sendou.ink id (canonical English text
* exists only inside the snap tables).
*
* Sources per language:
* CommonMsg/VS/VSRuleName modes (+ the _2L two-line intro-splash
* wrap variants, e.g. "Muschel-\nchaos")
* CommonMsg/VS/VSStageName stages (keyed via the USen values)
* CommonMsg/MatchMode lobby tags (XMatch / Private)
* LayoutMsg/Lobby_MenuMode_00 the intro splash's "MODE" label
* LayoutMsg/Mng_Result_00 replay-browser VICTORY / DEFEAT tags
* LayoutMsg/VS_Beaten_00 (999) the death-burst message; the weapon
* placeholder sits on line 1 or 2
* depending on language, so this becomes
* a per-language template
* CommonMsg/Weapon/WeaponName_* weapon names, mapped to the canonical
* entries via their USen value
*
* Usage: pnpm scanner:build-localized-entries [path-to-splat3]
* Writes app/features/scanner/core/localized-entries.ts
* and app/features/scanner/core/detectors/death/localized-messages.ts
*/
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ALL_WEAPON_ENTRIES } from "../../app/features/scanner/core/detectors/death/weapon-names";
import type { ScannerLobby } from "../../app/features/scanner/scanner-types";
import { stageIds } from "../../app/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "../../app/modules/in-game-lists/types";
import gameMisc from "../../locales/en/game-misc.json";
const SPLAT3_DIR =
process.argv[2] ?? new URL("../../../splat3", import.meta.url).pathname;
const LANG_DIR = join(SPLAT3_DIR, "data", "language");
const OUT_ENTRIES = new URL(
"../../app/features/scanner/core/localized-entries.ts",
import.meta.url,
).pathname;
const OUT_MESSAGES = new URL(
"../../app/features/scanner/core/detectors/death/localized-messages.ts",
import.meta.url,
).pathname;
const CANONICAL_LANG = "USen";
const misc = gameMisc as Record<string, string>;
/** VSRuleName key -> ModeShort; USen values validate against the en locale. */
const RULE_KEYS: Record<string, ModeShort> = {
Pnt: "TW",
Var: "SZ",
Vlf: "TC",
Vgl: "RM",
Vcl: "CB",
};
/** MatchMode key -> lobby code; USen values validate against these names. */
const LOBBY_KEYS: Record<string, ScannerLobby> = {
XMatch: "X",
Bankara: "SERIES",
BankaraOpen: "OPEN",
Private: "PRIVATE",
};
/** the English lobby tags as the game shows them, for USen validation */
const LOBBY_ENGLISH: Record<ScannerLobby, string> = {
X: "X Battle",
SERIES: "Anarchy Battle (Series)",
OPEN: "Anarchy Battle (Open)",
PRIVATE: "Private Battle",
};
type LangDump = Record<string, Record<string, string>>;
function loadLang(lang: string): LangDump {
return JSON.parse(
readFileSync(join(LANG_DIR, `${lang}_full.json`), "utf8"),
) as LangDump;
}
/** Drop [size=...]/[color=...]-style markup and collapse whitespace. */
function clean(s: string): string {
return s
.replace(/\[[^\]]*\]/g, "")
.replace(/[ \t]+/g, " ")
.trim();
}
/** The case/space/diacritic-insensitive key entries are deduped on (mirrors text.ts). */
function foldKey(s: string): string {
return s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\s+/g, "");
}
/** sendou.ink locales use the ASCII apostrophe; the game dumps use ’. */
function normalizeApostrophes(s: string): string {
return s.replace(/[’‘]/g, "'");
}
const languages = [
...new Set(
readdirSync(LANG_DIR)
.filter((f) => f.endsWith("_full.json"))
.map((f) => f.replace("_full.json", "")),
),
].sort();
if (!languages.includes(CANONICAL_LANG)) {
throw new Error(
`canonical language ${CANONICAL_LANG} not found in ${LANG_DIR}`,
);
}
const dumps = new Map<string, LangDump>(languages.map((l) => [l, loadLang(l)]));
const usen = dumps.get(CANONICAL_LANG)!;
// ---- validate the canonical sets against USen ------------------------------
for (const [key, mode] of Object.entries(RULE_KEYS)) {
const value = clean(usen["CommonMsg/VS/VSRuleName"]![key]!);
const expected = misc[`MODE_LONG_${mode}`]!;
if (value !== expected)
throw new Error(`USen rule ${key} is "${value}", expected "${expected}"`);
}
for (const [key, lobby] of Object.entries(LOBBY_KEYS)) {
const value = clean(usen["CommonMsg/MatchMode"]![key]!);
const expected = LOBBY_ENGLISH[lobby as ScannerLobby];
if (value !== expected)
throw new Error(`USen lobby ${key} is "${value}", expected "${expected}"`);
}
/** English stage name (per the sendou.ink en locale) -> StageId. */
const stageIdByEnglishName = new Map<string, StageId>(
stageIds.map((stageId) => [misc[`STAGE_${stageId}`]!, stageId]),
);
/** VSStageName key -> StageId, via the USen values. */
const stageKeys = new Map<string, StageId>();
for (const [key, value] of Object.entries(usen["CommonMsg/VS/VSStageName"]!)) {
const stageId = stageIdByEnglishName.get(value);
if (stageId !== undefined) stageKeys.set(key, stageId);
}
for (const [name, stageId] of stageIdByEnglishName) {
if (![...stageKeys.values()].includes(stageId)) {
throw new Error(
`stage "${name}" (id ${stageId}) not found in USen VSStageName`,
);
}
}
// ---- per-language closed sets ----------------------------------------------
interface LocalizedLobby {
text: string;
lobby: ScannerLobby;
}
interface LocalizedMode {
text: string;
mode: ModeShort;
}
interface LocalizedStage {
text: string;
stageId: StageId;
}
interface LanguageEntries {
lang: string;
modeLabel: string;
victory: string;
defeat: string;
lobbies: LocalizedLobby[];
modes: LocalizedMode[];
modeWraps: LocalizedMode[];
stages: LocalizedStage[];
}
const languageEntries: LanguageEntries[] = [];
for (const lang of languages) {
const d = dumps.get(lang)!;
const rules = d["CommonMsg/VS/VSRuleName"]!;
const modes: LocalizedMode[] = [];
const modeWraps: LocalizedMode[] = [];
for (const [key, mode] of Object.entries(RULE_KEYS)) {
modes.push({ text: clean(rules[key]!), mode });
// the intro splash renders the _2L wrap variant, hyphens included
const wrap = clean(rules[`${key}_2L`]!.replace(/\n/g, " "));
if (foldKey(wrap) !== foldKey(rules[key]!))
modeWraps.push({ text: wrap, mode });
}
languageEntries.push({
lang,
modeLabel: clean(d["LayoutMsg/Lobby_MenuMode_00"]!.T_Rule_00!),
victory: clean(d["LayoutMsg/Mng_Result_00"]!.T_Win_00!),
defeat: clean(d["LayoutMsg/Mng_Result_00"]!.T_Lose_00!),
lobbies: Object.entries(LOBBY_KEYS).map(([key, lobby]) => ({
text: clean(d["CommonMsg/MatchMode"]![key]!),
lobby,
})),
modes,
modeWraps,
stages: [...stageKeys.entries()].map(([key, stageId]) => ({
text: clean(d["CommonMsg/VS/VSStageName"]![key]!),
stageId,
})),
});
}
// A localized string that means one thing in language A and another in
// language B would snap ambiguously in the flattened unions — reject.
for (const category of ["lobbies", "modes", "modeWraps", "stages"] as const) {
const seen = new Map<string, string | number>();
for (const entries of languageEntries) {
for (const entry of entries[category]) {
const value =
"lobby" in entry
? entry.lobby
: "mode" in entry
? entry.mode
: entry.stageId;
const k = foldKey(entry.text);
const prior = seen.get(k);
if (prior !== undefined && prior !== value) {
throw new Error(
`${category}: "${entry.text}" maps to both "${prior}" and "${value}" across languages`,
);
}
seen.set(k, value);
}
}
}
// ---- death message templates -----------------------------------------------
const PLACEHOLDER = /\[group=[^\]]*\]/;
/** stands in for the weapon placeholder while splitting the message */
const SENTINEL = "\u0000";
interface DeathTemplate {
langs: string[];
weaponLine: 1 | 2;
constText: string;
weaponPre: string;
weaponPost: string;
}
const templates: DeathTemplate[] = [];
for (const lang of languages) {
const raw = dumps.get(lang)!["LayoutMsg/VS_Beaten_00"]!["999"]!;
const lines = raw
.split("\n")
.map((l) => clean(l.replace(PLACEHOLDER, SENTINEL)));
if (lines.length !== 2)
throw new Error(`${lang}: death message is not two lines: ${raw}`);
const weaponIndex = lines.findIndex((l) => l.includes(SENTINEL));
if (weaponIndex < 0)
throw new Error(`${lang}: no weapon placeholder: ${raw}`);
const weaponLine = (weaponIndex + 1) as 1 | 2;
const [pre, post] = lines[weaponLine - 1]!.split(SENTINEL) as [
string,
string,
];
const constText = lines[weaponLine % 2]!;
const existing = templates.find(
(t) =>
t.weaponLine === weaponLine &&
t.constText === constText &&
t.weaponPre === pre &&
t.weaponPost === post,
);
if (existing) existing.langs.push(lang);
else
templates.push({
langs: [lang],
weaponLine,
constText,
weaponPre: pre,
weaponPost: post,
});
}
// ---- localized weapon names --------------------------------------------------
const WEAPON_MSGS = [
"CommonMsg/Weapon/WeaponName_Main",
"CommonMsg/Weapon/WeaponName_Sub",
"CommonMsg/Weapon/WeaponName_Special",
];
const canonicalWeaponNames = new Set(ALL_WEAPON_ENTRIES.map((e) => e.name));
/** codename -> canonical entry name, via the USen value. */
const weaponCodenames = new Map<string, string>();
for (const msg of WEAPON_MSGS) {
for (const [codename, value] of Object.entries(usen[msg]!)) {
const name = normalizeApostrophes(value);
if (canonicalWeaponNames.has(name))
weaponCodenames.set(`${msg} ${codename}`, name);
}
}
const unmapped = [...canonicalWeaponNames].filter(
(n) => ![...weaponCodenames.values()].includes(n),
);
if (unmapped.length > 0) {
console.warn(
`WARNING: ${unmapped.length} weapon entries have no splat3 codename: ${unmapped.join(", ")}`,
);
}
/** lang -> localized names that differ from the canonical English name. */
const localizedWeaponNames: Record<string, { text: string; name: string }[]> =
{};
for (const lang of languages) {
const d = dumps.get(lang)!;
const seen = new Set<string>();
const entries: { text: string; name: string }[] = [];
for (const [key, name] of weaponCodenames) {
const [msg, codename] = key.split(" ") as [string, string];
const text = clean(d[msg]![codename] ?? "");
if (!text || text === "-") continue;
const k = foldKey(text);
if (seen.has(k) || k === foldKey(name)) continue;
seen.add(k);
entries.push({ text, name });
}
if (entries.length > 0) localizedWeaponNames[lang] = entries;
}
// ---- emit --------------------------------------------------------------------
const banner = (extra: string) =>
`/**
* GENERATED by scripts/scanner/build-localized-entries.ts from the splat3 repo's
* language dumps — do not edit by hand; regenerate when the game adds
* content. ${extra}
*/`;
writeFileSync(
OUT_ENTRIES,
`${banner(
`Localized versus-UI strings for every game language, each
* mapped to the sendou.ink id it means (core/localized.ts derives the
* flattened match sets detectors snap OCR output against).`,
)}
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { ScannerLobby } from "../scanner-types";
export interface LocalizedLobby {
text: string;
lobby: ScannerLobby;
}
export interface LocalizedMode {
text: string;
mode: ModeShort;
}
export interface LocalizedStage {
text: string;
stageId: StageId;
}
export interface LanguageEntries {
lang: string;
/** the intro splash's constant "MODE" label */
modeLabel: string;
/** replay-browser winner/loser panel tags */
victory: string;
defeat: string;
lobbies: LocalizedLobby[];
modes: LocalizedMode[];
/** two-line intro-splash wrap variants ("Muschel-\\nchaos"), space-joined */
modeWraps: LocalizedMode[];
stages: LocalizedStage[];
}
export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = ${JSON.stringify(
languageEntries,
null,
2,
)};
`,
);
writeFileSync(
OUT_MESSAGES,
`${banner(
`Per-language death-burst message templates and localized
* weapon names: the "Splatted by\\n<weapon>!" burst puts the weapon on line
* 1 or 2 depending on language, with language-specific text around it.`,
)}
export interface DeathMessageTemplate {
/** languages sharing this exact template */
langs: readonly string[];
/** which burst line carries the weapon name */
weaponLine: 1 | 2;
/** the constant text on the other line (parse-time confirmation) */
constText: string;
/** constant text around the weapon name on its own line */
weaponPre: string;
weaponPost: string;
}
export const DEATH_MESSAGE_TEMPLATES: readonly DeathMessageTemplate[] = ${JSON.stringify(
templates,
null,
2,
)};
export interface LocalizedWeaponName {
text: string;
/** canonical English entry name (weapon-names.ts) */
name: string;
}
/**
* Per-language weapon names that differ from the canonical English name
* (identical ones are omitted; match against the canonical set too).
*/
export const LOCALIZED_WEAPON_NAMES: Readonly<
Record<string, readonly LocalizedWeaponName[]>
> = ${JSON.stringify(localizedWeaponNames, null, 2)};
`,
);
console.info(
`localized-entries: ${languages.length} languages, ` +
`${languageEntries.reduce((n, l) => n + l.stages.length, 0)} stage strings`,
);
console.info(
`localized-messages: ${templates.length} death templates, ` +
`${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names`,
);

View File

@@ -0,0 +1,111 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Build the planner signature atlas from the assets repo's planner renders.
*
* The sendou-ink/assets checkout's assets/planner-maps/ holds the full
* planner PNGs (~340MB), named "<stageId>-<MODE>-<TYPE>.png" (MODE in
* CB/RM/SZ/TC/TW; TYPE in OVER/MINI/ITEMS). This tool reduces each
* PLANNER_TYPE render to the ink-invariant structural signature
* (app/features/scanner/core/detectors/minimap/stage.ts) and packs all of them,
* quantized to uint8, into a single grayscale atlas PNG plus a manifest
* (keys "<stageId>-<MODE>") — a few hundred KB the minimap detector loads
* to identify the stage. Output goes to the assets checkout
* (SCANNER_ASSETS_DIR/planner); shipping a regen means pushing the assets repo.
*
* pnpm scanner:build-planner-signatures
*/
import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadOpenCV } from "../../app/features/scanner/core/cv";
import {
PLANNER_SIG_H,
PLANNER_SIG_W,
type PlannerManifest,
plannerSignature,
} from "../../app/features/scanner/core/detectors/minimap/stage";
import {
type FrameData,
normalizeFrame,
toMat,
} from "../../app/features/scanner/core/image";
import { SCANNER_ASSETS_DIR } from "../../app/features/scanner/node/assets-dir";
import { readImage, writePng } from "../../app/features/scanner/node/image-io";
/** which render variant the signatures are built from */
const PLANNER_TYPE = process.env.SCANNER_PLANNER_TYPE ?? "MINI";
const SRC_DIR =
process.env.SCANNER_PLANNER_MAPS_DIR ??
new URL("../../../assets/assets/planner-maps", import.meta.url).pathname;
const OUT_DIR =
process.env.SCANNER_PLANNER_OUT_DIR ?? join(SCANNER_ASSETS_DIR, "planner");
const COLS = 5;
await loadOpenCV();
const files = readdirSync(SRC_DIR)
.filter((f) => f.endsWith(`-${PLANNER_TYPE}.png`))
.sort((a, b) => {
const [sa, ma] = a.split("-");
const [sb, mb] = b.split("-");
return Number(sa) - Number(sb) || ma!.localeCompare(mb!);
});
if (files.length === 0) {
throw new Error(`no ${PLANNER_TYPE} planner PNGs in ${SRC_DIR}`);
}
const rows = Math.ceil(files.length / COLS);
const atlasW = COLS * PLANNER_SIG_W;
const atlasH = rows * PLANNER_SIG_H;
const atlas: FrameData = {
width: atlasW,
height: atlasH,
data: new Uint8ClampedArray(atlasW * atlasH * 4),
};
const keys: string[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i]!;
const key = file.replace(`-${PLANNER_TYPE}.png`, "");
keys.push(key);
const img = toMat(await readImage(join(SRC_DIR, file)));
const norm = normalizeFrame(img);
const sig = plannerSignature(norm);
img.delete();
norm.delete();
// quantize to uint8 (load re-normalizes to unit L2, so the scale is free)
let max = 0;
for (const v of sig) if (v > max) max = v;
const scale = max > 0 ? 255 / max : 0;
const tx = (i % COLS) * PLANNER_SIG_W;
const ty = Math.floor(i / COLS) * PLANNER_SIG_H;
for (let y = 0; y < PLANNER_SIG_H; y++) {
for (let x = 0; x < PLANNER_SIG_W; x++) {
const v = Math.round(sig[y * PLANNER_SIG_W + x]! * scale);
const o = ((ty + y) * atlasW + (tx + x)) * 4;
atlas.data[o] = v;
atlas.data[o + 1] = v;
atlas.data[o + 2] = v;
atlas.data[o + 3] = 255;
}
}
}
mkdirSync(OUT_DIR, { recursive: true });
writePng(join(OUT_DIR, "signatures.png"), atlas);
const manifest: PlannerManifest = {
width: PLANNER_SIG_W,
height: PLANNER_SIG_H,
cols: COLS,
keys,
};
writeFileSync(
join(OUT_DIR, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
console.info(
`packed ${files.length} signatures into ${atlasW}x${atlasH} atlas -> ${OUT_DIR}`,
);

View File

@@ -0,0 +1,93 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* ROI calibration helper: normalize a frame to 1080p, then dump crops
* (optionally scaled up) and/or a grid overlay for visual inspection.
*
* Usage:
* vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/dump-crops.ts <image> <outdir> grid
* vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/dump-crops.ts <image> <outdir> x,y,w,h[,scale][:label] ...
*/
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { loadOpenCV } from "../../app/features/scanner/core/cv";
import {
cropRoi,
matToFrameData,
normalizeFrame,
toMat,
} from "../../app/features/scanner/core/image";
import { readImage, writePng } from "../../app/features/scanner/node/image-io";
const [imagePath, outDir, ...specs] = process.argv.slice(2);
if (!imagePath || !outDir || specs.length === 0) {
console.error(
"usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/dump-crops.ts <image> <outdir> (grid | x,y,w,h[,scale][:label])...",
);
process.exit(1);
}
const cv = await loadOpenCV();
mkdirSync(outDir, { recursive: true });
const src = toMat(await readImage(imagePath));
const frame = normalizeFrame(src);
src.delete();
for (const spec of specs) {
if (spec === "grid") {
const overlay = frame.clone();
for (let x = 0; x < overlay.cols; x += 20) {
const major = x % 100 === 0;
cv.line(
overlay,
new cv.Point(x, 0),
new cv.Point(x, overlay.rows),
new cv.Scalar(255, 0, major ? 0 : 255, 255),
major ? 2 : 1,
);
}
for (let y = 0; y < overlay.rows; y += 20) {
const major = y % 100 === 0;
cv.line(
overlay,
new cv.Point(0, y),
new cv.Point(overlay.cols, y),
new cv.Scalar(255, 0, major ? 0 : 255, 255),
major ? 2 : 1,
);
}
writePng(join(outDir, "grid.png"), matToFrameData(overlay));
overlay.delete();
continue;
}
const [rect = "", label] = spec.split(":");
const [x, y, w, h, scale = 1] = rect.split(",").map(Number);
if ([x, y, w, h].some((v) => !Number.isFinite(v))) {
console.error(`bad spec: ${spec}`);
continue;
}
const cx = Math.max(0, Math.min(x!, frame.cols - 1));
const cy = Math.max(0, Math.min(y!, frame.rows - 1));
const crop = cropRoi(frame, {
x: cx,
y: cy,
w: Math.min(w!, frame.cols - cx),
h: Math.min(h!, frame.rows - cy),
});
const out = new cv.Mat();
cv.resize(
crop,
out,
new cv.Size(0, 0),
Number(scale),
Number(scale),
cv.INTER_NEAREST,
);
const name = label ?? `crop-${x}-${y}-${w}x${h}`;
writePng(join(outDir, `${name}.png`), matToFrameData(out));
crop.delete();
out.delete();
}
frame.delete();
console.info(`wrote crops to ${outDir}`);

View File

@@ -0,0 +1,61 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Minimal OpenType cmap reader: which codepoints does a font actually map?
*
* @napi-rs/canvas silently falls back to a system font for characters the
* requested family lacks (the Blitz cuts carry Latin/Greek/Cyrillic/kana
* but no kanji, hangul or hanzi), which would bake wrong-font glyphs into
* the atlases. The builder filters every charset through this before
* rendering. Supports the two subtable formats the game fonts use:
* format 4 (BMP segments) and format 12 (grouped full-range).
*/
import { readFileSync } from "node:fs";
export function readFontCoverage(path: string): (codepoint: number) => boolean {
const buf = readFileSync(path);
const numTables = buf.readUInt16BE(4);
let cmapOffset = -1;
for (let i = 0; i < numTables; i++) {
const rec = 12 + i * 16;
if (buf.toString("latin1", rec, rec + 4) === "cmap") {
cmapOffset = buf.readUInt32BE(rec + 8);
break;
}
}
if (cmapOffset < 0) throw new Error(`${path}: no cmap table`);
// prefer a full-repertoire format-12 subtable, else the BMP format 4
const encodingCount = buf.readUInt16BE(cmapOffset + 2);
let best = -1;
let bestFormat = -1;
for (let i = 0; i < encodingCount; i++) {
const rec = cmapOffset + 4 + i * 8;
const subtable = cmapOffset + buf.readUInt32BE(rec + 4);
const format = buf.readUInt16BE(subtable);
if (format === 12 || (format === 4 && bestFormat !== 12)) {
best = subtable;
bestFormat = format;
}
}
if (best < 0) throw new Error(`${path}: no format 4/12 cmap subtable`);
const ranges: [number, number][] = [];
if (bestFormat === 12) {
const nGroups = buf.readUInt32BE(best + 12);
for (let g = 0; g < nGroups; g++) {
const rec = best + 16 + g * 12;
ranges.push([buf.readUInt32BE(rec), buf.readUInt32BE(rec + 4)]);
}
} else {
const segCount = buf.readUInt16BE(best + 6) / 2;
const endCodes = best + 14;
const startCodes = endCodes + segCount * 2 + 2;
for (let s = 0; s < segCount; s++) {
const start = buf.readUInt16BE(startCodes + s * 2);
const end = buf.readUInt16BE(endCodes + s * 2);
if (start !== 0xffff) ranges.push([start, end]);
}
}
return (codepoint) =>
ranges.some(([start, end]) => codepoint >= start && codepoint <= end);
}

View File

@@ -0,0 +1,145 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Draw all scoreboard ROIs on a (normalized) frame for visual calibration.
* Usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-battle-log-replay|scoreboard-battle-log]
*/
import { loadOpenCV, type Mat } from "../../app/features/scanner/core/cv";
import * as death from "../../app/features/scanner/core/detectors/death/rois";
import * as mapStart from "../../app/features/scanner/core/detectors/map-start/rois";
import * as minimap from "../../app/features/scanner/core/detectors/minimap/rois";
import * as sb from "../../app/features/scanner/core/detectors/scoreboard/rois";
import * as bl from "../../app/features/scanner/core/detectors/scoreboard-battle-log/rois";
import * as replay from "../../app/features/scanner/core/detectors/scoreboard-battle-log-replay/rois";
import {
matToFrameData,
normalizeFrame,
type Roi,
toMat,
} from "../../app/features/scanner/core/image";
import { readImage, writePng } from "../../app/features/scanner/node/image-io";
const [imagePath, outPath = "roi-overlay.png", detector = "scoreboard"] =
process.argv.slice(2);
if (!imagePath) {
console.error(
"usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-battle-log-replay|scoreboard-battle-log|death|map-start|minimap]",
);
process.exit(1);
}
const cv = await loadOpenCV();
const src = toMat(await readImage(imagePath));
const frame = normalizeFrame(src);
src.delete();
function rect(m: Mat, roi: Roi, color: [number, number, number]) {
cv.rectangle(
m,
new cv.Point(roi.x, roi.y),
new cv.Point(roi.x + roi.w, roi.y + roi.h),
new cv.Scalar(...color, 255),
1,
);
}
if (detector === "scoreboard") {
for (const cy of sb.ROW_CENTERS) {
rect(frame, sb.weaponRoi(cy), [255, 0, 0]);
rect(frame, sb.nameRoi(cy), [0, 255, 0]);
rect(frame, sb.paintRoi(cy), [0, 128, 255]);
for (const i of [0, 1, 2] as const)
rect(frame, sb.statRoi(cy, i), [255, 0, 255]);
rect(frame, sb.gateDarkProbe(cy), [255, 255, 0]);
}
for (const roi of sb.TEAM_SCORE_ROIS) rect(frame, roi, [0, 128, 255]);
for (const roi of sb.GATE_PANEL_PROBES) rect(frame, roi, [255, 255, 0]);
} else if (detector === "scoreboard-battle-log-replay") {
for (const dx of replay.PANEL_XS) {
for (const cy of replay.ROW_CENTERS) {
rect(frame, replay.weaponRoi(cy, dx), [255, 0, 0]);
rect(frame, replay.nameRoi(cy, dx), [0, 255, 0]);
rect(frame, replay.paintRoi(cy, dx), [0, 128, 255]);
rect(frame, replay.paintSuffixRoi(cy, dx), [0, 255, 255]);
for (const i of [0, 1, 2] as const)
rect(frame, replay.statRoi(cy, dx, i), [255, 0, 255]);
rect(frame, replay.gateFlatProbe(cy, dx), [255, 255, 0]);
}
rect(frame, replay.teamScoreRoi(dx), [0, 128, 255]);
rect(frame, replay.resultTagRoi(dx), [255, 128, 0]);
}
for (const roi of replay.MATCH_SCORE_ROIS) rect(frame, roi, [0, 128, 255]);
for (const roi of replay.GATE_GAP_PROBES) rect(frame, roi, [255, 255, 0]);
rect(frame, replay.HEADER_TOP_BAND, [0, 255, 0]);
rect(frame, replay.HEADER_BOTTOM_BAND, [0, 255, 0]);
rect(frame, replay.REPLAY_CODE_ROI, [0, 255, 0]);
} else if (detector === "scoreboard-battle-log") {
for (const dy of bl.PANEL_DYS) {
for (const base of bl.ROW_CENTERS) {
const cy = base + dy;
rect(frame, bl.weaponRoi(cy), [255, 0, 0]);
rect(frame, bl.nameRoi(cy), [0, 255, 0]);
rect(frame, bl.paintRoi(cy), [0, 128, 255]);
rect(frame, bl.paintSuffixRoi(cy), [0, 255, 255]);
for (const i of [0, 1, 2] as const)
rect(frame, bl.statRoi(cy, i), [255, 0, 255]);
rect(frame, bl.gateDarkProbe(cy), [255, 255, 0]);
rect(frame, bl.povArrowRoi(cy), [255, 128, 0]);
rect(frame, bl.specialIconRoi(cy), [255, 0, 0]);
}
rect(frame, bl.teamScoreRoi(dy), [0, 128, 255]);
rect(frame, bl.resultTagRoi(dy), [255, 128, 0]);
}
for (const roi of bl.MATCH_SCORE_ROIS) rect(frame, roi, [0, 128, 255]);
for (const roi of bl.GATE_COLOR_PROBES) rect(frame, roi, [255, 255, 0]);
rect(frame, bl.HEADER_TOP_BAND, [0, 255, 0]);
rect(frame, bl.HEADER_BOTTOM_BAND, [0, 255, 0]);
} else if (detector === "death") {
rect(frame, death.SPLAT_LINE1_ROI, [0, 255, 0]);
rect(frame, death.WEAPON_LINE_ROI, [255, 0, 0]);
for (let row = 0; row < death.ABILITY_ROWS; row++) {
rect(frame, death.abilityMainRoi(row), [0, 128, 255]);
for (const slot of [0, 1, 2])
rect(frame, death.abilitySubRoi(row, slot), [255, 0, 255]);
}
rect(frame, death.TAG_NAME_OUTER, [0, 255, 0]);
for (const roi of [...death.GATE_BURST_PROBES, ...death.GATE_PANEL_PROBES]) {
rect(frame, roi, [255, 255, 0]);
}
} else if (detector === "map-start") {
rect(frame, mapStart.MODE_LABEL_ROI, [0, 255, 0]);
rect(frame, mapStart.MODE_BLOCK_ROI, [255, 0, 0]);
rect(frame, mapStart.STAGE_ROI, [0, 128, 255]);
rect(frame, mapStart.GATE_INK_BAND, [0, 255, 255]);
for (const roi of mapStart.GATE_DARK_PROBES) rect(frame, roi, [255, 255, 0]);
} else if (detector === "minimap") {
for (const card of minimap.CARD_LAYOUTS) {
rect(frame, card.name, [0, 255, 0]);
rect(frame, card.weapon, [255, 0, 0]);
rect(frame, card.subTile, [0, 255, 255]);
for (const [cx, cy] of card.badges)
rect(frame, minimap.badgeRoi(cx, cy), [0, 128, 255]);
rect(frame, card.cross, [255, 0, 255]);
}
for (const cy of minimap.ENEMY_ROW_CYS) {
rect(frame, minimap.enemyWeaponRoi(cy), [255, 0, 0]);
rect(frame, minimap.enemySubTileRoi(cy), [0, 255, 255]);
for (const cx of minimap.ENEMY_BADGE_XS)
rect(frame, minimap.badgeRoi(cx, cy), [0, 128, 255]);
rect(frame, minimap.enemyCrossRoi(cy), [255, 0, 255]);
}
for (const roi of [
minimap.GATE_CLOSE_BRIGHT,
minimap.GATE_SPAWN_BRIGHT,
...minimap.GATE_CLOSE_DARK_PROBES,
...minimap.GATE_SPAWN_DARK_PROBES,
]) {
rect(frame, roi, [255, 255, 0]);
}
} else {
console.error(`unknown detector "${detector}"`);
process.exit(1);
}
writePng(outPath, matToFrameData(frame));
frame.delete();
console.info(`wrote ${outPath}`);

View File

@@ -0,0 +1,82 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* CLI harness: replay a directory of extracted VoD frames through the full
* detector registry driven by a DetectorScheduler, mirroring the analyzer
* worker's chunk scan — to reproduce scheduling-dependent misses offline.
* This is the tool for "the browser scan missed an event that a fixture
* parses fine": extract the surrounding footage with
* ffmpeg -ss <startT> -i vod.mkv -t 30 -vf fps=6 frames/f%04d.png
* then replay it and watch which checks the scheduler ran and what they saw.
*
* Usage: pnpm scanner:replay <framesDir> <startT> <fps>
* (frames are ffmpeg-numbered f0001.png..; t = startT + (n-1)/fps)
*/
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { loadOpenCV } from "../../app/features/scanner/core/cv";
import { MAP_START_EVENT_TYPE } from "../../app/features/scanner/core/detectors/map-start/index";
import {
createAllDetectors,
SCOREBOARD_EVENT_TYPES,
} from "../../app/features/scanner/core/detectors/registry";
import { DetectorScheduler } from "../../app/features/scanner/core/detectors/scheduler";
import { normalizeFrame, toMat } from "../../app/features/scanner/core/image";
import { readImage } from "../../app/features/scanner/node/image-io";
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
const [framesDir, startTArg, fpsArg] = process.argv.slice(2);
if (!framesDir || !startTArg || !fpsArg) {
console.error("usage: pnpm scanner:replay <framesDir> <startT> <fps>");
process.exit(1);
}
const startT = Number(startTArg);
const fps = Number(fpsArg);
await loadOpenCV();
const detectors = createAllDetectors(await loadScoreboardResources());
const scheduler = new DetectorScheduler(detectors, {
matchOpeningTypes: [MAP_START_EVENT_TYPE],
matchClosingTypes: SCOREBOARD_EVENT_TYPES,
});
scheduler.reset(startT);
const files = readdirSync(framesDir)
.filter((f) => f.endsWith(".png"))
.sort();
for (const [i, file] of files.entries()) {
const t = startT + i / fps;
const due = scheduler.dueDetectors(t);
if (due.length === 0) continue;
const image = await readImage(join(framesDir, file));
const src = toMat(image);
const frame = normalizeFrame(src);
src.delete();
for (const detector of detectors) {
if (!due.includes(detector.id)) continue;
const gate = detector.gate(frame);
scheduler.recordGate(detector.id, t, gate.pass, gate.signature);
if (!gate.pass) {
if (process.env.LOG_GATES?.includes(detector.id)) {
console.log(
`${t.toFixed(2)} ${file} ${detector.id} gate=${gate.score.toFixed(3)} FAIL`,
);
}
continue;
}
if (!scheduler.shouldParse(detector.id, t)) {
console.log(
`${t.toFixed(2)} ${file} ${detector.id} gate=${gate.score.toFixed(3)} PARSE-SUPPRESSED`,
);
continue;
}
const events = detector.parse(frame, t, gate);
scheduler.recordParse(detector.id, t, events);
console.log(
`${t.toFixed(2)} ${file} ${detector.id} gate=${gate.score.toFixed(3)} events=[${events
.map((e) => `${e.type}@${e.confidence.toFixed(3)}`)
.join(", ")}]`,
);
}
frame.delete();
}

View File

@@ -0,0 +1,528 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Accuracy report across all fixtures — separate from pass/fail testing.
* Runs each detector over its own fixture directory and prints aggregate
* per-field accuracy plus character error rate (CER) for names, the metric
* that drives glyph atlas expansion.
*
* Usage: pnpm scanner:report
*/
import { loadOpenCV } from "../../app/features/scanner/core/cv";
import {
createDeathDetector,
type DeathData,
} from "../../app/features/scanner/core/detectors/death/index";
import {
createMapStartDetector,
type MapStartData,
} from "../../app/features/scanner/core/detectors/map-start/index";
import {
createMinimapDetector,
type MinimapData,
} from "../../app/features/scanner/core/detectors/minimap/index";
import { createScoreboardDetector } from "../../app/features/scanner/core/detectors/scoreboard/index";
import { createScoreboardBattleLogDetector } from "../../app/features/scanner/core/detectors/scoreboard-battle-log/index";
import {
createScoreboardBattleLogReplayDetector,
type ScoreboardBattleLogReplayData,
} from "../../app/features/scanner/core/detectors/scoreboard-battle-log-replay/index";
import {
createScoreboardOwnDetector,
type ScoreboardOwnData,
} from "../../app/features/scanner/core/detectors/scoreboard-own/index";
import type { Detector } from "../../app/features/scanner/core/detectors/types";
import {
loadFixtures,
runDetectorOnFixture,
} from "../../app/features/scanner/node/fixtures";
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
await loadOpenCV();
const resources = await loadScoreboardResources();
interface Config {
label: string;
detector: Detector<Partial<ScoreboardBattleLogReplayData>>;
fixturesDir: string;
event: string;
}
const configs: Config[] = [
{
label: "scoreboard",
detector: createScoreboardDetector(resources),
fixturesDir: "scoreboard",
event: "Scoreboard",
},
{
label: "scoreboard-battle-log-replay",
detector: createScoreboardBattleLogReplayDetector(resources),
fixturesDir: "scoreboard-battle-log-replay",
event: "ScoreboardBattleLogReplay",
},
{
label: "scoreboard-battle-log",
detector: createScoreboardBattleLogDetector(resources),
fixturesDir: "scoreboard-battle-log",
event: "ScoreboardBattleLog",
},
];
interface Tally {
ok: number;
total: number;
}
/** Levenshtein distance for CER. */
function editDistance(a: string, b: string): number {
const dp = Array.from({ length: a.length + 1 }, (_, i) => {
const row = new Array<number>(b.length + 1).fill(0);
row[0] = i;
return row;
});
for (let j = 0; j <= b.length; j++) dp[0]![j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
dp[i]![j] = Math.min(
dp[i - 1]![j]! + 1,
dp[i]![j - 1]! + 1,
dp[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
);
}
}
return dp[a.length]![b.length]!;
}
function pct(t: Tally): string {
if (t.total === 0) return " n/a";
return `${((100 * t.ok) / t.total).toFixed(1).padStart(5)}% (${t.ok}/${t.total})`;
}
for (const config of configs) {
// Shared negatives count toward gate accuracy (expectPositive is false).
const fixtures = [
...loadFixtures(config.fixturesDir),
...loadFixtures("negative"),
];
const tally = {
gate: { ok: 0, total: 0 } as Tally,
header: { ok: 0, total: 0 } as Tally,
timestamp: { ok: 0, total: 0 } as Tally,
replayCode: { ok: 0, total: 0 } as Tally,
matchScores: { ok: 0, total: 0 } as Tally,
weapons: { ok: 0, total: 0 } as Tally,
names: { ok: 0, total: 0 } as Tally,
paint: { ok: 0, total: 0 } as Tally,
stats: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
const nameMisses: string[] = [];
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture(
config.detector,
fixture,
);
const expectPositive = fixture.expected.event === config.event;
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.mode !== undefined) {
tally.header.total++;
if (
event.data.lobby === (expected.lobby ?? null) &&
event.data.mode === (expected.mode ?? null) &&
event.data.stage === (expected.stage ?? null)
) {
tally.header.ok++;
}
}
if (expected.timestamp !== undefined) {
tally.timestamp.total++;
if (event.data.timestamp === expected.timestamp) tally.timestamp.ok++;
}
if (expected.replayCode !== undefined) {
tally.replayCode.total++;
if (event.data.replayCode === expected.replayCode) tally.replayCode.ok++;
}
if (expected.matchScores) {
tally.matchScores.total++;
if (
JSON.stringify(event.data.matchScores) ===
JSON.stringify(expected.matchScores)
) {
tally.matchScores.ok++;
}
}
(expected.players ?? []).forEach((want, i) => {
const got = event.data.players?.[i];
if (!got) return;
if (want.weaponId !== undefined) {
tally.weapons.total++;
if (got.weaponId === want.weaponId) tally.weapons.ok++;
}
if (want.name !== undefined) {
tally.names.total++;
const dist = editDistance(got.name, want.name);
charEdits += dist;
charTotal += want.name.length;
if (dist === 0) tally.names.ok++;
else
nameMisses.push(
`${fixture.name} row${i}: "${got.name}" != "${want.name}"`,
);
}
if (want.paint !== undefined) {
tally.paint.total++;
if (got.paint === want.paint) tally.paint.ok++;
}
if (want.ka !== undefined) {
tally.stats.total++;
if (
got.ka === want.ka &&
got.d === (want.d ?? null) &&
got.s === (want.s ?? null)
) {
tally.stats.ok++;
}
}
});
}
console.info(`\n=== ${config.label} (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`header ${pct(tally.header)}`);
if (tally.timestamp.total > 0) {
console.info(`timestamp ${pct(tally.timestamp)}`);
}
if (tally.replayCode.total > 0) {
console.info(`replayCode ${pct(tally.replayCode)}`);
}
console.info(`matchScores ${pct(tally.matchScores)}`);
console.info(`weapons ${pct(tally.weapons)}`);
console.info(`names ${pct(tally.names)}`);
console.info(`paint ${pct(tally.paint)}`);
console.info(`stats ${pct(tally.stats)}`);
console.info(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);
if (nameMisses.length > 0) {
console.info("name misses:");
for (const m of nameMisses) console.info(` ${m}`);
}
}
// Map-start fixtures only carry mode + stage, so they get their own pass.
{
const detector = createMapStartDetector(resources);
const fixtures = loadFixtures("map-start");
const tally = {
gate: { ok: 0, total: 0 } as Tally,
mode: { ok: 0, total: 0 } as Tally,
stage: { ok: 0, total: 0 } as Tally,
};
const misses: string[] = [];
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<MapStartData>(
detector,
fixture,
);
const expectPositive = fixture.expected.event === "MapStart";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.mode !== undefined) {
tally.mode.total++;
if (event.data.mode === expected.mode) tally.mode.ok++;
else
misses.push(
`${fixture.name}: mode "${event.data.mode}" != "${expected.mode}"`,
);
}
if (expected.stage !== undefined) {
tally.stage.total++;
if (event.data.stage === expected.stage) tally.stage.ok++;
else
misses.push(
`${fixture.name}: stage "${event.data.stage}" != "${expected.stage}"`,
);
}
}
console.info(`\n=== map-start (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`mode ${pct(tally.mode)}`);
console.info(`stage ${pct(tally.stage)}`);
if (misses.length > 0) {
console.info("misses:");
for (const m of misses) console.info(` ${m}`);
}
}
// Scoreboard-own fixtures carry header + own weapon + ability grid.
{
const detector = createScoreboardOwnDetector(resources);
const fixtures = [
...loadFixtures("scoreboard-own"),
...loadFixtures("negative"),
];
const tally = {
gate: { ok: 0, total: 0 } as Tally,
header: { ok: 0, total: 0 } as Tally,
weapon: { ok: 0, total: 0 } as Tally,
abilities: { ok: 0, total: 0 } as Tally,
};
const misses: string[] = [];
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<ScoreboardOwnData>(
detector,
fixture,
);
const expectPositive = fixture.expected.event === "ScoreboardOwn";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.mode !== undefined) {
tally.header.total++;
if (
event.data.lobby === (expected.lobby ?? null) &&
event.data.mode === (expected.mode ?? null) &&
event.data.stage === (expected.stage ?? null)
) {
tally.header.ok++;
} else {
misses.push(
`${fixture.name}: header "${event.data.lobby}/${event.data.mode}/${event.data.stage}"`,
);
}
}
if (expected.weaponId !== undefined) {
tally.weapon.total++;
if (event.data.weaponId === expected.weaponId) tally.weapon.ok++;
else
misses.push(
`${fixture.name}: weapon ${event.data.weaponId} != ${expected.weaponId} ("${expected.weaponLabel ?? ""}")`,
);
}
(expected.abilities ?? []).forEach((wantRow, row) => {
wantRow.forEach((want, slot) => {
tally.abilities.total++;
const got = event.data.abilities[row]?.[slot];
if (got === want) tally.abilities.ok++;
else
misses.push(
`${fixture.name}: ability [${row}][${slot}] "${got}" != "${want}"`,
);
});
});
}
console.info(`\n=== scoreboard-own (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`header ${pct(tally.header)}`);
console.info(`weapon ${pct(tally.weapon)}`);
console.info(`abilities ${pct(tally.abilities)}`);
if (misses.length > 0) {
console.info("misses:");
for (const m of misses) console.info(` ${m}`);
}
}
// Minimap fixtures carry teammates/enemies, so they get their own pass.
{
const detector = createMinimapDetector(resources);
const fixtures = [...loadFixtures("minimap"), ...loadFixtures("negative")];
const tally = {
gate: { ok: 0, total: 0 } as Tally,
weapons: { ok: 0, total: 0 } as Tally,
names: { ok: 0, total: 0 } as Tally,
abilities: { ok: 0, total: 0 } as Tally,
stage: { ok: 0, total: 0 } as Tally,
status: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
const misses: string[] = [];
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<MinimapData>(
detector,
fixture,
);
const expectPositive = fixture.expected.event === "Minimap";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
const sides: [
string,
{
name?: string | null;
weaponId?: number | null;
abilities?: (string | null)[];
dead?: boolean;
specialReady?: boolean;
}[],
{
name?: string | null;
weaponId: number | null;
abilities: (string | null)[];
dead: boolean;
specialReady: boolean;
}[],
][] = [
["teammate", expected.teammates ?? [], event.data.teammates],
["enemy", expected.enemies ?? [], event.data.enemies],
];
for (const [side, wants, gots] of sides) {
wants.forEach((want, i) => {
const got = gots[i];
if (!got) return;
if (want.weaponId !== undefined) {
tally.weapons.total++;
if (got.weaponId === want.weaponId) tally.weapons.ok++;
else
misses.push(
`${fixture.name} ${side}${i}: weapon ${got.weaponId} != ${want.weaponId}`,
);
}
if (want.name !== undefined && want.name !== null) {
tally.names.total++;
const gotName = got.name ?? "";
const dist = editDistance(gotName, want.name);
charEdits += dist;
charTotal += want.name.length;
if (dist === 0) tally.names.ok++;
else
misses.push(
`${fixture.name} ${side}${i}: name "${gotName}" != "${want.name}"`,
);
}
(want.abilities ?? []).forEach((wantId, slot) => {
tally.abilities.total++;
const gotId = got.abilities[slot] ?? null;
if (gotId === wantId) tally.abilities.ok++;
else
misses.push(
`${fixture.name} ${side}${i}: ability [${slot}] "${gotId}" != "${wantId}"`,
);
});
for (const flag of ["dead", "specialReady"] as const) {
if (want[flag] === undefined) continue;
tally.status.total++;
if (got[flag] === want[flag]) tally.status.ok++;
else
misses.push(
`${fixture.name} ${side}${i}: ${flag} ${got[flag]} != ${want[flag]}`,
);
}
});
}
if (expected.stage !== undefined) {
tally.stage.total++;
if (event.data.stage === expected.stage) tally.stage.ok++;
else
misses.push(
`${fixture.name}: stage "${event.data.stage}" != "${expected.stage}"`,
);
}
}
console.info(`\n=== minimap (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`weapons ${pct(tally.weapons)}`);
console.info(`names ${pct(tally.names)}`);
console.info(`abilities ${pct(tally.abilities)}`);
console.info(`stage ${pct(tally.stage)}`);
console.info(`status ${pct(tally.status)}`);
console.info(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);
if (misses.length > 0) {
console.info("misses:");
for (const m of misses) console.info(` ${m}`);
}
}
// Death fixtures carry a different data shape (weapon text, ability grid,
// splash-tag name), so they get their own pass instead of a Config entry.
{
const detector = createDeathDetector(resources);
const fixtures = loadFixtures("death");
const tally = {
gate: { ok: 0, total: 0 } as Tally,
weapon: { ok: 0, total: 0 } as Tally,
abilities: { ok: 0, total: 0 } as Tally,
names: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
const misses: string[] = [];
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<DeathData>(
detector,
fixture,
);
const expectPositive = fixture.expected.event === "Death";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.weaponId !== undefined) {
tally.weapon.total++;
if (event.data.weaponId === expected.weaponId) tally.weapon.ok++;
else
misses.push(
`${fixture.name}: weapon ${event.data.weaponId} != ${expected.weaponId} ("${expected.weaponLabel ?? ""}")`,
);
}
(expected.abilities ?? []).forEach((wantRow, row) => {
wantRow.forEach((want, slot) => {
tally.abilities.total++;
const got = event.data.abilities[row]?.[slot];
if (got === want) tally.abilities.ok++;
else
misses.push(
`${fixture.name}: ability [${row}][${slot}] "${got}" != "${want}"`,
);
});
});
if (expected.name !== undefined) {
tally.names.total++;
const got = event.data.name ?? "";
const dist = editDistance(got, expected.name);
charEdits += dist;
charTotal += expected.name.length;
if (dist === 0) tally.names.ok++;
else misses.push(`${fixture.name}: name "${got}" != "${expected.name}"`);
}
}
console.info(`\n=== death (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`weapon ${pct(tally.weapon)}`);
console.info(`abilities ${pct(tally.abilities)}`);
console.info(`names ${pct(tally.names)}`);
console.info(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);
if (misses.length > 0) {
console.info("misses:");
for (const m of misses) console.info(` ${m}`);
}
}

View File

@@ -0,0 +1,52 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* CLI harness: run the ScoreboardDetector over fixtures and print everything
* it saw — gate result, parsed fields, per-field scores, top weapon candidates.
*
* Usage: pnpm scanner:fixtures [case-name-substring]
*/
import { loadOpenCV } from "../../app/features/scanner/core/cv";
import {
createScoreboardDetector,
type ScoreboardRowDebug,
} from "../../app/features/scanner/core/detectors/scoreboard/index";
import {
loadFixtures,
runDetectorOnFixture,
} from "../../app/features/scanner/node/fixtures";
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
const filter = process.argv[2];
await loadOpenCV();
const detector = createScoreboardDetector(await loadScoreboardResources());
const fixtures = loadFixtures("scoreboard").filter(
(f) => !filter || f.name.includes(filter),
);
if (fixtures.length === 0) {
console.error("no fixtures matched");
process.exit(1);
}
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture(detector, fixture);
console.info(`\n=== ${fixture.name}`);
console.info(`gate: pass=${gate.pass} score=${gate.score.toFixed(3)}`);
for (const event of events) {
console.info(`event confidence=${event.confidence.toFixed(3)}`);
console.info(`matchScores: ${JSON.stringify(event.data.matchScores)}`);
const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[];
event.data.players.forEach((p, i) => {
const dbg = rows[i];
const top = dbg?.weapon?.top
.map((t) => `${t.id}:${t.score.toFixed(2)}`)
.join(" ");
console.info(
` row${i}: name="${p.name}"(${dbg?.nameScore.toFixed(2)}) ` +
`weapon=${p.weaponId} [${top}] paint=${p.paint}(${dbg?.paintScore.toFixed(2)}) ` +
`ka=${p.ka} d=${p.d} s=${p.s} statScores=${dbg?.statScores.map((s) => s.toFixed(2)).join(",")}`,
);
});
}
}

View File

@@ -0,0 +1,321 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* CLI equivalent of the VoD tab: scan a video file with the full detector
* registry and write the same events CSV the tab's Export menu downloads.
* ffmpeg decodes the video to raw RGBA frames piped through the
* DetectorScheduler + detectors, and every parse event goes through a
* TimelineBuilder with the tab's default merge/confidence options — so the
* CSV matches a browser scan of the same footage (minus the calm-stretch
* keyframe skimming, which only affects speed, not results).
*
* Requires ffmpeg (and ffprobe for the progress percentage) on PATH.
*
* Usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry]
* --telemetry prints the VoD tab's ?telemetry=true scan counters after the
* run (per-detector gate/parse time, scheduling savings).
*/
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
import { basename } from "node:path";
import {
type CsvEvent,
eventsToCsv,
} from "../../app/features/scanner/components/events-csv";
import { loadOpenCV } from "../../app/features/scanner/core/cv";
import { MAP_START_EVENT_TYPE } from "../../app/features/scanner/core/detectors/map-start/index";
import {
createAllDetectors,
SCOREBOARD_EVENT_TYPES,
} from "../../app/features/scanner/core/detectors/registry";
import { DetectorScheduler } from "../../app/features/scanner/core/detectors/scheduler";
import {
createScanTelemetry,
detectorTelemetry,
} from "../../app/features/scanner/core/detectors/telemetry";
import { normalizeFrame, toMat } from "../../app/features/scanner/core/image";
import { TimelineBuilder } from "../../app/features/scanner/core/timeline/index";
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
const FRAME_WIDTH = 1920;
const FRAME_HEIGHT = 1080;
const FRAME_BYTES = FRAME_WIDTH * FRAME_HEIGHT * 4;
/** Slightly over the scheduler's densest cadence (refineIntervalS 0.15s). */
const DEFAULT_FPS = 8;
const PROGRESS_INTERVAL_SECONDS = 60;
const options = parseArgs(process.argv.slice(2));
if (!options) {
console.error(
"usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry]",
);
process.exit(1);
}
const { videoPath, fps, start, duration, outPath, collectTelemetry } = options;
await loadOpenCV();
const detectors = createAllDetectors(await loadScoreboardResources());
const scheduler = new DetectorScheduler(detectors, {
matchOpeningTypes: [MAP_START_EVENT_TYPE],
matchClosingTypes: SCOREBOARD_EVENT_TYPES,
});
scheduler.reset(start);
const timeline = new TimelineBuilder();
const telemetry = collectTelemetry ? createScanTelemetry() : null;
const totalSeconds = await probeDurationSeconds(videoPath);
const scanEnd =
duration !== undefined
? start + duration
: totalSeconds !== null
? totalSeconds
: null;
const ffmpeg = spawn(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
...(start > 0 ? ["-ss", String(start)] : []),
"-i",
videoPath,
...(duration !== undefined ? ["-t", String(duration)] : []),
"-vf",
`fps=${fps},scale=${FRAME_WIDTH}:${FRAME_HEIGHT}`,
"-f",
"rawvideo",
"-pix_fmt",
"rgba",
"pipe:1",
],
{ stdio: ["ignore", "pipe", "inherit"] },
);
const frameBuffer = Buffer.alloc(FRAME_BYTES);
let frameFill = 0;
let frameIndex = 0;
let framesAnalyzed = 0;
let nextProgressT = start;
const startedAt = Date.now();
for await (const chunk of ffmpeg.stdout) {
let offset = 0;
while (offset < chunk.length) {
const take = Math.min(FRAME_BYTES - frameFill, chunk.length - offset);
chunk.copy(frameBuffer, frameFill, offset, offset + take);
frameFill += take;
offset += take;
if (frameFill < FRAME_BYTES) continue;
frameFill = 0;
processFrame(start + frameIndex / fps);
frameIndex++;
}
}
const exitCode = await new Promise<number | null>((resolve) =>
ffmpeg.on("close", resolve),
);
if (exitCode !== 0) {
console.error(`ffmpeg exited with code ${exitCode}`);
process.exit(1);
}
const csv = eventsToCsv(timeline.events as CsvEvent[]);
writeFileSync(outPath, csv);
printSummary();
function parseArgs(argv: string[]): {
videoPath: string;
fps: number;
start: number;
duration: number | undefined;
outPath: string;
collectTelemetry: boolean;
} | null {
let videoPath: string | undefined;
let fps = DEFAULT_FPS;
let start = 0;
let duration: number | undefined;
let outPath: string | undefined;
let collectTelemetry = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--fps") fps = Number(argv[++i]);
else if (arg === "--start") start = Number(argv[++i]);
else if (arg === "--duration") duration = Number(argv[++i]);
else if (arg === "--out") outPath = argv[++i];
else if (arg === "--telemetry") collectTelemetry = true;
else if (!arg.startsWith("--") && videoPath === undefined) videoPath = arg;
else return null;
}
if (
videoPath === undefined ||
Number.isNaN(fps) ||
fps <= 0 ||
Number.isNaN(start) ||
(duration !== undefined && Number.isNaN(duration))
) {
return null;
}
return {
videoPath,
fps,
start,
duration,
outPath:
outPath ?? `${basename(videoPath).replace(/\.[^.]+$/, "")}-events.csv`,
collectTelemetry,
};
}
function probeDurationSeconds(path: string): Promise<number | null> {
return new Promise((resolve) => {
const ffprobe = spawn(
"ffprobe",
[
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
path,
],
{ stdio: ["ignore", "pipe", "ignore"] },
);
let output = "";
ffprobe.stdout.on("data", (data) => {
output += data;
});
ffprobe.on("close", () => {
const seconds = Number.parseFloat(output.trim());
resolve(Number.isFinite(seconds) ? seconds : null);
});
ffprobe.on("error", () => resolve(null));
});
}
function processFrame(t: number): void {
if (t >= nextProgressT) {
const percent =
scanEnd === null
? ""
: ` (${Math.round(((t - start) / (scanEnd - start)) * 100)}%)`;
const rate = (t - start) / Math.max(0.001, (Date.now() - startedAt) / 1000);
console.error(
`scanning t=${Math.round(t)}s${percent} · ${rate.toFixed(1)}x realtime · ${timeline.events.length} events`,
);
nextProgressT += PROGRESS_INTERVAL_SECONDS;
}
if (telemetry) {
telemetry.decodedFrames++;
telemetry.activeVideoS += 1 / fps;
}
const due = scheduler.dueDetectors(t);
if (due.length === 0) return;
framesAnalyzed++;
if (telemetry) telemetry.analyzedFrames++;
const src = toMat({
width: FRAME_WIDTH,
height: FRAME_HEIGHT,
data: new Uint8ClampedArray(
frameBuffer.buffer,
frameBuffer.byteOffset,
FRAME_BYTES,
),
});
const frame = normalizeFrame(src);
src.delete();
for (const detector of detectors) {
if (!due.includes(detector.id)) continue;
const counters = telemetry
? detectorTelemetry(telemetry, detector.id)
: null;
const gateStart = counters ? performance.now() : 0;
const gate = detector.gate(frame);
if (counters) {
counters.checks++;
counters.gateMs += performance.now() - gateStart;
}
scheduler.recordGate(detector.id, t, gate.pass, gate.signature);
if (!gate.pass) continue;
if (counters) counters.gatePasses++;
if (!scheduler.shouldParse(detector.id, t)) {
if (counters) counters.suppressedParses++;
continue;
}
const parseStart = counters ? performance.now() : 0;
const events = detector.parse(frame, t, gate);
if (counters) {
counters.parses++;
counters.parseMs += performance.now() - parseStart;
}
scheduler.recordParse(detector.id, t, events);
for (const event of events) {
const action = timeline.push(event);
if (action.action === "added" || action.action === "replaced") {
console.error(
` event t=${event.t.toFixed(2)} ${event.type} conf=${event.confidence.toFixed(3)}`,
);
}
}
}
frame.delete();
}
function printSummary(): void {
const counts = new Map<string, number>();
for (const event of timeline.events) {
counts.set(event.type, (counts.get(event.type) ?? 0) + 1);
}
const countText =
[...counts.entries()].map(([type, n]) => `${type} ${n}`).join(", ") ||
"none";
console.error(
`analyzed ${framesAnalyzed}/${frameIndex} decoded frames in ${Math.round((Date.now() - startedAt) / 1000)}s`,
);
console.error(`timeline events: ${timeline.events.length} (${countText})`);
console.error(`wrote ${outPath}`);
if (telemetry) printTelemetry();
console.error(`next: pnpm scanner:status-audit ${outPath}`);
}
/** The VoD tab's ?telemetry=true panel, as an aligned stderr table. */
function printTelemetry(): void {
if (!telemetry) return;
telemetry.wallMs = Date.now() - startedAt;
console.error(
`telemetry · analyzed ${telemetry.analyzedFrames}/${telemetry.decodedFrames} decoded frames · ${(telemetry.wallMs / 1000).toFixed(1)}s wall · ${telemetry.activeVideoS.toFixed(0)}s video covered (dense, no skim in CLI)`,
);
const header = [
"detector",
"checks",
"gate pass",
"gate ms",
"parses",
"parse ms",
"suppressed",
];
const rows = Object.entries(telemetry.detectors)
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, d]) => [
id,
String(d.checks),
String(d.gatePasses),
String(Math.round(d.gateMs)),
String(d.parses),
String(Math.round(d.parseMs)),
String(d.suppressedParses),
]);
const widths = header.map((h, i) =>
Math.max(h.length, ...rows.map((row) => row[i]!.length)),
);
const line = (cells: string[]) =>
cells
.map((cell, i) =>
i === 0 ? cell.padEnd(widths[i]!) : cell.padStart(widths[i]!),
)
.join(" ");
console.error(line(header));
for (const row of rows) console.error(line(row));
}

View File

@@ -0,0 +1,930 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Audit death/special detection against scoreboard truth, from an events CSV
* downloaded from the scanner UI (Live/VoD tab → Download CSV). The CSV rows
* are parsed back into DetectedEvents, run through the real match builder,
* and rendered into the same status spans the timeline would draw — then
* each player's span counts are diffed against the scoreboard's D/S numbers,
* which are near-always correct. A special span ending in a death (or held
* at the final whistle) is a legit non-use and does not count toward S.
*
* The CSV is lossy (no ink colors, only top-1 strip-weapon candidates), so
* cast-footage side orientation and slot→row assignment can degrade to
* their fallbacks; the output flags when a mismatch looks like a
* slot-mapping artifact rather than a detection error.
*
* Output is structured for triage: every discrepancy lists the exact
* read timestamps most likely to yield a new failing fixture.
*
* Usage: pnpm scanner:status-audit <events.csv> [--all]
*/
import { readFileSync } from "node:fs";
import { statusSpans } from "../../app/components/PlayerStatusTimeline";
import { formatTime } from "../../app/features/scanner/components/format";
import {
lobbyLabel,
mainWeaponLabel,
modeLabel,
stageLabel,
} from "../../app/features/scanner/components/labels";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../../app/features/scanner/core/detectors/death/index";
import { ALL_WEAPON_ENTRIES } from "../../app/features/scanner/core/detectors/death/weapon-names";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../../app/features/scanner/core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
type MinimapEnemy,
type MinimapTeammate,
} from "../../app/features/scanner/core/detectors/minimap/index";
import type { CardSlot } from "../../app/features/scanner/core/detectors/minimap/rois";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../../app/features/scanner/core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
type PlayerStatusFlags,
type PlayerStatusLayout,
} from "../../app/features/scanner/core/detectors/objective/player-status";
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../../app/features/scanner/core/detectors/objective/strip-weapons";
import {
SCOREBOARD_EVENT_TYPE,
type ScoreboardData,
type ScoreboardPlayer,
} from "../../app/features/scanner/core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../../app/features/scanner/core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../../app/features/scanner/core/detectors/scoreboard-battle-log-replay/index";
import type { DetectedEvent } from "../../app/features/scanner/core/detectors/types";
import {
type BuiltMatch,
buildScannerMatches,
} from "../../app/features/scanner/core/match-builder";
import type {
ScannerMatch,
ScannerMatchPlayerStatusSample,
} from "../../app/features/scanner/core/scanner-match";
import type { ScannerLobby } from "../../app/features/scanner/scanner-types";
import { modesShort } from "../../app/modules/in-game-lists/modes";
import { stageIds } from "../../app/modules/in-game-lists/stage-ids";
import type {
AbilityWithUnknown,
MainWeaponId,
ModeShort,
StageId,
} from "../../app/modules/in-game-lists/types";
import { mainWeaponIds } from "../../app/modules/in-game-lists/weapon-ids";
/** Mirrors PlayerStatusTimeline's MAX_BRIDGE_SECONDS: longer sample gaps render as unobserved. */
const OBSERVATION_GAP_SECONDS = 15;
/** Mirrors match-builder's DEAD_RUN_MIN_SECONDS: no true splat is shorter. */
const MIN_DEAD_SECONDS = 3.5;
const SPAN_EPSILON_SECONDS = 0.001;
const SCOREBOARD_TYPES = new Set([
SCOREBOARD_EVENT_TYPE,
SCOREBOARD_BATTLE_LOG_EVENT_TYPE,
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
]);
const CARD_SLOTS = new Set<CardSlot>(["up", "left", "right", "self", "down"]);
const MODE_BY_LABEL = new Map<string, ModeShort>(
modesShort.map((mode) => [modeLabel(mode) ?? mode, mode]),
);
const STAGE_BY_LABEL = new Map<string, StageId>(
stageIds.map((id) => [stageLabel(id) ?? String(id), id]),
);
const MAIN_WEAPON_BY_LABEL = new Map<string, MainWeaponId>(
mainWeaponIds.map((id) => [mainWeaponLabel(id) ?? String(id), id]),
);
const DEATH_WEAPON_BY_LABEL = new Map(
ALL_WEAPON_ENTRIES.map((entry) => [entry.name, entry]),
);
const LOBBY_BY_LABEL = new Map<string, ScannerLobby>(
(["X", "SERIES", "OPEN", "PRIVATE"] as const).map((lobby) => [
lobbyLabel(lobby) ?? lobby,
lobby,
]),
);
const args = process.argv.slice(2);
const csvPath = args.find((arg) => !arg.startsWith("--"));
const showAllSpans = args.includes("--all");
if (!csvPath) {
console.error("Usage: pnpm scanner:status-audit <events.csv> [--all]");
process.exit(1);
}
const rows = parseCsv(readFileSync(csvPath, "utf8"));
const header = rows[0] ?? [];
const col = (name: string) => header.indexOf(name);
const columns = {
type: col("type"),
t: col("t_seconds"),
confidence: col("confidence"),
lobby: col("lobby"),
mode: col("mode"),
stage: col("stage"),
winnerScore: col("winner_score"),
loserScore: col("loser_score"),
pov: col("pov"),
weapon: col("weapon"),
name: col("name"),
abilities: col("abilities"),
players: col("players"),
replayCode: col("replay_code"),
replayTimestamp: col("replay_timestamp"),
};
if (columns.type === -1 || columns.t === -1 || columns.players === -1) {
console.error(
`Not an events CSV (missing type/t_seconds/players columns): ${csvPath}`,
);
process.exit(1);
}
const events: DetectedEvent[] = [];
const skippedTypes = new Map<string, number>();
const parseFailures: string[] = [];
for (const row of rows.slice(1)) {
if (row.length === 0 || (row.length === 1 && row[0] === "")) continue;
const type = row[columns.type] ?? "";
try {
const event = eventFromRow(type, row);
if (event) events.push(event);
else skippedTypes.set(type, (skippedTypes.get(type) ?? 0) + 1);
} catch (error) {
parseFailures.push(
`t=${row[columns.t]} ${type}: ${error instanceof Error ? error.message : error}`,
);
}
}
const built = buildScannerMatches(events);
const candidates: FixtureCandidate[] = [];
printHeader();
for (const [index, builtMatch] of built.entries()) {
printMatch(index, builtMatch);
}
printCandidates();
printGuidance();
// ---------------------------------------------------------------------------
// CSV → DetectedEvent reconstruction
function parseCsv(text: string): string[][] {
const result: string[][] = [];
let row: string[] = [];
let cell = "";
let quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"';
i++;
} else if (ch === '"') {
quoted = false;
} else {
cell += ch;
}
} else if (ch === '"') {
quoted = true;
} else if (ch === ",") {
row.push(cell);
cell = "";
} else if (ch === "\n" || ch === "\r") {
if (ch === "\r" && text[i + 1] === "\n") i++;
row.push(cell);
result.push(row);
row = [];
cell = "";
} else {
cell += ch;
}
}
if (cell !== "" || row.length > 0) {
row.push(cell);
result.push(row);
}
return result;
}
function eventFromRow(type: string, row: string[]): DetectedEvent | null {
const base = {
t: Number(row[columns.t]),
confidence: Number(row[columns.confidence] || 0),
};
if (Number.isNaN(base.t)) throw new Error("unparseable t_seconds");
const players = row[columns.players] ?? "";
if (type === PLAYER_STATUS_EVENT_TYPE) {
return { type, ...base, data: parsePlayerStatusCell(players) };
}
if (type === OBJECTIVE_EVENT_TYPE) {
return { type, ...base, data: parseObjectiveCell(players) };
}
if (type === STRIP_WEAPONS_EVENT_TYPE) {
return { type, ...base, data: parseStripWeaponsCell(players) };
}
if (type === MINIMAP_EVENT_TYPE) {
return {
type,
...base,
data: parseMinimapCell(players, row[columns.stage] ?? ""),
};
}
if (type === MAP_START_EVENT_TYPE) {
const data: MapStartData = {
mode: MODE_BY_LABEL.get(row[columns.mode] ?? "") ?? null,
stage: STAGE_BY_LABEL.get(row[columns.stage] ?? "") ?? null,
};
return { type, ...base, data };
}
if (type === DEATH_EVENT_TYPE) {
return { type, ...base, data: parseDeathRow(row) };
}
if (SCOREBOARD_TYPES.has(type)) {
return { type, ...base, data: parseScoreboardRow(row) };
}
return null;
}
function parseClock(m: string, s: string): number {
return Number(m) * 60 + Number(s);
}
function parsePlayerStatusCell(cell: string): PlayerStatusData {
const match = cell.match(
/^(?:(\d+):(\d{2}) · )?([✕★·]{4}) vs ([✕★·]{4}) \((pov|cast|cast-mirror)\)$/u,
);
if (!match) throw new Error(`bad PlayerStatus cell: ${cell}`);
const [, m, s, left, right, layout] = match;
const sideFlags = (icons: string) => {
const chars = [...icons];
return {
dead: chars.map((c) => c === "✕") as PlayerStatusFlags,
special: chars.map((c) => c === "★") as PlayerStatusFlags,
};
};
const a = sideFlags(left!);
const b = sideFlags(right!);
return {
time: m === undefined ? null : parseClock(m, s!),
special: [a.special, b.special],
dead: [a.dead, b.dead],
layout: layout as PlayerStatusLayout,
};
}
function parseObjectiveCell(cell: string): ObjectiveData {
const match = cell.match(
/^(?:(\d+):(\d{2}) · )?(\d+|\?)(?: \(\+(\d+)\))?( ctrl)? vs (\d+|\?)(?: \(\+(\d+)\))?( ctrl)?$/u,
);
if (!match) throw new Error(`bad Objective cell: ${cell}`);
const [, m, s, scoreA, penA, ctrlA, scoreB, penB, ctrlB] = match;
const num = (v: string | undefined) =>
v === undefined || v === "?" ? null : Number(v);
return {
mode: "SZ",
time: m === undefined ? null : parseClock(m, s!),
score: [num(scoreA), num(scoreB)],
penalty: [num(penA), num(penB)],
control: [ctrlA !== undefined, ctrlB !== undefined],
teamColor: [null, null],
};
}
function parseStripWeaponsCell(cell: string): StripWeaponsData {
const match = cell.match(
/^(?:(\d+):(\d{2}) · )?(.*) vs (.*) \((pov|cast|cast-mirror)\)$/u,
);
if (!match) throw new Error(`bad StripWeapons cell: ${cell}`);
const [, m, s, left, right, layout] = match;
const side = (text: string) =>
text.split(" | ").map((entry) => {
if (entry === "✕") return null;
if (entry === "?") return [];
const weaponId = MAIN_WEAPON_BY_LABEL.get(entry);
return weaponId === undefined ? [] : [{ weaponId, score: 1 }];
});
return {
time: m === undefined ? null : parseClock(m, s!),
layout: layout as PlayerStatusLayout,
slots: [side(left!), side(right!)],
};
}
function parseAbilityTokens(text: string): (AbilityWithUnknown | null)[] {
if (text === "") return [];
return text
.split("+")
.map((token) => (token === "?" ? null : (token as AbilityWithUnknown)));
}
function parseMinimapCell(cell: string, stageCell: string): MinimapData {
const teammates: MinimapTeammate[] = [];
const enemies: MinimapEnemy[] = [];
for (const entry of cell === "" ? [] : cell.split("; ")) {
const parts = entry.split(" · ");
let specialReady = false;
let dead = false;
if (parts.at(-1) === "special") {
specialReady = true;
parts.pop();
}
if (parts.at(-1) === "splatted") {
dead = true;
parts.pop();
}
if (parts.length < 3) throw new Error(`bad Minimap entry: ${entry}`);
const abilities = parseAbilityTokens(parts.pop()!);
const weaponCell = parts.pop()!;
const weaponId =
weaponCell === "?"
? null
: (MAIN_WEAPON_BY_LABEL.get(weaponCell) ?? null);
const head = parts.join(" · ");
const spaceAt = head.indexOf(" ");
const label = spaceAt === -1 ? head : head.slice(0, spaceAt);
const rawName = spaceAt === -1 ? "" : head.slice(spaceAt + 1);
const name = rawName === "?" || rawName === "" ? null : rawName;
const player = { name, weaponId, abilities, dead, specialReady };
if (CARD_SLOTS.has(label as CardSlot)) {
teammates.push({ slot: label as CardSlot, ...player });
} else if (/^enemy[1-4]$/.test(label)) {
enemies.push(player);
} else {
throw new Error(`bad Minimap slot label: ${label}`);
}
}
return {
stage: STAGE_BY_LABEL.get(stageCell) ?? null,
spectator: enemies.some((enemy) => enemy.name !== null),
teammates,
enemies,
teamColors: [null, null],
};
}
function parseDeathRow(row: string[]): DeathData {
const weaponCell = row[columns.weapon] ?? "";
const entry = DEATH_WEAPON_BY_LABEL.get(weaponCell);
const abilitiesCell = row[columns.abilities] ?? "";
const nameCell = row[columns.name] ?? "";
return {
weaponId:
entry === undefined ? null : (Number(entry.id) as DeathData["weaponId"]),
weaponType: entry?.type ?? null,
abilities:
abilitiesCell === ""
? []
: abilitiesCell
.split(" | ")
.map(
(gearRow) => parseAbilityTokens(gearRow) as AbilityWithUnknown[],
),
name: nameCell === "" ? null : nameCell,
};
}
function parseScoreboardRow(row: string[]): ScoreboardData & {
replayCode?: string | null;
timestamp?: string | null;
} {
const players: ScoreboardPlayer[] = [];
const cell = row[columns.players] ?? "";
for (const entry of cell === "" ? [] : cell.split("; ")) {
const parts = entry.split(" · ");
const stats = parts
.pop()
?.match(/^(\d+|\?)p (\d+|\?)\/(\d+|\?)\/(\d+|\?)$/u);
const weaponCell = parts.pop();
const head = parts.join(" · ");
if (!stats || weaponCell === undefined || !/^[WL] /.test(head)) {
throw new Error(`bad Scoreboard player entry: ${entry}`);
}
const num = (v: string) => (v === "?" ? null : Number(v));
players.push({
name: head.slice(2),
weaponId:
weaponCell === "?" ? null : (Number(weaponCell) as MainWeaponId),
paint: num(stats[1]!),
ka: num(stats[2]!),
d: num(stats[3]!),
s: num(stats[4]!),
});
}
const score = (cellValue: string | undefined) =>
cellValue === undefined || cellValue === "" ? null : Number(cellValue);
const povName = row[columns.pov] ?? "";
const povIndex = players.findIndex((p) => p.name === povName);
const replayCode = row[columns.replayCode] ?? "";
const timestamp = row[columns.replayTimestamp] ?? "";
return {
lobby: LOBBY_BY_LABEL.get(row[columns.lobby] ?? "") ?? null,
mode: MODE_BY_LABEL.get(row[columns.mode] ?? "") ?? null,
stage: STAGE_BY_LABEL.get(row[columns.stage] ?? "") ?? null,
matchScores: [
score(row[columns.winnerScore]),
score(row[columns.loserScore]),
],
players,
povIndex: povName === "" || povIndex === -1 ? null : povIndex,
replayCode: replayCode === "" ? null : replayCode,
timestamp: timestamp === "" ? null : timestamp,
};
}
// ---------------------------------------------------------------------------
// Analysis
interface FixtureCandidate {
score: number;
t: number;
matchIndex: number;
description: string;
reads: number[];
}
interface StatusSpan {
start: number;
end: number;
/** sample timestamps that read the flag true inside the span */
confirmingReads: number[];
/**
* widest the true state could really have held: from the last false read
* before the span to the false read that closed it (the builder's
* flank-to-flank measure); the rendered span bounds where a flank is
* unobserved (series edge or gap-split)
*/
maxPossibleSeconds: number;
}
interface SlotAnalysis {
side: 0 | 1;
slot: number;
deadSpans: StatusSpan[];
specialSpans: StatusSpan[];
specialUses: number;
diedWithSpecial: number;
heldAtEnd: boolean;
unknownSpecialEnds: number;
}
function annotatedSpans(
samples: readonly ScannerMatchPlayerStatusSample[],
flagOf: (sample: ScannerMatchPlayerStatusSample) => boolean,
): StatusSpan[] {
return statusSpans(samples, flagOf).map((span) => {
const confirmingReads = samples
.filter(
(sample) =>
flagOf(sample) &&
sample.t >= span.start - SPAN_EPSILON_SECONDS &&
sample.t <= span.end + SPAN_EPSILON_SECONDS,
)
.map((sample) => sample.t);
const prev = samples.findLast(
(sample) => sample.t < span.start - SPAN_EPSILON_SECONDS,
);
const closedByFalseRead = samples.some(
(sample) =>
!flagOf(sample) &&
Math.abs(sample.t - span.end) <= SPAN_EPSILON_SECONDS,
);
const boundedBefore = prev !== undefined && !flagOf(prev);
return {
start: span.start,
end: span.end,
confirmingReads,
maxPossibleSeconds:
boundedBefore && closedByFalseRead
? span.end - prev.t
: Number.POSITIVE_INFINITY,
};
});
}
function analyzeSlot(
samples: readonly ScannerMatchPlayerStatusSample[],
side: 0 | 1,
slot: number,
): SlotAnalysis {
const deadSpans = annotatedSpans(samples, (s) => s.dead[side][slot]!);
const specialSpans = annotatedSpans(samples, (s) => s.special[side][slot]!);
let specialUses = 0;
let diedWithSpecial = 0;
let unknownSpecialEnds = 0;
for (let i = 1; i < samples.length; i++) {
const prev = samples[i - 1]!;
const cur = samples[i]!;
if (!prev.special[side][slot] || cur.special[side][slot]) continue;
if (cur.t - prev.t > OBSERVATION_GAP_SECONDS) unknownSpecialEnds++;
else if (cur.dead[side][slot]) diedWithSpecial++;
else specialUses++;
}
const last = samples.at(-1);
const heldAtEnd = last !== undefined && last.special[side][slot] === true;
return {
side,
slot,
deadSpans,
specialSpans,
specialUses,
diedWithSpecial,
heldAtEnd,
unknownSpecialEnds,
};
}
function observationGaps(
samples: readonly ScannerMatchPlayerStatusSample[],
): StatusSpan[] {
const gaps: StatusSpan[] = [];
for (let i = 1; i < samples.length; i++) {
const dt = samples[i]!.t - samples[i - 1]!.t;
if (dt > OBSERVATION_GAP_SECONDS) {
gaps.push({ start: samples[i - 1]!.t, end: samples[i]!.t });
}
}
return gaps;
}
function readsInWindow(
sources: readonly DetectedEvent[],
start: number,
end: number,
): number[] {
return sources
.filter(
(event) =>
(event.type === PLAYER_STATUS_EVENT_TYPE ||
event.type === MINIMAP_EVENT_TYPE) &&
event.t >= start - 0.5 &&
event.t <= end + 0.5,
)
.map((event) => Math.round(event.t * 100) / 100);
}
// ---------------------------------------------------------------------------
// Output
function ts(t: number): string {
return `t=${Math.round(t * 10) / 10} (${formatTime(t)})`;
}
function spanText(span: StatusSpan, suspicions: string[]): string {
const duration = Math.round((span.end - span.start) * 10) / 10;
const marks = suspicions.length > 0 ? ` ⚠ ${suspicions.join(", ")}` : "";
return `${ts(span.start)} ${duration}s/${span.confirmingReads.length}r${marks}`;
}
function deadSpanSuspicions(span: StatusSpan): string[] {
const suspicions: string[] = [];
if (span.maxPossibleSeconds < MIN_DEAD_SECONDS) {
suspicions.push(
`even flank-to-flank shorter than min respawn ${MIN_DEAD_SECONDS}s`,
);
}
if (span.confirmingReads.length === 1) suspicions.push("single read");
return suspicions;
}
function specialSpanSuspicions(span: StatusSpan): string[] {
return span.confirmingReads.length === 1 ? ["single read"] : [];
}
function playerLabelOf(match: ScannerMatch, side: 0 | 1, slot: number): string {
const player = match.teams[side].players[slot];
const weapon = mainWeaponLabel(player?.weaponId ?? null);
return `${player?.name ?? "?"}${weapon ? ` · ${weapon}` : ""}`;
}
function printHeader(): void {
console.log(`# Player-status audit: ${csvPath}`);
const counts = new Map<string, number>();
for (const event of events) {
counts.set(event.type, (counts.get(event.type) ?? 0) + 1);
}
const countText = [...counts.entries()]
.map(([type, n]) => `${type} ${n}`)
.join(", ");
console.log(`events reconstructed: ${events.length} (${countText})`);
for (const [type, n] of skippedTypes) {
console.log(`ignored: ${n} × ${type} (not used by the match builder)`);
}
for (const failure of parseFailures) {
console.log(`PARSE FAILURE (row skipped): ${failure}`);
}
console.log(`matches built: ${built.length}`);
console.log("");
}
function printMatch(
index: number,
builtMatch: BuiltMatch<DetectedEvent>,
): void {
const { match, sources } = builtMatch;
const headline = [
match.mode ? modeLabel(match.mode) : "mode?",
match.stage !== null ? stageLabel(match.stage) : "stage?",
match.startsAt !== null && match.endsAt !== null
? `${ts(match.startsAt)} – ${ts(match.endsAt)}`
: "span?",
match.cast ? "cast footage" : "POV footage",
].join(" · ");
console.log(`## Match ${index + 1} · ${headline}`);
const samples = match.playerStatus?.samples ?? [];
if (samples.length === 0) {
console.log(
match.mode !== null && match.mode !== "SZ"
? "no status samples (non-SZ match: counter/status reads voided as lookalike misreads)"
: "no status samples — nothing to audit",
);
console.log("");
return;
}
const sorted = samples.toSorted((a, b) => a.t - b.t);
const gaps = observationGaps(sorted);
const windowSeconds = sorted.at(-1)!.t - sorted[0]!.t;
const gapSeconds = gaps.reduce((acc, gap) => acc + (gap.end - gap.start), 0);
const coverage =
windowSeconds <= 0 ? 1 : (windowSeconds - gapSeconds) / windowSeconds;
console.log(
`status samples: ${sorted.length} over ${ts(sorted[0]!.t)} – ${ts(sorted.at(-1)!.t)} · observed ${Math.round(coverage * 100)}% of the window`,
);
for (const gap of gaps) {
console.log(
` unobserved gap: ${ts(gap.start)} → ${ts(gap.end)} (${Math.round(gap.end - gap.start)}s) — deaths/specials in here are invisible to the timeline`,
);
}
const hasScoreboard = match.winner !== null;
if (!hasScoreboard) {
console.log(
"no scoreboard closed this match — no ground truth to diff against; flagging only implausible spans",
);
}
for (const side of [0, 1] as const) {
const teamLabel =
match.winner === null
? `Team ${side + 1}`
: side === 0
? "Team 1 (winner)"
: "Team 2 (loser)";
console.log(`### ${teamLabel}`);
const analyses = [0, 1, 2, 3].map((slot) =>
analyzeSlot(sorted, side, slot as 0 | 1 | 2 | 3),
);
const sbDeaths = analyses.map(
(a) => match.teams[side].players[a.slot]?.d ?? null,
);
const tlDeaths = analyses.map((a) => a.deadSpans.length);
const deathsMultisetMatch =
sbDeaths.every((d) => d !== null) &&
multisetEquals(sbDeaths as number[], tlDeaths);
for (const analysis of analyses) {
printSlot(index, builtMatch, analysis, deathsMultisetMatch);
}
if (
deathsMultisetMatch &&
sbDeaths.some((d, slot) => d !== tlDeaths[slot])
) {
console.log(
" note: death counts match as a set but not per row — likely a slot→row assignment artifact (CSV carries only top-1 strip-weapon candidates), not a detection error",
);
}
}
printPovCrossCheck(match, sources);
console.log("");
}
function printSlot(
matchIndex: number,
builtMatch: BuiltMatch<DetectedEvent>,
analysis: SlotAnalysis,
deathsMultisetMatch: boolean,
): void {
const { match, sources } = builtMatch;
const { side, slot } = analysis;
const player = match.teams[side].players[slot];
const label = playerLabelOf(match, side, slot);
const sbD = player?.d ?? null;
const sbS = player?.s ?? null;
const tlD = analysis.deadSpans.length;
const tlUses = analysis.specialUses;
const deathVerdict = verdict(sbD, tlD);
const specialNotes = [
analysis.diedWithSpecial > 0
? `${analysis.diedWithSpecial} died holding special (legit non-use)`
: null,
analysis.heldAtEnd ? "held at match end (legit non-use)" : null,
analysis.unknownSpecialEnds > 0
? `${analysis.unknownSpecialEnds} special end(s) lost in observation gaps`
: null,
].filter((note) => note !== null);
const specialVerdict = verdict(sbS, tlUses);
console.log(
`row${slot} ${label} — deaths sb=${sbD ?? "?"} tl=${tlD} ${deathVerdict.text} · specials sb=${sbS ?? "?"} used tl=${tlUses} (spans ${analysis.specialSpans.length}) ${specialVerdict.text}${specialNotes.length > 0 ? ` [${specialNotes.join("; ")}]` : ""}`,
);
const deadSuspicious = analysis.deadSpans.some(
(span) => deadSpanSuspicions(span).length > 0,
);
const showDetail =
showAllSpans ||
deathVerdict.mismatch !== 0 ||
specialVerdict.mismatch !== 0 ||
deadSuspicious;
if (showDetail) {
if (analysis.deadSpans.length > 0) {
console.log(
` dead spans: ${analysis.deadSpans.map((span) => spanText(span, deadSpanSuspicions(span))).join(" | ")}`,
);
}
if (analysis.specialSpans.length > 0) {
console.log(
` special spans: ${analysis.specialSpans.map((span) => spanText(span, specialSpanSuspicions(span))).join(" | ")}`,
);
}
}
collectCandidates(
matchIndex,
match,
sources,
analysis,
sbD,
sbS,
deathsMultisetMatch,
);
}
function verdict(
sb: number | null,
tl: number,
): { text: string; mismatch: number } {
if (sb === null) return { text: "(scoreboard unread)", mismatch: 0 };
const delta = tl - sb;
if (delta === 0) return { text: "✓", mismatch: 0 };
return {
text:
delta > 0
? `✗ +${delta} EXTRA on timeline`
: `✗ ${delta} MISSING on timeline`,
mismatch: delta,
};
}
function multisetEquals(a: readonly number[], b: readonly number[]): boolean {
const as = a.toSorted((x, y) => x - y);
const bs = b.toSorted((x, y) => x - y);
return as.length === bs.length && as.every((v, i) => v === bs[i]);
}
function collectCandidates(
matchIndex: number,
match: ScannerMatch,
sources: readonly DetectedEvent[],
analysis: SlotAnalysis,
sbD: number | null,
sbS: number | null,
deathsMultisetMatch: boolean,
): void {
const { side, slot } = analysis;
const label = `match ${matchIndex + 1} team ${side + 1} row${slot} (${playerLabelOf(match, side, slot)})`;
const deadExcess = sbD !== null && analysis.deadSpans.length > sbD;
const specialExcess = sbS !== null && analysis.specialUses > sbS;
for (const span of analysis.deadSpans) {
const suspicions = deadSpanSuspicions(span);
if (suspicions.length === 0 && !deadExcess) continue;
let score = 0;
if (span.maxPossibleSeconds < MIN_DEAD_SECONDS) score += 4;
if (span.confirmingReads.length === 1) score += 1;
if (deadExcess) score += deathsMultisetMatch ? 1 : 3;
if (score === 0) continue;
const confirmed = span.confirmingReads.at(-1)! - span.confirmingReads[0]!;
candidates.push({
score: score + 1 / (1 + confirmed),
t: span.start,
matchIndex,
description: `${label} — DEAD span ${Math.round((span.end - span.start) * 10) / 10}s over ${span.confirmingReads.length} read(s)${suspicions.length > 0 ? ` (${suspicions.join(", ")})` : ""}${deadExcess ? ` · row has +${analysis.deadSpans.length - (sbD ?? 0)} extra death(s) vs scoreboard` : ""}`,
reads: readsInWindow(sources, span.start, span.end),
});
}
if (specialExcess) {
const shortestFirst = analysis.specialSpans.toSorted(
(a, b) => a.confirmingReads.length - b.confirmingReads.length,
);
for (const span of shortestFirst.slice(
0,
analysis.specialUses - (sbS ?? 0),
)) {
const duration = span.end - span.start;
candidates.push({
score: 2 + 1 / (1 + span.confirmingReads.length),
t: span.start,
matchIndex,
description: `${label} — SPECIAL span ${Math.round(duration * 10) / 10}s over ${span.confirmingReads.length} read(s), row has +${analysis.specialUses - (sbS ?? 0)} extra special use(s) vs scoreboard`,
reads: readsInWindow(sources, span.start, span.end),
});
}
}
const deadMissing = sbD !== null && analysis.deadSpans.length < sbD;
const specialMissing = sbS !== null && analysis.specialUses < sbS;
if (deadMissing || specialMissing) {
const missing = [
deadMissing ? `${sbD - analysis.deadSpans.length} death(s)` : null,
specialMissing ? `${sbS - analysis.specialUses} special use(s)` : null,
].filter((part) => part !== null);
candidates.push({
score: 1.5,
t: match.startsAt ?? 0,
matchIndex,
description: `${label} — timeline MISSING ${missing.join(" and ")}: either lost in unobserved gaps (see match coverage) or the detector never flagged the state — scan this row's footage`,
reads: [],
});
}
}
function printPovCrossCheck(
match: ScannerMatch,
sources: readonly DetectedEvent[],
): void {
if (match.pov === null) return;
const overlayDeaths = sources.filter(
(event) => event.type === DEATH_EVENT_TYPE,
).length;
const povRow = match.teams[match.pov.team].players[match.pov.index];
if (povRow?.d == null) return;
const agrees = overlayDeaths === povRow.d ? "✓" : "✗ disagrees";
console.log(
`pov cross-check: ${overlayDeaths} respawn-overlay death event(s) vs scoreboard d=${povRow.d} for ${povRow.name ?? "?"} ${agrees} (independent of the icon-strip pipeline)`,
);
}
function printCandidates(): void {
console.log("## Fixture candidates (most suspect first)");
if (candidates.length === 0) {
console.log("none — timeline agrees with every scoreboard count");
console.log("");
return;
}
const ranked = candidates.toSorted((a, b) => b.score - a.score);
for (const [i, candidate] of ranked.entries()) {
console.log(`${i + 1}. ${ts(candidate.t)} · ${candidate.description}`);
if (candidate.reads.length > 0) {
console.log(
` reads to inspect: ${candidate.reads
.slice(0, 8)
.map((t) => `t=${t}`)
.join(
", ",
)}${candidate.reads.length > 8 ? ` (+${candidate.reads.length - 8} more)` : ""}`,
);
}
}
console.log("");
}
function printGuidance(): void {
console.log("## How to turn a candidate into a fixture");
console.log(
"1. Seek the VoD to the candidate's read timestamps (t = seconds into the footage; hh:mm:ss given alongside).",
);
console.log(
"2. In the scanner UI, analyze that frame and use “Save fixture” — the misread frame lands byte-exact with a prefilled expected.json.",
);
console.log(
"3. Hand-correct expected.json (icon-strip states go under tests/fixtures/player-status/, minimap card states under tests/fixtures/minimap/), then run pnpm test:scanner.",
);
console.log(
"Legend: a DEAD span shorter than 3.5s cannot be a real splat (min respawn); single-read spans are one frame's misread away from vanishing; EXTRA counts point at phantom bands, MISSING counts at undetected states or coverage gaps.",
);
}

View File

@@ -0,0 +1,18 @@
/**
* Config for running the scanner scripts with vite-node. Deliberately minimal:
* the root vite.config.ts pre-bundles @techstark/opencv-js for the browser
* worker, and vite-node would resolve that browser prebundle (which
* crashes on __dirname in Node). Without the include, vite-node
* externalizes the dep to a plain require of the (patched) CJS bundle.
*/
import { defineConfig } from "vite";
export default defineConfig({
resolve: {
tsconfigPaths: true,
},
optimizeDeps: {
noDiscovery: true,
exclude: ["@techstark/opencv-js"],
},
});

View File

@@ -0,0 +1,166 @@
import { ordinal } from "openskill";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { TIERS, type TierName } from "~/features/mmr/mmr-constants";
import * as SkillRepository from "~/features/mmr/SkillRepository.server";
import { freshUserSkills } from "~/features/mmr/tiered.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const rawNth = process.argv[2]?.trim();
invariant(rawNth, "nth of new season needed (argument 1)");
const nth = Number(rawNth);
invariant(!Number.isNaN(nth), "nth of new season must be a number");
invariant(
await SkillRepository.existsBySeason(nth - 1),
`No skills for season ${nth - 1}`,
);
invariant(
!(await SkillRepository.existsBySeason(nth)),
`Skills for season ${nth} already exist`,
);
const idsOfActiveMatches = (
await db
.selectFrom("GroupMatch")
.leftJoin("Skill", "Skill.groupMatchId", "GroupMatch.id")
.select("GroupMatch.id")
.where("Skill.id", "is", null)
.execute()
).map((row) => row.id);
invariant(
idsOfActiveMatches.length === 0,
`There are active matches: (ids: ${idsOfActiveMatches.join(", ")})`,
);
// from prod database:
// sqlite> select avg(sigma) from skill where matchesCount > 10 and matchesCount < 20;
// 6.63571559436444
// sqlite> select avg(sigma) from skill where matchesCount > 15 and matchesCount < 25;
// 6.4242759350389
const DEFAULT_NEW_SIGMA = 6.5;
const TIER_TO_NEW_TIER: Record<TierName, TierName> = {
IRON: "BRONZE",
BRONZE: "BRONZE",
SILVER: "SILVER",
GOLD: "GOLD",
PLATINUM: "PLATINUM",
DIAMOND: "DIAMOND",
LEVIATHAN: "DIAMOND",
};
// - For +1 & +2 members, consider the last 3 seasons
// - For +3 members, consider the last 2 seasons
// - For non-plus members, consider the last season only
const getAllSkills = async () => {
const skills = (
await Promise.all([
freshUserSkills(nth - 1),
freshUserSkills(nth - 2),
freshUserSkills(nth - 3),
])
).map((seasonSkills) => seasonSkills.userSkills);
const plusServerMembers = await db
.selectFrom("PlusTier")
.select(["PlusTier.userId", "PlusTier.tier as plusTier"])
.execute();
const result: (typeof skills)[number] = {};
for (const member of plusServerMembers) {
const toConsider =
member.plusTier === 1 || member.plusTier === 2
? skills
: skills.slice(0, 2);
const bestTier = toConsider.reduce(
(acc, cur, idx) => {
const seasonsSkill = cur[member.userId!];
if (!seasonsSkill) {
return acc;
}
const newIdx = TIERS.findIndex(
(t) => t.name === seasonsSkill.tier.name,
);
const oldIdx = TIERS.findIndex((t) => t.name === acc?.name);
return oldIdx === -1 || newIdx < oldIdx
? { name: seasonsSkill.tier.name, idx }
: acc;
},
null as null | { name: TierName; idx: number },
);
if (bestTier) {
result[member.userId] = toConsider[bestTier.idx][member.userId!];
}
}
return Object.entries({ ...skills[0], ...result })
.map(([userId, skill]) => ({ userId: Number(userId), ...skill }))
.filter((s) => !s.approximate)
.sort((a, b) => b.ordinal - a.ordinal);
};
const allSkills = await getAllSkills();
const skillsToConsider = allSkills.filter((s) =>
Object.values(TIER_TO_NEW_TIER).includes(s.tier.name),
);
const groupedSkills = skillsToConsider.reduce(
(acc, skill) => {
const { tier } = skill;
if (!acc[tier.name]) {
acc[tier.name] = [];
}
acc[tier.name].push(skill);
return acc;
},
{} as Record<TierName, typeof skillsToConsider>,
);
const midPoints = {} as Record<TierName, Tables["Skill"]>;
for (const [tier, skills] of Object.entries(groupedSkills)) {
const midPoint = skills[Math.floor(skills.length / 2)];
const midPointSkill = await db
.selectFrom("Skill")
.selectAll()
.where("userId", "=", midPoint.userId)
.where("ordinal", "=", midPoint.ordinal)
.executeTakeFirst();
invariant(midPointSkill, "midPointSkill not found");
midPoints[tier as TierName] = midPointSkill;
}
const newSkills = allSkills.map((s) => {
const newTier = TIER_TO_NEW_TIER[s.tier.name];
const mu = midPoints[newTier].mu;
const sigma = DEFAULT_NEW_SIGMA;
return {
userId: s.userId,
sigma,
mu,
ordinal: ordinal({ sigma, mu }),
season: nth,
};
});
await db.transaction().execute(async (trx) => {
for (const skill of newSkills) {
await SkillRepository.addInitialSkill(skill, trx);
}
await trx.updateTable("Group").set({ status: "INACTIVE" }).execute();
});
logger.info(
`Done adding new skills for season ${nth} (${newSkills.length} added)`,
);

View File

@@ -0,0 +1,77 @@
import { Config } from "~/config";
export const SEED_ART_URLS = [
"https://images.unsplash.com/photo-1611627474565-2367887415d1?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU1NTA2&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1625120742520-3f085b6894ba?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU1NTI2&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1656695607245-9686ce8e1a91?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU1NTQ1&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1673011526786-c7bf154d2c6d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU1NTY5&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1643833994700-059713434c71?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU1NTc2&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1541425284102-3d2c49dcb2bc?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU1NTk5&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1526946366170-7a81b443c4e6?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU1NjA4&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1551368003-4d96079d0a99?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU1NjIz&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1595960684234-49d2a004e753?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU1NjMw&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1676275062470-4b628cf1ce01?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU1NjM5&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1602099081031-767e09dfdbad?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU2NDYz&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1547532182-bf296f6be875?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU2NDY5&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1601549838695-57580707e367?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU2NDc2&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1595361315899-72a291112b7b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU2NDgy&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1676275061266-a28f2f3f4552?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU2NDg4&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/44/C3EWdWzT8imxs0fKeKoC_blackforrest.JPG?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU2NDk1&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1628425242605-a0039d89e8b2?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU2NTAx&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1542917118-105d7d34b9ca?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU2NTEw&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1601549838695-57580707e367?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU2NTE3&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1660583490803-75c0307c805b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8ZG9nLDF8fHx8fHwxNjg4NTU2NTQ2&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1490042706304-06c664f6fd9a?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8ZG9nLDF8fHx8fHwxNjg4NTU2NTU4&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1676998652985-fd74c7b2a8d5?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8ZG9nLDF8fHx8fHwxNjg4NTU2NTY0&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1470390356535-d19bbf47bacb?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8ZG9nLDF8fHx8fHwxNjg4NTU2NTcx&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1503256207526-0d5d80fa2f47?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8ZG9nLDF8fHx8fHwxNjg4NTU2Njcy&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1604495589307-973bd87d7fa3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8ZG9nLDF8fHx8fHwxNjg4NTU2Njg2&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1563476651637-3e5c5941d432?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU2NzEw&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1551567819-eef106c515b6?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU2NzE2&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
"https://images.unsplash.com/photo-1553536590-d28c5d5dee92?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLDF8fHx8fHwxNjg4NTU2NzIx&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080",
];
export function getArtFilename(index: number): string {
return `art-${index}.jpg`;
}
/** Logos in `app/db/seed/img`, uploaded to the local image storage under their own
* name. Anything the seed sets as a logo has to be one of these to render in dev. */
export const SEED_LOGO_FILENAMES = [
"alliance-rogue.png",
"default.png",
"in-the-zone.png",
"luti.png",
"paddling-pool.png",
"picnic.png",
"swim-or-sink.png",
"the-depths.png",
];
/** The logo of a tournament that has none of its own, uploaded under the name the
* app looks it up by. */
export const SEED_DEFAULT_TOURNAMENT_LOGO = {
sourceFilename: "default.png",
filename: Config.tournamentDefaultLogo,
};
/** How many numbered logos are uploaded. A seed run needing more than this many
* ends up with logos that 404 — raise it rather than reusing one, the url column
* being unique. */
export const SEED_NUMBERED_LOGO_COUNT = 200;
/** Filename of a logo the seed hands to something it has no particular logo for.
* Numbered because every image row needs a url of its own, cycling the logos so
* that a page full of them is not a page of one logo. */
export function numberedLogoFilename(index: number) {
return `seed-logo-${index}.png`;
}
/** The uploads a numbered logo filename resolves to, in the storage the app reads. */
export const SEED_NUMBERED_LOGOS = Array.from(
{ length: SEED_NUMBERED_LOGO_COUNT },
(_, index) => ({
filename: numberedLogoFilename(index),
sourceFilename: SEED_LOGO_FILENAMES[index % SEED_LOGO_FILENAMES.length],
}),
);

View File

@@ -0,0 +1,249 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { Readable } from "node:stream";
import { S3 } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { logger } from "~/utils/logger";
import {
getArtFilename,
SEED_ART_URLS,
SEED_DEFAULT_TOURNAMENT_LOGO,
SEED_LOGO_FILENAMES,
SEED_NUMBERED_LOGOS,
} from "./seed-art-urls";
async function checkMinioConnection(): Promise<boolean> {
try {
const {
STORAGE_END_POINT,
STORAGE_ACCESS_KEY,
STORAGE_SECRET,
STORAGE_REGION,
STORAGE_BUCKET,
} = process.env;
if (
!(
STORAGE_ACCESS_KEY &&
STORAGE_END_POINT &&
STORAGE_SECRET &&
STORAGE_REGION &&
STORAGE_BUCKET
)
) {
logger.warn("Storage configuration not found in environment");
return false;
}
const s3 = new S3({
endpoint: STORAGE_END_POINT,
forcePathStyle: false,
credentials: {
accessKeyId: STORAGE_ACCESS_KEY,
secretAccessKey: STORAGE_SECRET,
},
region: STORAGE_REGION,
});
await s3.headBucket({ Bucket: STORAGE_BUCKET });
return true;
} catch {
return false;
}
}
async function downloadImage(url: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
async function readLocalImage(filename: string): Promise<Buffer> {
const imagePath = join(process.cwd(), "app", "db", "seed", "img", filename);
return await readFile(imagePath);
}
async function uploadToMinio(
imageBuffer: Buffer,
filename: string,
): Promise<string> {
const {
STORAGE_END_POINT,
STORAGE_ACCESS_KEY,
STORAGE_SECRET,
STORAGE_REGION,
STORAGE_BUCKET,
} = process.env;
const s3 = new S3({
endpoint: STORAGE_END_POINT,
forcePathStyle: false,
credentials: {
accessKeyId: STORAGE_ACCESS_KEY!,
secretAccessKey: STORAGE_SECRET!,
},
region: STORAGE_REGION,
});
const stream = Readable.from(imageBuffer);
const upload = new Upload({
client: s3,
params: {
Bucket: STORAGE_BUCKET!,
Key: filename,
Body: stream,
ACL: "public-read",
},
});
await upload.done();
return filename;
}
async function fileExistsInMinio(filename: string): Promise<boolean> {
try {
const {
STORAGE_END_POINT,
STORAGE_ACCESS_KEY,
STORAGE_SECRET,
STORAGE_REGION,
STORAGE_BUCKET,
} = process.env;
const s3 = new S3({
endpoint: STORAGE_END_POINT,
forcePathStyle: false,
credentials: {
accessKeyId: STORAGE_ACCESS_KEY!,
secretAccessKey: STORAGE_SECRET!,
},
region: STORAGE_REGION,
});
await s3.headObject({
Bucket: STORAGE_BUCKET!,
Key: filename,
});
return true;
} catch {
return false;
}
}
export async function seedImages(): Promise<void> {
const minioAvailable = await checkMinioConnection();
if (!minioAvailable) {
logger.warn(
"⚠️ Minio is not available. Skipping image seeding. Make sure Docker is running if you want to seed images.",
);
return;
}
logger.info(`📥 Processing ${SEED_ART_URLS.length} art images`);
let successCount = 0;
let failCount = 0;
let skippedCount = 0;
for (let i = 0; i < SEED_ART_URLS.length; i++) {
const url = SEED_ART_URLS[i];
const filename = getArtFilename(i);
const smallFilename = filename.replace(/\.(\w+)$/, "-small.$1");
try {
const regularExists = await fileExistsInMinio(filename);
const smallExists = await fileExistsInMinio(smallFilename);
if (regularExists && smallExists) {
skippedCount++;
logger.info(
` ↷ Files ${filename} and ${smallFilename} already exist in Minio`,
);
} else {
const imageBuffer = await downloadImage(url);
if (!regularExists) {
logger.info(` Uploading ${filename} to Minio...`);
await uploadToMinio(imageBuffer, filename);
}
if (!smallExists) {
logger.info(` Uploading ${smallFilename} to Minio...`);
await uploadToMinio(imageBuffer, smallFilename);
}
successCount++;
}
} catch (err) {
failCount++;
logger.error(
` ✗ Failed to process ${filename}: ${(err as Error).message}`,
);
}
}
logger.info(
`\n✅ Art image seeding complete: ${successCount} uploaded, ${skippedCount} already existed, ${failCount} failed`,
);
const localImages = [
...SEED_LOGO_FILENAMES.map((filename) => ({
filename,
sourceFilename: filename,
})),
SEED_DEFAULT_TOURNAMENT_LOGO,
...SEED_NUMBERED_LOGOS,
];
logger.info(`\n📥 Processing ${localImages.length} logo images`);
let localSuccessCount = 0;
let localFailCount = 0;
let localSkippedCount = 0;
for (const { filename, sourceFilename } of localImages) {
const smallFilename = filename.replace(/\.(\w+)$/, "-small.$1");
try {
const regularExists = await fileExistsInMinio(filename);
const smallExists = await fileExistsInMinio(smallFilename);
if (regularExists && smallExists) {
localSkippedCount++;
logger.info(
` ↷ Files ${filename} and ${smallFilename} already exist in Minio`,
);
} else {
const imageBuffer = await readLocalImage(sourceFilename);
if (!regularExists) {
logger.info(` Uploading ${filename} to Minio...`);
await uploadToMinio(imageBuffer, filename);
}
if (!smallExists) {
logger.info(` Uploading ${smallFilename} to Minio...`);
await uploadToMinio(imageBuffer, smallFilename);
}
localSuccessCount++;
}
} catch (err) {
localFailCount++;
logger.error(
` ✗ Failed to process ${filename}: ${(err as Error).message}`,
);
}
}
logger.info(
`\n✅ Logo image seeding complete: ${localSuccessCount} uploaded, ${localSkippedCount} already existed, ${localFailCount} failed`,
);
}

View File

@@ -0,0 +1,8 @@
import { seed } from "~/db/seed";
const startedAt = Date.now();
await seed();
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(`Seeded in ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);

View File

@@ -0,0 +1,41 @@
import { db } from "~/db/sql";
import { notify } from "~/features/notifications/core/notify.server";
import { webPushEnabled } from "~/features/notifications/core/webPush.server";
import * as NotificationRepository from "~/features/notifications/NotificationRepository.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const username = process.argv[2]?.trim() ?? "Sendou";
const seasonNth = Number(process.argv[3] ?? 1);
invariant(!Number.isNaN(seasonNth), "season nth must be a number (argument 2)");
const user = await db
.selectFrom("User")
.select(["id", "username"])
.where("username", "=", username)
.executeTakeFirst();
invariant(user, `user with username ${username} not found`);
if (!webPushEnabled) {
logger.warn(
"VAPID env vars not set, only the in-app notification will be created",
);
}
const subscriptions =
await NotificationRepository.findAllSubscriptionsByUserIds([user.id]);
logger.info(
`${user.username} (id ${user.id}) has ${subscriptions.length} push subscription(s)`,
);
await notify({
userIds: [user.id],
notification: { type: "SEASON_STARTED", meta: { seasonNth } },
skipPushGracePeriod: true,
});
logger.info("Notification sent");
process.exit(0);

View File

@@ -0,0 +1,35 @@
import { db } from "~/db/sql";
import { logger } from "~/utils/logger";
import { seedImages } from "./seed-images";
async function main() {
const dbEmpty = !(await db.selectFrom("User").selectAll().executeTakeFirst());
// Run migration and seed if db.sqlite3 doesn't exist
if (dbEmpty) {
logger.info("🌱 Seeding database...");
try {
// Dynamically imported so we skip transforming the large
// seed import graph if the database is already seeded
const { seed } = await import("~/db/seed");
await seed();
logger.info("Database seeded successfully");
} catch (err) {
logger.error(
"Error running migrate or seed scripts:",
(err as Error).message,
);
process.exit(1);
}
}
// Seed images to Minio
logger.info("🖼️ Seeding images to Minio...");
try {
await seedImages();
} catch (err) {
logger.error("Error seeding images:", (err as Error).message);
}
}
main();

View File

@@ -0,0 +1,22 @@
import { db } from "~/db/sql";
import * as Seasons from "~/features/mmr/core/Seasons";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const discordId = process.argv[2]?.trim();
invariant(discordId, "discord id is required (argument 1)");
const currentSeasonNth = Seasons.currentOrPrevious()?.nth;
invariant(currentSeasonNth, "current season nth is required");
await db
.updateTable("User")
.set({ plusSkippedForSeasonNth: currentSeasonNth })
.where("discordId", "=", discordId)
.execute();
logger.info(
`Plus Server admission will be skipped for Discord ID: ${discordId} (season ${currentSeasonNth})`,
);

View File

@@ -0,0 +1,25 @@
import { db } from "~/db/sql";
import invariant from "~/utils/invariant";
const discordId = process.argv[2]?.trim();
const discordId2 = process.argv[3]?.trim();
invariant(discordId, "discord id is required (argument 1)");
invariant(discordId2, "discord id (2) is required (argument 2)");
invariant(discordId !== discordId2, "discord ids must be different");
const tempDiscordId = "temp-discord-id";
// swap user discordIds
await db.transaction().execute(async (trx) => {
const swap = (from: string, to: string) =>
trx
.updateTable("User")
.set({ discordId: to })
.where("discordId", "=", from)
.execute();
await swap(discordId, tempDiscordId);
await swap(discordId2, discordId);
await swap(tempDiscordId, discordId2);
});

View File

@@ -0,0 +1,169 @@
import { db } from "~/db/sql";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import { logger } from "~/utils/logger";
const HOMEMADE_BADGES_URL =
"https://raw.githubusercontent.com/sendou-ink/assets/main/homemade.json";
interface HomemadeBadge {
displayName: string;
authorDiscordId: string;
}
async function main() {
const homemadeBadges = await fetchHomemadeBadges();
let deleted = 0;
let updated = 0;
// update existing
for (const existingBadge of await homemadeBadgesInDb()) {
const badge = homemadeBadges[existingBadge.code];
if (!badge) {
await deleteBadge(existingBadge.id);
deleted++;
continue;
}
const author = await findUserByDiscordId(badge.authorDiscordId);
if (!author) {
logger.warn(
`Author not found for badge with id: ${existingBadge.id}, skipping`,
);
continue;
}
if (
badge.displayName !== existingBadge.displayName ||
badge.authorDiscordId !== existingBadge.discordId
) {
await updateBadge(existingBadge.id, {
displayName: badge.displayName,
authorId: author.id,
});
updated++;
}
}
const homemadeAfterUpdates = await homemadeBadgesInDb();
let added = 0;
// add new
for (const [fileName, badge] of Object.entries(homemadeBadges)) {
const existing = homemadeAfterUpdates.find(
(existingBadge) => fileName === existingBadge.code,
);
if (existing) {
continue;
}
const author = await findUserByDiscordId(badge.authorDiscordId);
if (!author) {
logger.warn(
`Author not found for badge with fileName: ${fileName}, skipping`,
);
continue;
}
await addBadge({
code: fileName,
displayName: badge.displayName,
authorId: author.id,
});
added++;
}
logger.info(
`Deleted ${deleted}, updated ${updated}, added ${added} homemade badges`,
);
}
async function fetchHomemadeBadges(): Promise<Record<string, HomemadeBadge>> {
const response = await fetch(HOMEMADE_BADGES_URL);
if (!response.ok) {
throw new Error(
`Failed to fetch homemade badges (${response.status} ${response.statusText})`,
);
}
return response.json() as Promise<Record<string, HomemadeBadge>>;
}
async function homemadeBadgesInDb() {
return db
.selectFrom("Badge")
.innerJoin("User", "Badge.authorId", "User.id")
.select(["Badge.id", "Badge.code", "User.discordId", "Badge.displayName"])
.execute();
}
async function findUserByDiscordId(discordId: string) {
return db
.selectFrom("User")
.select("id")
.where("discordId", "=", discordId)
.executeTakeFirst();
}
async function deleteBadge(badgeId: number) {
const owners = await db
.selectFrom("BadgeOwner")
.select("badgeId")
.where("badgeId", "=", badgeId)
.execute();
if (owners.length > 0) {
logger.warn(`Refusing to delete badge ${badgeId} because it has owners`);
return;
}
await db.transaction().execute(async (trx) => {
await trx
.deleteFrom("BadgeManager")
.where("badgeId", "=", badgeId)
.execute();
await trx
.deleteFrom("CalendarEventBadge")
.where("badgeId", "=", badgeId)
.execute();
await trx
.deleteFrom("TournamentBadgeOwner")
.where("badgeId", "=", badgeId)
.execute();
await trx
.deleteFrom("TournamentOrganizationBadge")
.where("badgeId", "=", badgeId)
.execute();
await trx.deleteFrom("Badge").where("id", "=", badgeId).execute();
});
}
async function updateBadge(
badgeId: number,
badge: { displayName: string; authorId: number },
) {
return db
.updateTable("Badge")
.set({
displayName: badge.displayName,
authorId: badge.authorId,
})
.where("id", "=", badgeId)
.execute();
}
async function addBadge(badge: {
code: string;
displayName: string;
authorId: number;
}) {
return BadgeRepository.insert({ ...badge, hue: null });
}
main();

View File

@@ -0,0 +1,440 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
mainWeaponIds,
specialWeaponIds,
subWeaponIds,
} from "~/modules/in-game-lists/weapon-ids";
import { logger } from "~/utils/logger";
import {
loadWeaponInfoMain,
loadWeaponInfoSpecial,
loadWeaponInfoSub,
MUSH_DIR,
PARAMETER_DIR,
} from "./utils";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const weapons = loadWeaponInfoMain();
const subWeapons = loadWeaponInfoSub();
const specialWeapons = loadWeaponInfoSpecial();
const OUTPUT_DIR = path.join(
__dirname,
"..",
"app",
"features",
"params",
"data",
);
const OUTPUT_FILE = path.join(OUTPUT_DIR, "all-version-weapon-params.json");
const SUB_OUTPUT_FILE = path.join(OUTPUT_DIR, "all-version-sub-params.json");
const SPECIAL_OUTPUT_FILE = path.join(
OUTPUT_DIR,
"all-version-special-params.json",
);
const WEAPON_TYPES_TO_IGNORE = [
"Mission",
"Coop",
"Hero",
"Rival",
"SalmonBuddy",
"Sdodr",
];
type MainWeapon = (typeof weapons)[number];
function parseVersionToDisplay(version: string): string {
const num = Number.parseInt(version, 10);
const major = Math.floor(num / 100);
const minor = Math.floor((num % 100) / 10);
const patch = num % 10;
return `${major}.${minor}.${patch}`;
}
function sortVersions(versions: string[]): string[] {
return versions.sort(
(a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10),
);
}
function mainWeaponShouldBeSkipped(mainWeapon: MainWeapon): boolean {
return WEAPON_TYPES_TO_IGNORE.includes(mainWeapon.Type);
}
function weaponRowIdToFileName(rowId: string): string {
const parts = rowId.split("_");
const category = parts[0];
const codeName = parts[1];
return `Weapon${category}${codeName ?? ""}.game__GameParameterTable.json`;
}
function buildWeaponFileNameToIdMap(): Map<string, number> {
const map = new Map<string, number>();
const seenFileNames = new Set<string>();
for (const weapon of weapons) {
if (mainWeaponShouldBeSkipped(weapon)) continue;
const fileName = weaponRowIdToFileName(weapon.__RowId);
if (!seenFileNames.has(fileName)) {
seenFileNames.add(fileName);
map.set(fileName, weapon.Id);
}
}
return map;
}
// Sub and special weapons share the per-version `weapon` GameParameterTable dump with main
// weapons, so only the canonical "Versus" entry of each id is kept (Hero/Mission/etc. variants
// of the same weapon are ignored).
function buildSubOrSpecialFileNameToIdMap(
entries: Array<{ Id: number; __RowId: string; Type: string }>,
allowedIds: ReadonlySet<number>,
): Map<string, number> {
const map = new Map<string, number>();
const seenFileNames = new Set<string>();
for (const entry of entries) {
if (entry.Type !== "Versus") continue;
if (!allowedIds.has(entry.Id)) continue;
const fileName = weaponRowIdToFileName(entry.__RowId);
if (!seenFileNames.has(fileName)) {
seenFileNames.add(fileName);
map.set(fileName, entry.Id);
}
}
return map;
}
function stripTypeFields(obj: unknown): unknown {
if (obj === null || typeof obj !== "object") {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(stripTypeFields);
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (key === "$type") continue;
result[key] = stripTypeFields(value);
}
return result;
}
function deepEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function getLeafPaths(
obj: unknown,
prefix = "",
): Array<{ path: string; value: unknown }> {
const results: Array<{ path: string; value: unknown }> = [];
if (obj === null || typeof obj !== "object") {
return [{ path: prefix, value: obj }];
}
if (Array.isArray(obj)) {
return [{ path: prefix, value: obj }];
}
for (const [key, value] of Object.entries(obj)) {
const newPath = prefix ? `${prefix}.${key}` : key;
if (value === null || typeof value !== "object" || Array.isArray(value)) {
results.push({ path: newPath, value });
} else {
results.push(...getLeafPaths(value, newPath));
}
}
return results;
}
function setNestedValueWithVersionedKey(
obj: Record<string, unknown>,
pathStr: string,
version: string,
value: unknown,
): void {
const parts = pathStr.split(".");
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current) || typeof current[part] !== "object") {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
const lastPart = parts[parts.length - 1];
const versionedKey = `${lastPart}@${version}`;
current[versionedKey] = value;
}
function getNestedValue(obj: unknown, pathStr: string): unknown {
const parts = pathStr.split(".");
let current = obj;
for (const part of parts) {
if (current === null || typeof current !== "object") {
return undefined;
}
current = (current as Record<string, unknown>)[part];
}
return current;
}
function mergeWithHistory(
latestParams: Record<string, unknown>,
allVersionParams: Map<string, Record<string, unknown>>,
sortedVersions: string[],
): Record<string, unknown> {
const result: Record<string, unknown> = JSON.parse(
JSON.stringify(latestParams),
);
const latestPaths = getLeafPaths(latestParams);
for (const { path: paramPath, value: latestValue } of latestPaths) {
for (let i = sortedVersions.length - 2; i >= 0; i--) {
const version = sortedVersions[i];
const versionParams = allVersionParams.get(version);
if (!versionParams) continue;
const versionValue = getNestedValue(versionParams, paramPath);
if (versionValue === undefined) continue;
const nextVersion = sortedVersions[i + 1];
const nextParams = allVersionParams.get(nextVersion);
const nextValue = nextParams
? getNestedValue(nextParams, paramPath)
: latestValue;
if (!deepEqual(versionValue, nextValue)) {
setNestedValueWithVersionedKey(
result,
paramPath,
parseVersionToDisplay(version),
versionValue,
);
}
}
}
return result;
}
// SpecialPoint lives in WeaponInfoMain (kit data), which is not part of the per-version weapon
// GameParameterTable dump, so it is read per version from the local mush dir.
function collectSpecialPointsByVersion(
sortedVersions: string[],
): Map<number, Map<string, number>> {
const result = new Map<number, Map<string, number>>();
const mainWeaponIdSet = new Set<number>(mainWeaponIds);
for (const version of sortedVersions) {
const filePath = path.join(MUSH_DIR, version, "WeaponInfoMain.json");
if (!fs.existsSync(filePath)) continue;
let entries: MainWeapon[];
try {
entries = JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
logger.warn(`Failed to parse ${filePath}`);
continue;
}
for (const weapon of entries) {
if (mainWeaponShouldBeSkipped(weapon)) continue;
if (!mainWeaponIdSet.has(weapon.Id)) continue;
if (typeof weapon.SpecialPoint !== "number") continue;
if (!result.has(weapon.Id)) {
result.set(weapon.Id, new Map());
}
result.get(weapon.Id)!.set(version, weapon.SpecialPoint);
}
}
return result;
}
function buildSpecialPointsHistory(
specialPointsByVersion: Map<number, Map<string, number>>,
sortedVersions: string[],
): Record<
string,
{ current: number; history: Array<{ version: string; value: number }> }
> {
const result: Record<
string,
{ current: number; history: Array<{ version: string; value: number }> }
> = {};
for (const [weaponId, byVersion] of specialPointsByVersion) {
const presentVersions = sortedVersions.filter((v) => byVersion.has(v));
if (presentVersions.length === 0) continue;
const current = byVersion.get(presentVersions[presentVersions.length - 1])!;
const history: Array<{ version: string; value: number }> = [];
for (let i = 0; i < presentVersions.length - 1; i++) {
const value = byVersion.get(presentVersions[i])!;
const nextValue = byVersion.get(presentVersions[i + 1])!;
if (value !== nextValue) {
history.push({
version: parseVersionToDisplay(presentVersions[i]),
value,
});
}
}
result[String(weaponId)] = { current, history };
}
return result;
}
// Reads every per-version `weapon` GameParameterTable dump for the given files and folds the
// historical values of each weapon into its latest params using versioned (`Key@version`) keys.
function buildParamsWithHistory(
fileNameToId: Map<string, number>,
sortedVersions: string[],
): Record<string, Record<string, unknown>> {
const allVersions = new Map<number, Map<string, Record<string, unknown>>>();
for (const version of sortedVersions) {
const weaponDir = path.join(PARAMETER_DIR, version, "weapon");
if (!fs.existsSync(weaponDir)) continue;
for (const file of fs.readdirSync(weaponDir)) {
if (!fileNameToId.has(file)) continue;
const weaponId = fileNameToId.get(file)!;
const filePath = path.join(weaponDir, file);
try {
const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
const params = stripTypeFields(content.GameParameters) as Record<
string,
unknown
>;
if (!allVersions.has(weaponId)) {
allVersions.set(weaponId, new Map());
}
allVersions.get(weaponId)!.set(version, params);
} catch {
logger.warn(`Failed to parse ${filePath}`);
}
}
}
const output: Record<string, Record<string, unknown>> = {};
const latestVersion = sortedVersions[sortedVersions.length - 1];
for (const [weaponId, versionParams] of allVersions) {
const latestParams = versionParams.get(latestVersion);
if (!latestParams) continue;
const versionsWithWeapon = sortedVersions.filter((v) =>
versionParams.has(v),
);
output[String(weaponId)] = mergeWithHistory(
latestParams,
versionParams,
versionsWithWeapon,
);
}
return output;
}
async function main() {
logger.info("Starting weapon params sync...");
const versionDirs = fs
.readdirSync(PARAMETER_DIR)
.filter((dir) => /^\d+$/.test(dir));
const sortedVersions = sortVersions(versionDirs);
logger.info(
`Found ${sortedVersions.length} versions: ${sortedVersions.map(parseVersionToDisplay).join(", ")}`,
);
const latestVersion = sortedVersions[sortedVersions.length - 1];
const metadata = {
generatedAt: new Date().toISOString(),
latestVersion: parseVersionToDisplay(latestVersion),
versions: sortedVersions.map(parseVersionToDisplay),
};
const weaponFileNameToId = buildWeaponFileNameToIdMap();
logger.info(`Processing ${weaponFileNameToId.size} unique weapons`);
const outputWeapons = buildParamsWithHistory(
weaponFileNameToId,
sortedVersions,
);
const specialPoints = buildSpecialPointsHistory(
collectSpecialPointsByVersion(sortedVersions),
sortedVersions,
);
const outputSubWeapons = buildParamsWithHistory(
buildSubOrSpecialFileNameToIdMap(subWeapons, new Set(subWeaponIds)),
sortedVersions,
);
const outputSpecialWeapons = buildParamsWithHistory(
buildSubOrSpecialFileNameToIdMap(specialWeapons, new Set(specialWeaponIds)),
sortedVersions,
);
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
fs.writeFileSync(
OUTPUT_FILE,
JSON.stringify(
{ metadata, weapons: outputWeapons, specialPoints },
null,
2,
),
);
fs.writeFileSync(
SUB_OUTPUT_FILE,
JSON.stringify({ metadata, weapons: outputSubWeapons }, null, 2),
);
fs.writeFileSync(
SPECIAL_OUTPUT_FILE,
JSON.stringify({ metadata, weapons: outputSpecialWeapons }, null, 2),
);
logger.info(`Written to ${OUTPUT_FILE}`);
logger.info(`Total main weapons: ${Object.keys(outputWeapons).length}`);
logger.info(`Total sub weapons: ${Object.keys(outputSubWeapons).length}`);
logger.info(
`Total special weapons: ${Object.keys(outputSpecialWeapons).length}`,
);
}
main().catch((err) => logger.error(err));

View File

@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"include": ["./**/*.ts", "../types/**/*.d.ts"],
"exclude": [],
"compilerOptions": {
"allowImportingTsExtensions": true
}
}

View File

@@ -0,0 +1,15 @@
import { db } from "~/db/sql";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const discordId = process.argv[2]?.trim();
invariant(discordId, "discord id is required (argument 1)");
await db
.updateTable("User")
.set({ plusSkippedForSeasonNth: null })
.where("discordId", "=", discordId)
.execute();
logger.info(`Plus Server admission unskipped for Discord ID: ${discordId}`);

View File

@@ -0,0 +1,107 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type euEn from "./dicts/splat3/data/language/EUen.json";
// The splat3 dump exposes a `latest` symlink to the newest version folder, so these loaders never
// need to hard-code a version.
import type gearInfoClothes from "./dicts/splat3/data/mush/latest/GearInfoClothes.json";
import type gearInfoHead from "./dicts/splat3/data/mush/latest/GearInfoHead.json";
import type gearInfoShoes from "./dicts/splat3/data/mush/latest/GearInfoShoes.json";
import type weaponInfoMain from "./dicts/splat3/data/mush/latest/WeaponInfoMain.json";
import type weaponInfoSpecial from "./dicts/splat3/data/mush/latest/WeaponInfoSpecial.json";
import type weaponInfoSub from "./dicts/splat3/data/mush/latest/WeaponInfoSub.json";
import type splPlayer from "./dicts/splat3/data/parameter/latest/misc/SplPlayer.game__GameParameterTable.json";
import type damageRateInfo from "./dicts/splat3/data/parameter/latest/misc/spl__DamageRateInfoConfig.pp__CombinationDataTableData.json";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SPLAT3_DATA_PATH = path.join(__dirname, "dicts", "splat3", "data");
/** Per-version weapon/sub/special `GameParameterTable` dumps, keyed by patch version folder. */
export const PARAMETER_DIR = path.join(SPLAT3_DATA_PATH, "parameter");
/** Per-version `WeaponInfo`/`GearInfo` dumps, keyed by patch version folder. */
export const MUSH_DIR = path.join(SPLAT3_DATA_PATH, "mush");
const LANG_DICTS_PATH = path.join(SPLAT3_DATA_PATH, "language");
export const LANG_JSONS_TO_CREATE = [
"EUen",
"CNzh",
"EUde",
"EUes",
"USes",
"EUfr",
"EUit",
"EUnl",
"EUru",
"JPja",
"KRko",
"USfr",
];
export async function loadLangDicts() {
const result: Array<[langCode: string, translations: typeof euEn]> = [];
const files = await fs.promises.readdir(LANG_DICTS_PATH);
for (const file of files) {
if (file === ".gitkeep") continue;
const translations = JSON.parse(
fs.readFileSync(path.join(LANG_DICTS_PATH, file), "utf8"),
);
result.push([file.replace(".json", ""), translations]);
}
return result;
}
export function translationJsonFolderName(langCode: string) {
if (langCode === "EUes") return "es-ES";
if (langCode === "USes") return "es-US";
if (langCode === "EUfr") return "fr-EU";
if (langCode === "USfr") return "fr-CA";
return langCode.slice(2);
}
/** Latest-version directory holding the per-weapon `GameParameterTable` dumps. */
export function weaponParamsDir() {
return path.join(PARAMETER_DIR, "latest", "weapon");
}
export const loadWeaponInfoMain = () =>
loadLatestMushJson<typeof weaponInfoMain>("WeaponInfoMain");
export const loadWeaponInfoSub = () =>
loadLatestMushJson<typeof weaponInfoSub>("WeaponInfoSub");
export const loadWeaponInfoSpecial = () =>
loadLatestMushJson<typeof weaponInfoSpecial>("WeaponInfoSpecial");
export const loadGearInfoClothes = () =>
loadLatestMushJson<typeof gearInfoClothes>("GearInfoClothes");
export const loadGearInfoHead = () =>
loadLatestMushJson<typeof gearInfoHead>("GearInfoHead");
export const loadGearInfoShoes = () =>
loadLatestMushJson<typeof gearInfoShoes>("GearInfoShoes");
export const loadSplPlayerParams = () =>
loadLatestParameterMiscJson<typeof splPlayer>(
"SplPlayer.game__GameParameterTable",
);
export const loadDamageRateInfo = () =>
loadLatestParameterMiscJson<typeof damageRateInfo>(
"spl__DamageRateInfoConfig.pp__CombinationDataTableData",
);
function loadLatestMushJson<T>(fileName: string): T {
return JSON.parse(
fs.readFileSync(path.join(MUSH_DIR, "latest", `${fileName}.json`), "utf8"),
);
}
function loadLatestParameterMiscJson<T>(fileName: string): T {
return JSON.parse(
fs.readFileSync(
path.join(PARAMETER_DIR, "latest", "misc", `${fileName}.json`),
"utf8",
),
);
}