Simplify comments

This commit is contained in:
Kalle
2026-09-02 21:02:01 +03:00
parent fe797e1891
commit 6a307ce59e
770 changed files with 4302 additions and 11417 deletions

View File

@@ -60,10 +60,7 @@ export interface BenchmarkCase {
run: () => Promise<unknown>;
}
/**
* Builds the full benchmark case registry from resolved fixtures. Cases whose
* fixture resolved to null are returned in `skipped` instead.
*/
/** Builds the benchmark case registry from resolved fixtures; cases whose fixture resolved to null go to `skipped`. */
export function buildCases(fx: Fixtures): {
cases: BenchmarkCase[];
skipped: string[];
@@ -87,7 +84,6 @@ export function buildCases(fx: Fixtures): {
cases.push({ name, run: async () => run() });
}
// AdminRepository
addStatic("AdminRepository.findAllBannedUsers", () =>
AdminRepository.findAllBannedUsers(),
);
@@ -95,7 +91,6 @@ export function buildCases(fx: Fixtures): {
AdminRepository.findModNoteById(id),
);
// ExternalStreamRepository
addStatic("ExternalStreamRepository.findAll", () =>
ExternalStreamRepository.findAll(),
);
@@ -103,7 +98,6 @@ export function buildCases(fx: Fixtures): {
ExternalStreamRepository.findAllForSidebar(),
);
// ApiRepository
add("ApiRepository.findTokenByUserId", fx.apiTokenUserId, (userId) =>
ApiRepository.findTokenByUserId(userId, "read"),
);
@@ -111,7 +105,6 @@ export function buildCases(fx: Fixtures): {
ApiRepository.findAllApiTokens(),
);
// ArtRepository
addStatic("ArtRepository.findShowcaseArts", () =>
ArtRepository.findShowcaseArts(),
);
@@ -129,7 +122,6 @@ export function buildCases(fx: Fixtures): {
ArtRepository.findById(artId),
);
// AssociationRepository
add("AssociationRepository.findById", fx.heavyAssociation, (association) =>
AssociationRepository.findById(association.id, { withMembers: true }),
);
@@ -155,12 +147,10 @@ export function buildCases(fx: Fixtures): {
(association) => AssociationRepository.findInviteCodeById(association.id),
);
// LogInLinkRepository
add("LogInLinkRepository.findValidByCode", fx.logInLinkCode, (code) =>
LogInLinkRepository.findValidByCode(code),
);
// AvailabilityRepository
add(
"AvailabilityRepository.findAllWeeksByUserIds",
both(fx.manyUserIds, fx.availabilityWindow),
@@ -220,7 +210,6 @@ export function buildCases(fx: Fixtures): {
AvailabilityRepository.findTeamEventById(id),
);
// BadgeRepository
addStatic("BadgeRepository.findAll", () => BadgeRepository.findAll());
add("BadgeRepository.findById", fx.heavyBadgeId, (badgeId) =>
BadgeRepository.findById(badgeId),
@@ -238,7 +227,6 @@ export function buildCases(fx: Fixtures): {
BadgeRepository.findByAuthorUserId(userId),
);
// BuildRepository
add("BuildRepository.findAllByUserId", fx.heavyBuildUserId, (userId) =>
BuildRepository.findAllByUserId(userId, {
showPrivate: true,
@@ -269,7 +257,6 @@ export function buildCases(fx: Fixtures): {
}),
);
// CalendarRepository
add(
"CalendarRepository.findAllBetweenTwoTimestamps",
fx.calendarWindow,
@@ -297,7 +284,6 @@ export function buildCases(fx: Fixtures): {
(eventId) => CalendarRepository.findTopThreeResultsByEventIds([eventId]),
);
// ChatRepository
add("ChatRepository.findAllRoomsByIds", fx.heavyChatUsers, (chatUsers) =>
ChatRepository.findAllRoomsByIds(chatUsers.busiest.openRoomIds),
);
@@ -329,7 +315,6 @@ export function buildCases(fx: Fixtures): {
),
);
// ChatRoomResolver
// a tournament match room is the costliest to resolve: its owner join carries
// the team members and the tournament's organizer permissions on top
add(
@@ -341,7 +326,6 @@ export function buildCases(fx: Fixtures): {
ChatRoomResolver.findAllByUserId(chatUsers.busiest.id),
);
// FriendRepository
add("FriendRepository.findByUserIdWithActivity", fx.heavyFriendPair, (pair) =>
FriendRepository.findByUserIdWithActivity(pair.userId),
);
@@ -391,7 +375,6 @@ export function buildCases(fx: Fixtures): {
(request) => FriendRepository.findFriendRequestByIdAndReceiver(request),
);
// ImageRepository
add("ImageRepository.findById", fx.imageId, (imageId) =>
ImageRepository.findById(imageId),
);
@@ -410,7 +393,6 @@ export function buildCases(fx: Fixtures): {
(userId) => ImageRepository.countUnvalidatedBySubmitterUserId(userId),
);
// LeaderboardRepository
add("LeaderboardRepository.findTeamLeaderboardBySeason", fx.sq, (sq) =>
LeaderboardRepository.findTeamLeaderboardBySeason({
season: sq.season,
@@ -441,7 +423,6 @@ export function buildCases(fx: Fixtures): {
LeaderboardRepository.findSeasonPopularUsersWeapon(sq.season),
);
// LFGRepository
addStatic("LFGRepository.findAllPosts.anon", () =>
LFGRepository.findAllPosts(),
);
@@ -452,17 +433,14 @@ export function buildCases(fx: Fixtures): {
LFGRepository.findByAuthorUserId(authorId),
);
// LiveStreamRepository
addStatic("LiveStreamRepository.findXRankStreams", () =>
LiveStreamRepository.findXRankStreams(),
);
// MatchProfileRepository
add("MatchProfileRepository.findSettingsByUserId", fx.heavyUser, (user) =>
MatchProfileRepository.findSettingsByUserId(user.id),
);
// SkillRepository (app/features/mmr)
add("SkillRepository.findCurrentUserSkills", fx.skillBatch, (skillBatch) =>
SkillRepository.findCurrentUserSkills({
season: skillBatch.season,
@@ -497,7 +475,6 @@ export function buildCases(fx: Fixtures): {
SkillRepository.findSeasonActiveDaysByUserId(sq),
);
// NotificationRepository
add("NotificationRepository.findByUserId", fx.notification, (notification) =>
NotificationRepository.findByUserId(notification.userId, { limit: 50 }),
);
@@ -510,7 +487,6 @@ export function buildCases(fx: Fixtures): {
(userIds) => NotificationRepository.findAllSubscriptionsByUserIds(userIds),
);
// PlusSuggestionRepository
add(
"PlusSuggestionRepository.findAllByMonth",
fx.plusSuggestionMonthYear,
@@ -530,7 +506,6 @@ export function buildCases(fx: Fixtures): {
(args) => PlusSuggestionRepository.findMonthSummary(args),
);
// PlusVotingRepository
addStatic("PlusVotingRepository.findAllPlusTiersFromLatestVoting", () =>
PlusVotingRepository.findAllPlusTiersFromLatestVoting(),
);
@@ -550,7 +525,6 @@ export function buildCases(fx: Fixtures): {
}),
);
// ScannerIngestRepository
add(
"ScannerIngestRepository.gamesPlayedByUserInTournament",
fx.scannerIngest,
@@ -637,7 +611,6 @@ export function buildCases(fx: Fixtures): {
ScannerIngestRepository.gamesInTournamentMatch(tournamentMatchId),
);
// ScrimMapListRepository
add(
"ScrimMapListRepository.findMapListsByScrimPostId",
fx.heavyScrimPostId,
@@ -645,14 +618,12 @@ export function buildCases(fx: Fixtures): {
ScrimMapListRepository.findMapListsByScrimPostId(scrimPostId),
);
// ScrimMapRepository
add(
"ScrimMapRepository.findMapsByScrimPostId",
fx.heavyScrimPostId,
(scrimPostId) => ScrimMapRepository.findMapsByScrimPostId(scrimPostId),
);
// ScrimPostRepository
add(
"ScrimPostRepository.findAllByChatRoomIds",
fx.openChatRoomIdsByType?.SCRIM ?? null,
@@ -699,14 +670,12 @@ export function buildCases(fx: Fixtures): {
}),
);
// GroupMatchContinueVoteRepository
add(
"GroupMatchContinueVoteRepository.findAllByGroupIds",
fx.heavyGroupIds,
(groupIds) => GroupMatchContinueVoteRepository.findAllByGroupIds(groupIds),
);
// PlayerStatRepository
add("PlayerStatRepository.findSeasonMapWinrateByUserId", fx.sq, (sq) =>
PlayerStatRepository.findSeasonMapWinrateByUserId(sq),
);
@@ -732,7 +701,6 @@ export function buildCases(fx: Fixtures): {
PlayerStatRepository.findSeasonTournamentRunsByUserId(sq),
);
// ReportedWeaponRepository
add(
"ReportedWeaponRepository.findByMatchId",
fx.heavyGroupMatchId,
@@ -760,7 +728,6 @@ export function buildCases(fx: Fixtures): {
}),
);
// SQMatchRepository
add(
"SQMatchRepository.findAllByChatRoomIds",
fx.openChatRoomIdsByType?.SQ_MATCH ?? null,
@@ -793,12 +760,11 @@ export function buildCases(fx: Fixtures): {
}),
);
// QStreamsRepository
addStatic("QStreamsRepository.findAllActiveMatchPlayers", () =>
QStreamsRepository.findAllActiveMatchPlayers(),
);
// PrivateUserNoteRepository (relies on the benchmark's actor context)
// relies on the benchmark's actor context
addStatic("PrivateUserNoteRepository.findAllOwn.all", () =>
PrivateUserNoteRepository.findAllOwn(),
);
@@ -808,7 +774,6 @@ export function buildCases(fx: Fixtures): {
(userIds) => PrivateUserNoteRepository.findAllOwn(userIds),
);
// SQGroupRepository
add(
"SQGroupRepository.findAllByChatRoomIds",
fx.openChatRoomIdsByType?.SQ_GROUP ?? null,
@@ -858,12 +823,10 @@ export function buildCases(fx: Fixtures): {
SQGroupRepository.findRecentlyFinishedMatches(),
);
// SplatoonRotationRepository
addStatic("SplatoonRotationRepository.findAll", () =>
SplatoonRotationRepository.findAll(),
);
// TeamRepository
addStatic("TeamRepository.searchByName", () =>
TeamRepository.searchByName(SEARCH_QUERY),
);
@@ -888,7 +851,6 @@ export function buildCases(fx: Fixtures): {
TeamRepository.findAllByMemberUserId(team.memberUserId),
);
// XRankPlacementRepository
add("XRankPlacementRepository.isPlayerLinkedByUserId", fx.xrank, (xrank) =>
XRankPlacementRepository.isPlayerLinkedByUserId(xrank.userId),
);
@@ -919,14 +881,12 @@ export function buildCases(fx: Fixtures): {
XRankPlacementRepository.findPeaksByUserId(xrank.userId, "both"),
);
// BracketRepository
add(
"BracketRepository.findByTournamentId",
fx.heaviestBracketTournamentId,
(tournamentId) => BracketRepository.findByTournamentId(tournamentId),
);
// TournamentMatchVodRepository
add(
"TournamentMatchVodRepository.findVodsByTournamentId",
fx.heavyTournamentId,
@@ -957,7 +917,6 @@ export function buildCases(fx: Fixtures): {
),
);
// TournamentLFGRepository
add(
"TournamentLFGRepository.findLookingTeamsByTournamentId",
fx.lfgTournament,
@@ -977,7 +936,6 @@ export function buildCases(fx: Fixtures): {
TournamentLFGRepository.findAllSubsByTournamentId(lfg.tournamentId),
);
// TournamentMatchRepository
add(
"TournamentMatchRepository.findAllByChatRoomIds",
fx.openChatRoomIdsByType?.TOURNAMENT_MATCH ?? null,
@@ -1019,7 +977,6 @@ export function buildCases(fx: Fixtures): {
TournamentMatchRepository.findByTournamentTeamId(tournamentTeamId),
);
// TournamentOrganizationRepository
add("TournamentOrganizationRepository.findBySlug", fx.heavyOrg, (org) =>
TournamentOrganizationRepository.findBySlug(org.slug),
);
@@ -1109,7 +1066,6 @@ export function buildCases(fx: Fixtures): {
}),
);
// SavedCalendarEventRepository
add(
"SavedCalendarEventRepository.isSaved",
both(fx.heavyUser, fx.heavyTournamentId),
@@ -1128,7 +1084,6 @@ export function buildCases(fx: Fixtures): {
(user) => SavedCalendarEventRepository.findAllUpcomingByUserId(user.id),
);
// TournamentAuditLogRepository
add(
"TournamentAuditLogRepository.findByTournamentId",
fx.auditTournamentId,
@@ -1152,7 +1107,6 @@ export function buildCases(fx: Fixtures): {
TournamentAuditLogRepository.findTeamsByTournamentId(tournamentId),
);
// TournamentRepository
add(
"TournamentRepository.findOrganizerPermissionsByTournamentIds",
fx.recentTournamentIds,
@@ -1269,7 +1223,6 @@ export function buildCases(fx: Fixtures): {
TournamentRepository.findRunningTournamentIds(),
);
// TournamentTeamRepository
add(
"TournamentTeamRepository.findAllByChatRoomIds",
fx.openChatRoomIdsByType?.TOURNAMENT_TEAM ?? null,
@@ -1325,7 +1278,6 @@ export function buildCases(fx: Fixtures): {
}),
);
// TrophyRepository
addStatic("TrophyRepository.all", () => TrophyRepository.all());
add("TrophyRepository.findById", fx.trophy, (trophy) =>
TrophyRepository.findById(trophy.heavyTrophyId),
@@ -1340,7 +1292,6 @@ export function buildCases(fx: Fixtures): {
TrophyRepository.findWinsByOwner(trophy.wins),
);
// UserCardRepository
add("UserCardRepository.findAllByUserIds", fx.manyUserIds, (userIds) =>
UserCardRepository.findAllByUserIds({
userIds,
@@ -1358,7 +1309,6 @@ export function buildCases(fx: Fixtures): {
UserCardRepository.findVerifiedXpByUserId(user.id, null),
);
// UserRepository
add("UserRepository.findIdByIdentifier", fx.heavyUser, (user) =>
UserRepository.findIdByIdentifier(user.identifier),
);
@@ -1463,7 +1413,6 @@ export function buildCases(fx: Fixtures): {
UserRepository.findWeaponPoolByUserId(user.id),
);
// VodRepository
add("VodRepository.findByUserId", fx.vod, (vod) =>
VodRepository.findByUserId(vod.userId),
);

View File

@@ -137,10 +137,8 @@ export interface Fixtures {
}
/**
* Resolves "worst-case" benchmark arguments by sampling the heaviest rows from
* the database (user with most tournament results, tournament with most teams
* and so on). Fields resolve to null when the source table is empty which
* causes the dependent benchmark cases to be skipped.
* Resolves worst-case benchmark arguments by sampling the heaviest rows (user with most
* results, tournament with most teams...). Fields resolve to null when the source table is empty, skipping their cases.
*/
export async function resolveFixtures(): Promise<Fixtures> {
const heavyUser = await resolveHeavyUser();

View File

@@ -12,10 +12,8 @@ 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.
// `_zero` is an i18next `count === 0` override, not a CLDR category: it legitimately sits beside the
// bare singular key single-plural languages (zh, ja, ko) use, so it never counts as a suffix clash
const ZERO_SUFFIX = "_zero";
const PLURAL_SUFFIXES = KNOWN_SUFFIXES.filter((sfx) => sfx !== ZERO_SUFFIX);
@@ -188,7 +186,7 @@ function validateNoDuplicateKeys({
}
}
// get keys while respecting different plural/context key suffixes in different languages.
// keys with their language-specific plural/context suffixes stripped
function getKeysWithoutSuffix(
translations: Record<string, string>,
lang: string,

View File

@@ -9,31 +9,22 @@ 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`.
// writes nothing and exits non-zero if any file would be collapsed, so CI can
// fail a PR that committed suffixed plural keys 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`.
// `_zero` is excluded: an i18next `count === 0` override rather than a CLDR category, which
// `i18next-locales-sync` treats as a plain key preserved across every language
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.
// the form i18next resolves at runtime for these languages, typically carrying `{{count}}`;
// the other forms are only a fallback when it 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.
// `i18next-locales-sync` keys single-category languages (zh, ja, ko) by the bare singular key;
// a translation a translator added under a suffixed key is silently dropped by the sync unless
// collapsed into the singular key first, which is what this script does
const languages = fs
.readdirSync(LOCALES_PATH)
.filter((lang) => lang !== PRIMARY_LANGUAGE && !lang.startsWith("."));
@@ -103,8 +94,7 @@ function collapsePluralKeys(content: Record<string, string>) {
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
// `_other` wins; otherwise the first non-empty value of the remaining forms
const shouldReplace =
!(baseKey in collapsed) ||
(!collapsed[baseKey] && !!value) ||

View File

@@ -695,20 +695,16 @@ function parametersToSubWeaponResult(
};
}
// Thrown targeting specials (Booyah Bomb, Super Chump, Triple Inkstrike, Ultra Stamp) are
// player-aimed and have no throw-distance param in the game data, so their throw (fly) range is
// approximated with a constant. Kept as named per-special constants so each can be tuned later;
// for now they all reuse Booyah Bomb's value.
// Thrown targeting specials (Booyah Bomb, Super Chump, Triple Inkstrike, Ultra Stamp) are player-aimed
// with no throw-distance param, so their fly range is a per-special constant, all Booyah Bomb's value for now.
const BOOYAH_BOMB_FLY_DISTANCE = 27;
const SUPER_CHUMP_FLY_DISTANCE = 30;
const TRIPLE_INKSTRIKE_FLY_DISTANCE = 28;
const ULTRA_STAMP_FLY_DISTANCE = 24;
// Range circle data for the map planner. Projectile / thrown specials store the distance the shot
// travels plus its blast; a few store a single effect radius. Specials whose reach is global
// (Tenta Missiles, Killer Wail), placed or utility (Big Bubbler, Tacticooler, Ink Storm, Ink Vac),
// or otherwise has no meaningful circle (Kraken Royale, Splattercolor Screen) are left out.
// See app/features/comp-analyzer/core/special-weapon-range.ts.
// Range circles for the map planner (see app/features/comp-analyzer/core/special-weapon-range.ts):
// travel distance plus blast, or a single effect radius. Global (Tenta Missiles, Killer Wail), placed/utility
// (Big Bubbler, Tacticooler, Ink Storm, Ink Vac) and circle-less (Kraken Royale, Splattercolor Screen) specials are left out.
function specialWeaponRangeParams(
specialWeapon: SpecialWeapon,
rawParams: any,
@@ -785,8 +781,7 @@ function specialWeaponRangeParams(
}
}
// some specials lack damage values in the params
// so they are instead hardcoded here as a workaround
// some specials lack damage values in the params, so they are hardcoded here
function parametersToSpecialWeaponResult(params: any) {
const result: any = {};

View File

@@ -114,9 +114,8 @@ const specialIdsForRow = (rowKey: string) =>
);
/**
* 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.
* Per-target damage rates of every damage rate info row in one config dump, PvP-relevant receivers
* only, with the synthetic launched/Recycled Brella canopy targets derived as the calculator expects.
*/
const damageRatesByRow = (
config: DamageRateConfig,
@@ -190,9 +189,8 @@ function versionDirToDisplay(version: string): string {
}
/**
* 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.
* Writes the per-row, per-target damage rate history across every versioned dump for the params
* page's patch history. Only PvP-relevant rows and targets whose rate ever changed are kept.
*/
function writeDamageRateHistory() {
const versionDirs = fs

View File

@@ -15,23 +15,17 @@ 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.
// test workers only read this file to copy its schema; out of WAL there are no
// -wal/-shm sidecars, so their concurrent opens need no write access
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.
* Creates `dbPath` if missing, applies pending migrations, and rebuilds it from scratch if an
* applied migration disappeared or changed contents: 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 does not.
*/
export function ensureMigratedDb(dbPath: string) {
const resolvedPath = path.resolve(ROOT_DIR, dbPath);

View File

@@ -120,10 +120,7 @@ async function main() {
}
}
/**
* The screenshotted page is whatever dev server holds the port, which can be another
* checkout of the repo whose entries are not the ones being shipped.
*/
/** The dev server holding the port can be another checkout whose entries are not the ones being shipped. */
async function assertSameCheckout(page: Page) {
const marker = page.locator("[data-changelog-head]");
const pageHead =

View File

@@ -37,8 +37,7 @@ async function main() {
});
await page.goto(OG_IMAGES_PAGE_URL, { waitUntil: "networkidle" });
// the page previews the canvases scaled down and inside the site layout, the
// one being captured is blown up to full size and lifted on top of everything
// the page previews the canvases scaled down; the captured one is blown up to full size on top of everything
await page.addStyleTag({
content: `
* { --og-scale: 1 !important }

View File

@@ -1,7 +1,5 @@
// 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
// org stats for the last 6 finished months, deciding which orgs are "established":
// 150 average monthly active players to gain it, dropping below 100 loses it
import { db } from "~/db/sql";
import { logger } from "~/utils/logger";

View File

@@ -18,22 +18,18 @@ function main() {
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);
}

View File

@@ -1,15 +1,14 @@
/** 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.
* Bootstraps glyph atlases from labeled reference fixtures: slices templates
* straight out of the 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 char and
* recognition takes the best-scoring one. build-glyph-atlas.ts then fills the
* rest of the charset from the fonts, preserving these fixture-tagged glyphs.
*
* Usage: pnpm scanner:bootstrap-atlas
* Writes SCANNER_ASSETS_DIR/glyphs/scoreboard-{names,paint-digits,stat-digits}.{png,json}
* Writes SCANNER_ASSETS_DIR/glyphs/*.{png,json}
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -61,9 +60,8 @@ interface Source {
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.
* absolute x positions where merged segments must be cut, keyed by row —
* e.g. "Te" in Teddy renders as one segment, boundary shifting a few px per source
*/
nameSplitHints: Record<number, number[]>;
/** omit to skip header harvest for this source */
@@ -94,10 +92,9 @@ const REFERENCE = {
["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).
* Team totals render outlined on the colored team box, so they get their own
* atlas. This fixture only contributes '5' and '0'; harvest keeps the first
* instance per char, so rerunning with more fixtures is additive.
*/
teamScores: ["500", "0"],
header: {
@@ -154,11 +151,10 @@ const SOURCES: Source[] = [
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.
// Renders names slightly smaller than the reference: its 'T' (9x15) loses
// to the taller reference crops, and its baseline dots are homoglyphs the
// font templates can't split ('.' 4px, '・' 5px; exact crops separate them
// via the ink-coverage penalty). Only the dot/T rows are labeled.
frame: "scoreboard/robot/frame.png",
names: ["R.O.B.O.T", "", "", "", "", "Rαι×ι..・", "", ""],
paints: ["", "", "", "", "", "", "", ""],
@@ -239,10 +235,9 @@ async function loadGreen(framePath: string): Promise<Mat> {
}
/**
* 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.
* Death splash-tag name band, prepared the way death/index.ts reads it: 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)));
@@ -321,11 +316,9 @@ async function loadTagBand(framePath: string): Promise<Mat> {
}
/**
* 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.
* Zeroes ink components touching the band border, as the detector does before
* parsing (clearBorderBlobs in death/index.ts): banner art and title-line
* slivers at the edges would join the column runs or push a tight box past it.
*/
function clearBorderBlobs(band: Mat, threshold: number): void {
const bin = new cv.Mat();
@@ -384,9 +377,8 @@ function columnRuns(
}
}
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.
// Merge hints bridge runs belonging to one glyph (multi-stroke kana like パ
// segment as two strokes): a hint x inside the gap joins the two runs.
for (const hint of mergeHints) {
const i = runs.findIndex(
(r, idx) =>
@@ -460,10 +452,9 @@ function cropGlyph(
}
/**
* Slice a labeled text ROI into per-char crops. A Map collector keeps the
* Slices 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.
* collector keeps every instance (letters land on different subpixel phases).
*/
function harvest(
roi: Roi,
@@ -621,8 +612,7 @@ for (const source of SOURCES) {
}
// 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.
// live-scoreboard atlas covers. Every occurrence kept (array), like names.
const REPLAY_SOURCES: { frame: string; code: string }[] = [
{
frame:
@@ -639,10 +629,9 @@ const REPLAY_SOURCES: { frame: string; code: string }[] = [
"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.
// Heavily compressed stream captures: font-rendered templates lose to fixture
// crops of lookalikes here (E beat a real F by 0.1+), so the chars they cover
// 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",
@@ -671,9 +660,8 @@ for (const source of REPLAY_SOURCES) {
}
// 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.
// nominal 42 with the detector upscaling to 46 at load (see build-glyph-atlas.ts),
// so native-size crops shrink to 42 here.
const TAG_BIN_THRESHOLD = 160; // TAG_NAME_BIN_THRESHOLD in death/index.ts
const TAG_ATLAS_HEIGHT = 42;
const DEATH_TAG_SOURCES: {
@@ -725,10 +713,9 @@ for (const source of DEATH_TAG_SOURCES) {
}
}
// 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.
// JA death-message lines (condensed Kurokane/Rowdy blend at ~40px; native-size
// atlas, matched unscaled by the 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;

View File

@@ -1,19 +1,15 @@
/** 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:
* Builds 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)
* team digits BlitzBold 36px (~28px — bold face, so paint glyphs can't be reused)
* names BlitzMain 20px (cap height ~17px), ASCII + Latin-1 + kana + symbols
*
* 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.
* assets/fonts/ (see README); bootstrap-atlas-from-fixture.ts is the fallback.
*
* Usage: pnpm scanner:build-glyph-atlas
* Writes SCANNER_ASSETS_DIR/glyphs/scoreboard-*.{png,json}
@@ -39,11 +35,9 @@ 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.
* Atlases are hybrids: fixture-harvested glyphs (bootstrap-atlas-from-fixture.ts,
* source:"fixture") are exact in-game pixels preserved across rebuilds;
* font-rendered glyphs fill the rest of the charset and lose ties to them.
*/
const FONTS_DIR = new URL("../../assets/fonts", import.meta.url).pathname;
@@ -59,11 +53,9 @@ const FONT_FILES = {
} 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.
* Which codepoints each font maps: canvas silently substitutes a system font
* for the rest (the Blitz cuts have no kanji/hangul/hanzi), so charsets are
* filtered through this so no wrong-font glyphs get baked into the atlases.
*/
const fontCoverage: Record<string, (codepoint: number) => boolean> = {};
@@ -100,35 +92,31 @@ function nameCharset(): string[] {
}
/**
* 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.
* Greek letters players stylize names with, added per attested fixture only
* ("Rιppιng_H"): most of the block are homoglyphs of latin/kana at capture
* fidelity (η~n, ε~c) and displace correct matches on ranking noise. Even a
* few narrow glyphs shift an atlas's median width enough to change
* wide-segment splitting, so it stays out of the death-tag charset until attested.
*/
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.
* IN_GAME_NAME_CHARACTER_CATEGORIES "symbols" + "cjk-symbols"), minus what
* nameCharset() already carries and chars the Blitz cmap doesn't map
* (ˊˋ𝑓⁀⚪⚫◻◼⍑; nameSymbols() re-checks at build time). "•" stays out: BlitzMain's
* bullet is a 4px dot, the on-screen circle comes from "●" via RENDER_ALIASES.
* Only the fullwidth "~" (U+FF5E), the form fixture labels attest — the wave
* dash "〜" (U+301C) is a pixel-identical homoglyph that would duel it.
* Scoreboard-names only (not death-tag) until attested, like NAME_GREEK.
*/
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 "•".
* Render the key char but emit it as the value: in-game names show "•" as a
* full-size filled circle (BlitzMain's own "•" is a 4px dot never seen on
* screen) and fixture labels write it as "•".
*/
const RENDER_ALIASES: Record<string, string> = { "●": "•" };
@@ -163,9 +151,8 @@ async function readFixtureGlyphs(name: string): Promise<GlyphBitmap[]> {
}
/**
* 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).
* Renders one glyph and tight-crops it via the alpha channel. xScale < 1
* condenses horizontally (the JA death message renders squeezed to ~3/4 width).
*/
function renderGlyph(
family: string,
@@ -331,15 +318,12 @@ await build("scoreboard-names", 17, [
},
]);
/**
* 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.
* Localized closed-set charsets (all 14 game languages), restricted to the
* Latin scripts (< U+0250) the fixtures attest: wholesale Cyrillic/kana/
* ideograph glyphs displace Latin matches on ranking noise (adding them
* regressed the English fixtures), so non-Latin entries stay in the closed
* sets but their glyphs wait for fixtures. Chars the font doesn't map are
* dropped too (canvas would render a system-font substitute).
*/
function localizedChars(texts: readonly string[], family: string): string[] {
const covers = fontCoverage[family]!;
@@ -391,12 +375,10 @@ await build("scoreboard-replay-result", 30, [
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.
// map-start intro splash: the mode title on the center splat is BlitzBold
// (~76px tight caps at 1080p; a px sweep reads best at 99-101), the stage name
// bottom-right is BlitzMain (~40px tight; 46-48px render). The constant "MODE"
// label is BlitzMain too, read with the stage atlas rescaled to ~48px.
await build("map-start-mode", 76, [
{
family: "BlitzBold",
@@ -411,11 +393,9 @@ await build("map-start-stage", 40, [
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
// death screen: the localized "Splatted by <weapon>!" burst (tight caps ~28px;
// the face reads between the two Blitz cuts at capture fidelity, so carry both)
// and the splash-tag name: BlitzBold for latin, the angular Rowdy face for kana
const deathWeaponTexts = [
...ALL_WEAPON_ENTRIES.map((e) => e.name),
...Object.values(LOCALIZED_WEAPON_NAMES).flatMap((names) =>
@@ -439,15 +419,12 @@ await build("death-weapon", 34, [
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.
// 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. The in-game JP face renders condensed
// and sits between the two FOT cuts (ス/ク 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. 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) =>

View File

@@ -1,25 +1,19 @@
/** 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).
* Generates the localized closed sets from the splat3 repo's language dumps
* (https://github.com/Leanny/splat3, data/language/<Lang>_full.json) so
* ingestion works in any game language: detectors OCR what is on screen, snap
* it against every language's entries, and emit the sendou.ink id.
*
* Sources per language:
* CommonMsg/VS/VSRuleName modes (+ the _2L two-line intro-splash
* wrap variants, e.g. "Muschel-\nchaos")
* CommonMsg/VS/VSRuleName modes (+ _2L two-line intro-splash wraps, "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
* LayoutMsg/VS_Beaten_00 (999) death-burst message; the weapon placeholder sits on
* line 1 or 2 by language, so it becomes a per-language template
* CommonMsg/Weapon/WeaponName_* weapon names, mapped to canonical entries via USen
*
* Usage: pnpm scanner:build-localized-entries [path-to-splat3]
* Writes app/features/scanner/core/localized-entries.ts
@@ -119,8 +113,6 @@ if (!languages.includes(CANONICAL_LANG)) {
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}`]!;
@@ -152,8 +144,6 @@ for (const [name, stageId] of stageIdByEnglishName) {
}
}
// ---- per-language closed sets ----------------------------------------------
interface LocalizedLobby {
text: string;
lobby: ScannerLobby;
@@ -233,8 +223,6 @@ for (const category of ["lobbies", "modes", "modeWraps", "stages"] as const) {
}
}
// ---- death message templates -----------------------------------------------
const PLACEHOLDER = /\[group=[^\]]*\]/;
/** stands in for the weapon placeholder while splitting the message */
const SENTINEL = "\u0000";
@@ -282,8 +270,6 @@ for (const lang of languages) {
});
}
// ---- localized weapon names --------------------------------------------------
const WEAPON_MSGS = [
"CommonMsg/Weapon/WeaponName_Main",
"CommonMsg/Weapon/WeaponName_Sub",
@@ -328,8 +314,6 @@ for (const lang of languages) {
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

View File

@@ -1,16 +1,13 @@
/** 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.
* Builds the planner signature atlas from the assets repo's planner renders:
* assets/planner-maps/ in the sendou-ink/assets checkout holds the full PNGs
* (~340MB) named "<stageId>-<MODE>-<TYPE>.png" (MODE in CB/RM/SZ/TC/TW; TYPE
* in OVER/MINI/ITEMS). Each PLANNER_TYPE render is reduced to the
* ink-invariant structural signature (core/detectors/minimap/stage.ts) and
* packed, quantized to uint8, into one grayscale atlas PNG plus a manifest
* (keys "<stageId>-<MODE>"). Output goes to SCANNER_ASSETS_DIR/planner;
* shipping a regen means pushing the assets repo.
*
* pnpm scanner:build-planner-signatures
*/

View File

@@ -1,13 +1,10 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Minimal OpenType cmap reader: which codepoints does a font actually map?
*
* Minimal OpenType cmap reader: which codepoints a font actually maps.
* @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).
* family lacks (the Blitz cuts have no kanji, hangul or hanzi), which would
* bake wrong-font glyphs into the atlases. Supports format 4 (BMP segments)
* and format 12 (grouped full-range), the two the game fonts use.
*/
import { readFileSync } from "node:fs";

View File

@@ -1,9 +1,8 @@
/** 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
* CLI harness: replays a directory of extracted VoD frames through the full
* detector registry driven by a DetectorScheduler, mirroring the worker's
* chunk scan — 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.

View File

@@ -1,11 +1,8 @@
/** 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
* Accuracy report across all fixtures, separate from pass/fail testing:
* 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 {

View File

@@ -1,18 +1,15 @@
/** 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).
* CLI equivalent of the VoD tab: scans a video with the full detector registry
* and writes the same events CSV the tab's Export menu downloads. ffmpeg
* decodes to raw RGBA frames piped through the DetectorScheduler + detectors
* and a TimelineBuilder with the tab's default options, so the CSV matches a
* browser scan (minus keyframe skimming, which only affects speed).
*
* 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).
* --telemetry prints the VoD tab's ?telemetry=true scan counters after the run.
*/
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";

View File

@@ -1,20 +1,16 @@
/** 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.
* Audits death/special detection against scoreboard truth, from an events CSV
* downloaded from the scanner UI. Rows are parsed back into DetectedEvents,
* run through the real match builder and rendered into the timeline's status
* spans; each player's span counts are diffed against the scoreboard's D/S
* numbers. 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.
* cast-footage side orientation and slot→row assignment can degrade to their
* fallbacks; the output flags mismatches that look like slot-mapping artifacts.
* Every discrepancy lists the read timestamps most likely to yield a fixture.
*
* Usage: pnpm scanner:status-audit <events.csv> [--all]
*/
@@ -182,9 +178,6 @@ for (const [index, builtMatch] of built.entries()) {
printCandidates();
printGuidance();
// ---------------------------------------------------------------------------
// CSV → DetectedEvent reconstruction
function parseCsv(text: string): string[][] {
const result: string[][] = [];
let row: string[] = [];
@@ -451,9 +444,6 @@ function parseScoreboardRow(row: string[]): ScoreboardData & {
};
}
// ---------------------------------------------------------------------------
// Analysis
interface FixtureCandidate {
score: number;
t: number;
@@ -471,10 +461,9 @@ interface StatusSpan extends TimeWindow {
/** 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)
* widest the true state could 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
*/
maxPossibleSeconds: number;
}
@@ -587,9 +576,6 @@ function readsInWindow(
.map((event) => Math.round(event.t * 100) / 100);
}
// ---------------------------------------------------------------------------
// Output
function ts(t: number): string {
return `t=${Math.round(t * 10) / 10} (${formatTime(t)})`;
}

View File

@@ -1,9 +1,8 @@
/**
* 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.
* vite-node config for the scanner scripts. The root vite.config.ts
* pre-bundles @techstark/opencv-js for the browser worker, and vite-node would
* resolve that prebundle (which crashes on __dirname in Node); without the
* include it externalizes the dep to a plain require of the patched CJS bundle.
*/
import { defineConfig } from "vite";

View File

@@ -5,12 +5,10 @@ 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
// dynamic so the large seed import graph is not transformed when already seeded
const { seed } = await import("~/db/seed");
await seed();
logger.info("Database seeded successfully");
@@ -23,7 +21,6 @@ async function main() {
}
}
// Seed images to Minio
logger.info("🖼️ Seeding images to Minio...");
try {
await seedImages();

View File

@@ -1,12 +1,7 @@
/**
* Hand-maintained types for the [Leanny/splat3](https://github.com/Leanny/splat3) game data dump.
*
* The dump itself is not checked in (see `.gitignore`), so these types are declared here rather
* than inferred from the JSON files. That keeps `pnpm run typecheck:scripts` passing whether or
* not the dump is currently sitting in `scripts/dicts/splat3`.
*
* Only the fields the scripts actually read are declared for the deeply nested parameter dumps.
* When a monthly update changes the dump's shape, update these types alongside the scripts.
* Hand-maintained types for the [Leanny/splat3](https://github.com/Leanny/splat3) game data dump,
* declared rather than inferred so `pnpm run typecheck:scripts` passes without the gitignored dump in
* `scripts/dicts/splat3`. Only the fields the scripts read are declared; update alongside the scripts.
*/
/** `data/language/<Lang>.json` — message category -> message key -> localized (URI encoded) text. */

View File

@@ -10,7 +10,6 @@ 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

View File

@@ -16,7 +16,6 @@ async function main() {
let deleted = 0;
let updated = 0;
// update existing
for (const existingBadge of await homemadeBadgesInDb()) {
const badge = homemadeBadges[existingBadge.code];
@@ -51,7 +50,6 @@ async function main() {
let added = 0;
// add new
for (const [fileName, badge] of Object.entries(homemadeBadges)) {
const existing = homemadeAfterUpdates.find(
(existingBadge) => fileName === existingBadge.code,