mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
35
apps/web-react/scripts/add-badge-winners.ts
Normal file
35
apps/web-react/scripts/add-badge-winners.ts
Normal 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`);
|
||||
30
apps/web-react/scripts/add-badge.ts
Normal file
30
apps/web-react/scripts/add-badge.ts
Normal 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}`);
|
||||
142
apps/web-react/scripts/add-leaderboard-teams-to-tournament.ts
Normal file
142
apps/web-react/scripts/add-leaderboard-teams-to-tournament.ts
Normal 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();
|
||||
240
apps/web-react/scripts/benchmark-db.ts
Normal file
240
apps/web-react/scripts/benchmark-db.ts
Normal 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`;
|
||||
}
|
||||
1327
apps/web-react/scripts/benchmark-db/cases.ts
Normal file
1327
apps/web-react/scripts/benchmark-db/cases.ts
Normal file
File diff suppressed because it is too large
Load Diff
1274
apps/web-react/scripts/benchmark-db/fixtures.ts
Normal file
1274
apps/web-react/scripts/benchmark-db/fixtures.ts
Normal file
File diff suppressed because it is too large
Load Diff
18
apps/web-react/scripts/check-articles.ts
Normal file
18
apps/web-react/scripts/check-articles.ts
Normal 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);
|
||||
});
|
||||
85
apps/web-react/scripts/check-season-status.ts
Normal file
85
apps/web-react/scripts/check-season-status.ts
Normal 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");
|
||||
}
|
||||
347
apps/web-react/scripts/check-translation-jsons.ts
Normal file
347
apps/web-react/scripts/check-translation-jsons.ts
Normal 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 })}`;
|
||||
}
|
||||
122
apps/web-react/scripts/collapse-single-plural-keys.ts
Normal file
122
apps/web-react/scripts/collapse-single-plural-keys.ts
Normal 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) };
|
||||
}
|
||||
3
apps/web-react/scripts/compute-luti-divs.ts
Normal file
3
apps/web-react/scripts/compute-luti-divs.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ComputeLutiDivsRoutine } from "~/routines/computeLutiDivs";
|
||||
|
||||
await ComputeLutiDivsRoutine.run();
|
||||
1266
apps/web-react/scripts/create-analyzer-json.ts
Normal file
1266
apps/web-react/scripts/create-analyzer-json.ts
Normal file
File diff suppressed because it is too large
Load Diff
153
apps/web-react/scripts/create-gear-json.ts
Normal file
153
apps/web-react/scripts/create-gear-json.ts
Normal 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();
|
||||
214
apps/web-react/scripts/create-league-divisions.ts
Normal file
214
apps/web-react/scripts/create-league-divisions.ts
Normal 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();
|
||||
66
apps/web-react/scripts/create-migration.ts
Normal file
66
apps/web-react/scripts/create-migration.ts
Normal 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);
|
||||
}
|
||||
342
apps/web-react/scripts/create-misc-json.ts
Normal file
342
apps/web-react/scripts/create-misc-json.ts
Normal 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();
|
||||
289
apps/web-react/scripts/create-object-dmg-json.ts
Normal file
289
apps/web-react/scripts/create-object-dmg-json.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
27
apps/web-react/scripts/delete-badge.ts
Normal file
27
apps/web-react/scripts/delete-badge.ts
Normal 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();
|
||||
22
apps/web-react/scripts/delete-db-files.mjs
Normal file
22
apps/web-react/scripts/delete-db-files.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
16
apps/web-react/scripts/delete-skill.ts
Normal file
16
apps/web-react/scripts/delete-skill.ts
Normal 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}`);
|
||||
23
apps/web-react/scripts/delete-user.ts
Normal file
23
apps/web-react/scripts/delete-user.ts
Normal 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}`);
|
||||
0
apps/web-react/scripts/dicts/.gitkeep
Normal file
0
apps/web-react/scripts/dicts/.gitkeep
Normal file
0
apps/web-react/scripts/dicts/langs/.gitkeep
Normal file
0
apps/web-react/scripts/dicts/langs/.gitkeep
Normal file
134
apps/web-react/scripts/ensure-test-db.ts
Normal file
134
apps/web-react/scripts/ensure-test-db.ts
Normal 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());
|
||||
}
|
||||
55621
apps/web-react/scripts/join-order.json
Normal file
55621
apps/web-react/scripts/join-order.json
Normal file
File diff suppressed because it is too large
Load Diff
42
apps/web-react/scripts/mass-add-tournaments-to-org.ts
Normal file
42
apps/web-react/scripts/mass-add-tournaments-to-org.ts
Normal 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();
|
||||
80
apps/web-react/scripts/migrate.ts
Normal file
80
apps/web-react/scripts/migrate.ts
Normal 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);
|
||||
});
|
||||
76
apps/web-react/scripts/nuke-reported-weapons.ts
Normal file
76
apps/web-react/scripts/nuke-reported-weapons.ts
Normal 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();
|
||||
190
apps/web-react/scripts/org-monthly-active-players.ts
Normal file
190
apps/web-react/scripts/org-monthly-active-players.ts
Normal 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);
|
||||
});
|
||||
0
apps/web-react/scripts/output/.gitkeep
Normal file
0
apps/web-react/scripts/output/.gitkeep
Normal file
139
apps/web-react/scripts/placements/index.ts
Normal file
139
apps/web-react/scripts/placements/index.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
76
apps/web-react/scripts/placements/schemas.ts
Normal file
76
apps/web-react/scripts/placements/schemas.ts
Normal 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(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
40
apps/web-react/scripts/refresh-prod-db.ts
Normal file
40
apps/web-react/scripts/refresh-prod-db.ts
Normal 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();
|
||||
21
apps/web-react/scripts/rename-badge.ts
Normal file
21
apps/web-react/scripts/rename-badge.ts
Normal 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}`);
|
||||
66
apps/web-react/scripts/replace-img-names.ts
Normal file
66
apps/web-react/scripts/replace-img-names.ts
Normal 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();
|
||||
100
apps/web-react/scripts/replace-weapon-names.ts
Normal file
100
apps/web-react/scripts/replace-weapon-names.ts
Normal 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();
|
||||
776
apps/web-react/scripts/scanner/bootstrap-atlas-from-fixture.ts
Normal file
776
apps/web-react/scripts/scanner/bootstrap-atlas-from-fixture.ts
Normal 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);
|
||||
481
apps/web-react/scripts/scanner/build-glyph-atlas.ts
Normal file
481
apps/web-react/scripts/scanner/build-glyph-atlas.ts
Normal 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() },
|
||||
]);
|
||||
436
apps/web-react/scripts/scanner/build-localized-entries.ts
Normal file
436
apps/web-react/scripts/scanner/build-localized-entries.ts
Normal 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`,
|
||||
);
|
||||
111
apps/web-react/scripts/scanner/build-planner-signatures.ts
Normal file
111
apps/web-react/scripts/scanner/build-planner-signatures.ts
Normal 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}`,
|
||||
);
|
||||
93
apps/web-react/scripts/scanner/dump-crops.ts
Normal file
93
apps/web-react/scripts/scanner/dump-crops.ts
Normal 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}`);
|
||||
61
apps/web-react/scripts/scanner/otf-cmap.ts
Normal file
61
apps/web-react/scripts/scanner/otf-cmap.ts
Normal 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);
|
||||
}
|
||||
145
apps/web-react/scripts/scanner/overlay-rois.ts
Normal file
145
apps/web-react/scripts/scanner/overlay-rois.ts
Normal 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}`);
|
||||
82
apps/web-react/scripts/scanner/replay-frames.ts
Normal file
82
apps/web-react/scripts/scanner/replay-frames.ts
Normal 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();
|
||||
}
|
||||
528
apps/web-react/scripts/scanner/report.ts
Normal file
528
apps/web-react/scripts/scanner/report.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
52
apps/web-react/scripts/scanner/run-fixtures.ts
Normal file
52
apps/web-react/scripts/scanner/run-fixtures.ts
Normal 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(",")}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
321
apps/web-react/scripts/scanner/scan-vod.ts
Normal file
321
apps/web-react/scripts/scanner/scan-vod.ts
Normal 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));
|
||||
}
|
||||
930
apps/web-react/scripts/scanner/status-audit.ts
Normal file
930
apps/web-react/scripts/scanner/status-audit.ts
Normal 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.",
|
||||
);
|
||||
}
|
||||
18
apps/web-react/scripts/scanner/vite-node.config.ts
Normal file
18
apps/web-react/scripts/scanner/vite-node.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
166
apps/web-react/scripts/season-initial-powers.ts
Normal file
166
apps/web-react/scripts/season-initial-powers.ts
Normal 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)`,
|
||||
);
|
||||
77
apps/web-react/scripts/seed-art-urls.ts
Normal file
77
apps/web-react/scripts/seed-art-urls.ts
Normal 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],
|
||||
}),
|
||||
);
|
||||
249
apps/web-react/scripts/seed-images.ts
Normal file
249
apps/web-react/scripts/seed-images.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
8
apps/web-react/scripts/seed.ts
Normal file
8
apps/web-react/scripts/seed.ts
Normal 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`);
|
||||
41
apps/web-react/scripts/send-test-notification.ts
Normal file
41
apps/web-react/scripts/send-test-notification.ts
Normal 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);
|
||||
35
apps/web-react/scripts/setup.ts
Normal file
35
apps/web-react/scripts/setup.ts
Normal 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();
|
||||
22
apps/web-react/scripts/skip-plus.ts
Normal file
22
apps/web-react/scripts/skip-plus.ts
Normal 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})`,
|
||||
);
|
||||
25
apps/web-react/scripts/swap-users.ts
Normal file
25
apps/web-react/scripts/swap-users.ts
Normal 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);
|
||||
});
|
||||
169
apps/web-react/scripts/sync-homemade-badges.ts
Normal file
169
apps/web-react/scripts/sync-homemade-badges.ts
Normal 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();
|
||||
440
apps/web-react/scripts/sync-weapon-params.ts
Normal file
440
apps/web-react/scripts/sync-weapon-params.ts
Normal 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));
|
||||
8
apps/web-react/scripts/tsconfig.json
Normal file
8
apps/web-react/scripts/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["./**/*.ts", "../types/**/*.d.ts"],
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
15
apps/web-react/scripts/unskip-plus.ts
Normal file
15
apps/web-react/scripts/unskip-plus.ts
Normal 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}`);
|
||||
107
apps/web-react/scripts/utils.ts
Normal file
107
apps/web-react/scripts/utils.ts
Normal 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",
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user