fix(catalog): resolve classname candidates using public_name
CI / check (push) Failing after 3m17s
CI / deploy (push) Skipped

This commit is contained in:
openhands committed 2026-09-07 19:15:35 +02:00
1 parent 8c47c3c158
commit ca6aa97eb2
5 files changed
+305 -211

No files matched your search

+4 -2
View File
@@ -193,13 +193,15 @@ describe.skipIf(!runLive)("catalog audit live (read-only)", () => {
if (types.NO_ASSET_KINDS.has(kind)) continue; if (types.NO_ASSET_KINDS.has(kind)) continue;
if ( if (
!types !types
.assetNameCandidates(r.item_name) .assetNameCandidates(r.item_name, r.public_name)
.nitro.some((n) => nitroSet.has(n)) .nitro.some((n) => nitroSet.has(n))
) { ) {
missingNitro++; missingNitro++;
} }
if ( if (
!types.assetNameCandidates(r.item_name).icon.some((n) => iconSet.has(n)) !types
.assetNameCandidates(r.item_name, r.public_name)
.icon.some((n) => iconSet.has(n))
) { ) {
missingIcon++; missingIcon++;
} }
+26 -18
View File
@@ -320,9 +320,10 @@ export async function runCatalogAudit(
itemId: item.id, itemId: item.id,
message: `"${item.item_name}" (ID ${item.id}) has no catalog_items entry — not purchasable`, message: `"${item.item_name}" (ID ${item.id}) has no catalog_items entry — not purchasable`,
}); });
const hasNitro = assetNameCandidates(item.item_name).nitro.some((n) => const hasNitro = assetNameCandidates(
nitroSet.has(n), item.item_name,
); item.public_name,
).nitro.some((n) => nitroSet.has(n));
// Pets ship their assets through the pet pipeline, not as furni // Pets ship their assets through the pet pipeline, not as furni
// .nitro bundles, so they can get a catalog entry regardless. // .nitro bundles, so they can get a catalog entry regardless.
if (kind === "pet" || hasNitro) { if (kind === "pet" || hasNitro) {
@@ -398,9 +399,10 @@ export async function runCatalogAudit(
// Badges, pets, effects, bots and sticky notes never ship a furni // Badges, pets, effects, bots and sticky notes never ship a furni
// .nitro bundle — skip the asset check for them. // .nitro bundle — skip the asset check for them.
if (NO_ASSET_KINDS.has(kindOf(item.item_name))) continue; if (NO_ASSET_KINDS.has(kindOf(item.item_name))) continue;
const hasNitro = assetNameCandidates(item.item_name).nitro.some((n) => const hasNitro = assetNameCandidates(
nitroSet.has(n), item.item_name,
); item.public_name,
).nitro.some((n) => nitroSet.has(n));
if (!hasNitro) { if (!hasNitro) {
missingNitroClassnames.push(item.item_name); missingNitroClassnames.push(item.item_name);
issues.push({ issues.push({
@@ -429,9 +431,10 @@ export async function runCatalogAudit(
// Badges use album1584 .gif icons; pets, effects, bots and sticky notes // Badges use album1584 .gif icons; pets, effects, bots and sticky notes
// never ship a furni icon file — skip the asset check for them. // never ship a furni icon file — skip the asset check for them.
if (NO_ASSET_KINDS.has(kindOf(item.item_name))) continue; if (NO_ASSET_KINDS.has(kindOf(item.item_name))) continue;
const hasIcon = assetNameCandidates(item.item_name).icon.some((n) => const hasIcon = assetNameCandidates(
iconSet.has(n), item.item_name,
); item.public_name,
).icon.some((n) => iconSet.has(n));
if (!hasIcon) { if (!hasIcon) {
missingIconClassnames.push(item.item_name); missingIconClassnames.push(item.item_name);
issues.push({ issues.push({
@@ -493,9 +496,11 @@ export async function runCatalogAudit(
// left over cannot be restored from any source → legacy/broken. // left over cannot be restored from any source → legacy/broken.
const stillMissing: string[] = []; const stillMissing: string[] = [];
for (const classname of missingIconClassnames) { for (const classname of missingIconClassnames) {
const hasIcon = assetNameCandidates(classname).icon.some((n) => const item = itemByName.get(classname);
iconSet.has(n), const hasIcon = assetNameCandidates(
); classname,
item?.public_name,
).icon.some((n) => iconSet.has(n));
if (!hasIcon) { if (!hasIcon) {
stillMissing.push(classname); stillMissing.push(classname);
} }
@@ -566,9 +571,11 @@ export async function runCatalogAudit(
// left over cannot be restored from any source → legacy/broken. // left over cannot be restored from any source → legacy/broken.
const stillMissing: string[] = []; const stillMissing: string[] = [];
for (const classname of missingNitroClassnames) { for (const classname of missingNitroClassnames) {
const hasNitro = assetNameCandidates(classname).nitro.some((n) => const item = itemByName.get(classname);
nitroSet.has(n), const hasNitro = assetNameCandidates(
); classname,
item?.public_name,
).nitro.some((n) => nitroSet.has(n));
if (!hasNitro) { if (!hasNitro) {
stillMissing.push(classname); stillMissing.push(classname);
} }
@@ -714,9 +721,10 @@ export async function runCatalogAudit(
} }
if (NO_CATALOG_KINDS.has(kind)) continue; if (NO_CATALOG_KINDS.has(kind)) continue;
if (postCatalogIds.has(item.id)) continue; if (postCatalogIds.has(item.id)) continue;
const hasNitro = assetNameCandidates(item.item_name).nitro.some((n) => const hasNitro = assetNameCandidates(
nitroSet.has(n), item.item_name,
); item.public_name,
).nitro.some((n) => nitroSet.has(n));
if (kind === "pet" || hasNitro) { if (kind === "pet" || hasNitro) {
missingCatalogEntries.push({ missingCatalogEntries.push({
classname: item.item_name, classname: item.item_name,
+26 -7
View File
@@ -77,8 +77,15 @@ export const NO_CATALOG_KINDS: ReadonlySet<CatalogItemKind> = new Set([
* files. Returns `.nitro` and icon candidates (most specific first): * files. Returns `.nitro` and icon candidates (most specific first):
* the exact classname, the star-variant base, the star→underscore name and the * the exact classname, the star-variant base, the star→underscore name and the
* dot→underscore name (`post.it` → `post_it.nitro`). * dot→underscore name (`post.it` → `post_it.nitro`).
*
* When `publicName` is provided it is treated as an alternative classname (many
* hotels store the real classname in `items_base.public_name` while `item_name`
* holds a display name). Candidates from both names are merged and deduplicated.
*/ */
export function assetNameCandidates(classname: string): { export function assetNameCandidates(
classname: string,
publicName?: string,
): {
nitro: string[]; nitro: string[];
icon: string[]; icon: string[];
} { } {
@@ -97,12 +104,24 @@ export function assetNameCandidates(classname: string): {
const starUnderscore = classname.replace(/\*/g, "_"); const starUnderscore = classname.replace(/\*/g, "_");
const dotUnderscore = classname.replace(/\./g, "_"); const dotUnderscore = classname.replace(/\./g, "_");
const bases = [classname, starBase, dotUnderscore, starUnderscore];
// Also try public_name when it looks like a real classname (no spaces,
// not empty, not "0" which some hotels use as a "no value" marker).
if (
publicName &&
publicName !== classname &&
!publicName.includes(" ") &&
publicName !== "0"
) {
const pnBase = publicName.split("*")[0];
const pnStar = publicName.replace(/\*/g, "_");
const pnDot = publicName.replace(/\./g, "_");
bases.push(publicName, pnBase, pnDot, pnStar);
}
return { return {
nitro: unique([classname, starBase, dotUnderscore, starUnderscore]).map( nitro: unique(bases).map((n) => `${n}.nitro`),
(n) => `${n}.nitro`, icon: unique(bases).map((n) => `${n}_icon.png`),
),
icon: unique([classname, starUnderscore, dotUnderscore, starBase]).map(
(n) => `${n}_icon.png`,
),
}; };
} }
+160 -120
View File
@@ -169,16 +169,18 @@ export async function repairMissingIcons(
let items: Array<{ let items: Array<{
id: number; id: number;
item_name: string; item_name: string;
public_name: string;
type: string; type: string;
interaction_type: string; interaction_type: string;
}>; }>;
try { try {
const [rows] = (await db.execute(sql` const [rows] = (await db.execute(sql`
SELECT id, item_name, type, interaction_type FROM items_base ORDER BY id SELECT id, item_name, public_name, type, interaction_type FROM items_base ORDER BY id
`)) as unknown as [ `)) as unknown as [
Array<{ Array<{
id: number; id: number;
item_name: string; item_name: string;
public_name: string;
type: string; type: string;
interaction_type: string; interaction_type: string;
}>, }>,
@@ -207,18 +209,40 @@ export async function repairMissingIcons(
const total = items.length; const total = items.length;
onEvent?.({ type: "started", total }); onEvent?.({ type: "started", total });
const processItem = async (classname: string) => { // A row can have its real classname in either `item_name` or `public_name`
// (some hotels swap them or store a display name). Build the candidate
// classnames to try in order, most specific first.
const classnameCandidates = (item: (typeof items)[number]): string[] => {
const out: string[] = [];
const add = (n?: string | null) => {
if (n && !out.includes(n)) out.push(n);
};
add(item.item_name);
if (
item.public_name &&
item.public_name !== item.item_name &&
!item.public_name.includes(" ") &&
item.public_name !== "0"
) {
add(item.public_name);
}
return out;
};
const processItem = async (item: (typeof items)[number]) => {
const classname = item.item_name;
// Special items (badges, pets, effects, bots, sticky notes) don't have // Special items (badges, pets, effects, bots, sticky notes) don't have
// .png icon files to repair. Skip them. // .png icon files to repair. Skip them.
if (dbSkipSet.has(classname)) { if (dbSkipSet.has(classname)) {
return { status: "skipped" } as const; return { status: "skipped" } as const;
} }
const fileName = `${classname}_icon.png`; const names = classnameCandidates(item);
const fileNameFor = (n: string) => `${n}_icon.png`;
const fileNames = names.map(fileNameFor);
// Badge items store icons as badge_<code>_icon.png (the classname already // A file is present if ANY candidate name exists in every target.
// includes the `badge_` prefix, so fileName matches correctly).
const presentDirs = iconDirs.filter((dir) => const presentDirs = iconDirs.filter((dir) =>
existsSync(getRuntimePath(dir, fileName)), fileNames.some((f) => existsSync(getRuntimePath(dir, f))),
); );
const missingDirs = iconDirs.filter((dir) => !presentDirs.includes(dir)); const missingDirs = iconDirs.filter((dir) => !presentDirs.includes(dir));
@@ -230,51 +254,57 @@ export async function repairMissingIcons(
const warnings: string[] = []; const warnings: string[] = [];
// 1) Copy across mirrors — the icon already exists in one target (e.g. // 1) Copy across mirrors — the icon already exists in one target (e.g.
// gamedata), so just fill the ones that are missing it. // gamedata), so just fill the ones that are missing it. Copy all matched
// files so whichever candidate forms exist are mirrored everywhere.
if (presentDirs.length > 0) { if (presentDirs.length > 0) {
try { for (const f of fileNames) {
const copyErrors = await copyIconAcrossDirs( const src = iconDirs.find((d) => existsSync(getRuntimePath(d, f)));
presentDirs[0], if (!src) continue;
missingDirs, const dst = missingDirs.filter((d) => d !== src);
fileName, if (dst.length === 0) continue;
); try {
warnings.push(...copyErrors); const copyErrors = await copyIconAcrossDirs(src, dst, f);
ok = copyErrors.length === 0; warnings.push(...copyErrors);
} catch (err) { } catch (err) {
warnings.push( warnings.push(
`copy across icon dirs failed: ${(err as Error).message}`, `copy across icon dirs failed: ${(err as Error).message}`,
); );
}
} }
} }
// 2) Extract from a local .nitro bundle (checked across every target). // 2) Extract from a local .nitro bundle (checked across every target).
if (!ok) { if (!ok) {
let foundNitro = false; for (const n of names) {
for (const dir of nitroDirs) { let foundNitro = false;
const nitroPath = getRuntimePath(dir, `${classname}.nitro`); for (const dir of nitroDirs) {
if (!existsSync(nitroPath)) continue; const nitroPath = getRuntimePath(dir, `${n}.nitro`);
foundNitro = true; if (!existsSync(nitroPath)) continue;
try { foundNitro = true;
const nitroBuf = await fs.readFile(nitroPath); try {
const icon = extractFurniIconPng(nitroBuf); const nitroBuf = await fs.readFile(nitroPath);
if (icon) { const icon = extractFurniIconPng(nitroBuf);
const writeErrors = await writeIconToDirs( if (icon) {
icon, const writeErrors = await writeIconToDirs(
missingDirs, icon,
fileName, missingDirs,
fileNameFor(n),
);
warnings.push(...writeErrors);
ok = writeErrors.length === 0;
break;
}
warnings.push("no icon asset in .nitro bundle");
} catch (err) {
warnings.push(
`extract from .nitro failed: ${(err as Error).message}`,
); );
warnings.push(...writeErrors);
ok = writeErrors.length === 0;
break;
} }
warnings.push("no icon asset in .nitro bundle"); if (ok) break;
} catch (err) {
warnings.push(
`extract from .nitro failed: ${(err as Error).message}`,
);
} }
if (ok) break;
if (!foundNitro) warnings.push(".nitro file not found on disk");
} }
if (!foundNitro) warnings.push(".nitro file not found on disk");
} }
// 3) Download from configured or default public sources. // 3) Download from configured or default public sources.
@@ -289,102 +319,105 @@ export async function repairMissingIcons(
} }
if (sources.length === 0) sources = DEFAULT_ICON_SOURCES; if (sources.length === 0) sources = DEFAULT_ICON_SOURCES;
for (const source of sources) { for (const n of names) {
const iconCandidates = [ const iconCandidates = [
`${classname}_icon.png`, fileNameFor(n),
// Variant classnames (e.g. `table_silo_med*10`) often only exist ...(n.includes("*")
// on sources under their base name (e.g. `table_silo_med_icon.png`) ? [fileNameFor(n.split("*")[0]), fileNameFor(n.replace(/\*/g, "_"))]
// or with the `*` replaced by `_` (e.g. `table_silo_med_10_icon.png`).
...(classname.includes("*")
? [
`${classname.split("*")[0]}_icon.png`,
`${classname.replace(/\*/g, "_")}_icon.png`,
]
: []),
// Classnames with dots (`post.it`) are stored on disk with
// underscores (`post_it_icon.png`).
...(classname.includes(".")
? [`${classname.replace(/\./g, "_")}_icon.png`]
: []), : []),
...(n.includes(".") ? [fileNameFor(n.replace(/\./g, "_"))] : []),
]; ];
for (const iconName of iconCandidates) { for (const source of sources) {
try { for (const iconName of iconCandidates) {
const dl = await downloadFile( try {
`${source.iconBaseUrl}/${iconName}`, const dl = await downloadFile(
/*turbopackIgnore: true*/ getRuntimePath( templateUrl(
targets.iconDir, source.iconBaseUrl,
fileName, n,
), iconName.replace(/_icon\.png$/, ""),
{ maxRetries: 1, validate: "png" }, ),
);
if (dl.ok) {
const icon = await fs.readFile(
/*turbopackIgnore: true*/ getRuntimePath( /*turbopackIgnore: true*/ getRuntimePath(
targets.iconDir, targets.iconDir,
fileName, fileNameFor(n),
), ),
{ maxRetries: 1, validate: "png" },
); );
const writeErrors = await writeIconToDirs( if (dl.ok) {
icon, const icon = await fs.readFile(
missingDirs, /*turbopackIgnore: true*/ getRuntimePath(
fileName, targets.iconDir,
); fileNameFor(n),
warnings.push(...writeErrors); ),
ok = writeErrors.length === 0; );
break;
}
warnings.push(`${iconName} not found on ${source.name}`);
} catch (err) {
warnings.push(
`download from ${source.name} failed: ${(err as Error).message}`,
);
}
}
if (ok) break;
// Some sources only ship icons embedded in their `.nitro` bundles —
// grab the bundle and extract the catalog icon from it.
if (source.nitroBaseUrl) {
const nitroTmp = getRuntimePath(
os.tmpdir(),
`.repair_${classname}.nitro`,
);
try {
const dl = await downloadFile(
`${source.nitroBaseUrl}/${classname}.nitro`,
nitroTmp,
{ maxRetries: 1 },
);
if (dl.ok) {
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
if (icon) {
const writeErrors = await writeIconToDirs( const writeErrors = await writeIconToDirs(
icon, icon,
missingDirs, missingDirs,
fileName, fileNameFor(n),
); );
warnings.push(...writeErrors); warnings.push(...writeErrors);
ok = writeErrors.length === 0; ok = writeErrors.length === 0;
} else { break;
warnings.push(`no icon asset in ${source.name} .nitro bundle`);
} }
} else { warnings.push(`${iconName} not found on ${source.name}`);
warnings.push(`.nitro not found on ${source.name}`); } catch (err) {
} warnings.push(
} catch (err) { `download from ${source.name} failed: ${(err as Error).message}`,
warnings.push(
`extract from ${source.name} .nitro failed: ${(err as Error).message}`,
);
} finally {
await fs
.unlink(nitroTmp)
.catch((error) =>
logServerError("repair_icons.cleanup_failed", error),
); );
}
} }
if (ok) break; if (ok) break;
// Some sources only ship icons embedded in their `.nitro` bundles —
// grab the bundle and extract the catalog icon from it.
if (source.nitroBaseUrl) {
const nitroTmp = getRuntimePath(
os.tmpdir(),
`.repair_${classname}.nitro`,
);
let wroteTmp = false;
try {
const dl = await downloadFile(
templateUrl(source.nitroBaseUrl, n, n),
nitroTmp,
{ maxRetries: 1 },
);
wroteTmp = dl.ok;
if (dl.ok) {
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
if (icon) {
const writeErrors = await writeIconToDirs(
icon,
missingDirs,
fileNameFor(n),
);
warnings.push(...writeErrors);
ok = writeErrors.length === 0;
} else {
warnings.push(
`no icon asset in ${source.name} .nitro bundle`,
);
}
} else {
warnings.push(`.nitro not found on ${source.name}`);
}
} catch (err) {
warnings.push(
`extract from ${source.name} .nitro failed: ${(err as Error).message}`,
);
} finally {
if (wroteTmp) {
await fs
.unlink(nitroTmp)
.catch((error) =>
logServerError("repair_icons.cleanup_failed", error),
);
}
}
if (ok) break;
}
} }
if (ok) break;
} }
} }
@@ -399,7 +432,7 @@ export async function repairMissingIcons(
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => { await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
const classname = item.item_name; const classname = item.item_name;
const result = await processItem(classname); const result = await processItem(item);
if (result.status === "skipped") { if (result.status === "skipped") {
skipped++; skipped++;
onEvent?.({ onEvent?.({
@@ -433,7 +466,6 @@ export async function repairMissingIcons(
message: result.message, message: result.message,
}); });
}); });
onEvent?.({ onEvent?.({
type: "batch_complete", type: "batch_complete",
succeeded, succeeded,
@@ -443,3 +475,11 @@ export async function repairMissingIcons(
return { succeeded, failed, skipped }; return { succeeded, failed, skipped };
} }
/** Expand %libname% / %param% templates used by some retro asset mirrors. */
function templateUrl(base: string, libname: string, param: string): string {
if (!base) return "";
return base
.replace(/%libname%/g, encodeURIComponent(libname))
.replace(/%param%/g, encodeURIComponent(param));
}
+89 -64
View File
@@ -162,16 +162,18 @@ export async function repairMissingNitros(
let items: Array<{ let items: Array<{
id: number; id: number;
item_name: string; item_name: string;
public_name: string;
type: string; type: string;
interaction_type: string; interaction_type: string;
}>; }>;
try { try {
const [rows] = (await db.execute(sql` const [rows] = (await db.execute(sql`
SELECT id, item_name, type, interaction_type FROM items_base ORDER BY id SELECT id, item_name, public_name, type, interaction_type FROM items_base ORDER BY id
`)) as unknown as [ `)) as unknown as [
Array<{ Array<{
id: number; id: number;
item_name: string; item_name: string;
public_name: string;
type: string; type: string;
interaction_type: string; interaction_type: string;
}>, }>,
@@ -200,16 +202,37 @@ export async function repairMissingNitros(
const total = items.length; const total = items.length;
onEvent?.({ type: "started", total }); onEvent?.({ type: "started", total });
const processItem = async (classname: string) => { // A row can have its real classname in either `item_name` or `public_name`.
const classnameCandidates = (item: (typeof items)[number]): string[] => {
const out: string[] = [];
const add = (n?: string | null) => {
if (n && !out.includes(n)) out.push(n);
};
add(item.item_name);
if (
item.public_name &&
item.public_name !== item.item_name &&
!item.public_name.includes(" ") &&
item.public_name !== "0"
) {
add(item.public_name);
}
return out;
};
const processItem = async (item: (typeof items)[number]) => {
const classname = item.item_name;
// Special items (badges, pets, effects, bots, sticky notes) don't have // Special items (badges, pets, effects, bots, sticky notes) don't have
// furni .nitro bundles to repair. Skip them. // furni .nitro bundles to repair. Skip them.
if (skipSet.has(classname)) { if (skipSet.has(classname)) {
return { status: "skipped" } as const; return { status: "skipped" } as const;
} }
const fileName = `${classname}.nitro`; const names = classnameCandidates(item);
const fileNameFor = (n: string) => `${n}.nitro`;
const fileNames = names.map(fileNameFor);
const presentDirs = nitroDirs.filter((dir) => const presentDirs = nitroDirs.filter((dir) =>
existsSync(getRuntimePath(dir, fileName)), fileNames.some((f) => existsSync(getRuntimePath(dir, f))),
); );
const missingDirs = nitroDirs.filter((dir) => !presentDirs.includes(dir)); const missingDirs = nitroDirs.filter((dir) => !presentDirs.includes(dir));
@@ -223,18 +246,19 @@ export async function repairMissingNitros(
// 1) Copy across mirrors — the .nitro already exists in one target (e.g. // 1) Copy across mirrors — the .nitro already exists in one target (e.g.
// gamedata), so just fill the ones that are missing it. // gamedata), so just fill the ones that are missing it.
if (presentDirs.length > 0) { if (presentDirs.length > 0) {
try { for (const f of fileNames) {
const copyErrors = await copyNitroAcrossDirs( const src = nitroDirs.find((d) => existsSync(getRuntimePath(d, f)));
presentDirs[0], if (!src) continue;
missingDirs, const dst = missingDirs.filter((d) => d !== src);
fileName, if (dst.length === 0) continue;
); try {
warnings.push(...copyErrors); const copyErrors = await copyNitroAcrossDirs(src, dst, f);
ok = copyErrors.length === 0; warnings.push(...copyErrors);
} catch (err) { } catch (err) {
warnings.push( warnings.push(
`copy across .nitro dirs failed: ${(err as Error).message}`, `copy across .nitro dirs failed: ${(err as Error).message}`,
); );
}
} }
} }
@@ -248,58 +272,51 @@ export async function repairMissingNitros(
} }
if (sources.length === 0) sources = DEFAULT_NITRO_SOURCES; if (sources.length === 0) sources = DEFAULT_NITRO_SOURCES;
const nitroCandidates = [ for (const n of names) {
`${classname}.nitro`, const nitroCandidates = [
// Variant classnames (e.g. `table_silo_med*10`) often only exist fileNameFor(n),
// on sources under their base name (e.g. `table_silo_med.nitro`) ...(n.includes("*")
// or with the `*` replaced by `_` (e.g. `table_silo_med_10.nitro`). ? [fileNameFor(n.split("*")[0]), fileNameFor(n.replace(/\*/g, "_"))]
...(classname.includes("*") : []),
? [ ...(n.includes(".") ? [fileNameFor(n.replace(/\./g, "_"))] : []),
`${classname.split("*")[0]}.nitro`, ];
`${classname.replace(/\*/g, "_")}.nitro`,
]
: []),
// Classnames with dots (`post.it`) are stored on disk with
// underscores (`post_it.nitro`).
...(classname.includes(".")
? [`${classname.replace(/\./g, "_")}.nitro`]
: []),
];
for (const source of sources) { for (const source of sources) {
for (const nitroName of nitroCandidates) { for (const nitroName of nitroCandidates) {
const tmpPath = getRuntimePath( const tmpPath = getRuntimePath(
os.tmpdir(), os.tmpdir(),
`.repair_nitro_${classname}_${Math.random().toString(36).slice(2, 8)}.nitro`, `.repair_nitro_${classname}_${Math.random().toString(36).slice(2, 8)}.nitro`,
);
try {
const dl = await downloadFile(
`${source.nitroBaseUrl}/${nitroName}`,
tmpPath,
{ maxRetries: 1 },
); );
if (dl.ok) { try {
// Validate it is a real Nitro bundle before writing it const dl = await downloadFile(
// anywhere (mirrors clone-import.ts). templateUrl(source.nitroBaseUrl, n, n),
const buffer = await fs.readFile(tmpPath); tmpPath,
parseNitroBundle(buffer); { maxRetries: 1 },
const writeErrors = await writeNitroToDirs(
buffer,
missingDirs,
fileName,
); );
warnings.push(...writeErrors); if (dl.ok) {
ok = writeErrors.length === 0; // Validate it is a real Nitro bundle before writing it
break; // anywhere (mirrors clone-import.ts).
const buffer = await fs.readFile(tmpPath);
parseNitroBundle(buffer);
const writeErrors = await writeNitroToDirs(
buffer,
missingDirs,
fileNameFor(n),
);
warnings.push(...writeErrors);
ok = writeErrors.length === 0;
break;
}
warnings.push(`${nitroName} not found on ${source.name}`);
} catch (err) {
warnings.push(
`download from ${source.name} failed: ${(err as Error).message}`,
);
} finally {
await fs.unlink(tmpPath).catch(() => {});
} }
warnings.push(`${nitroName} not found on ${source.name}`);
} catch (err) {
warnings.push(
`download from ${source.name} failed: ${(err as Error).message}`,
);
} finally {
await fs.unlink(tmpPath).catch(() => {});
} }
if (ok) break;
} }
if (ok) break; if (ok) break;
} }
@@ -316,7 +333,7 @@ export async function repairMissingNitros(
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => { await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
const classname = item.item_name; const classname = item.item_name;
const result = await processItem(classname); const result = await processItem(item);
if (result.status === "skipped") { if (result.status === "skipped") {
skipped++; skipped++;
onEvent?.({ onEvent?.({
@@ -360,6 +377,14 @@ export async function repairMissingNitros(
return { succeeded, failed, skipped }; return { succeeded, failed, skipped };
} }
/** Expand %libname% / %param% templates used by some retro asset mirrors. */
function templateUrl(base: string, libname: string, param: string): string {
if (!base) return "";
return base
.replace(/%libname%/g, encodeURIComponent(libname))
.replace(/%param%/g, encodeURIComponent(param));
}
/** /**
* Re-convert every furniture `.nitro` we produced from a local SWF so the * Re-convert every furniture `.nitro` we produced from a local SWF so the
* bundles include size 32 (and every other visualization size the converter * bundles include size 32 (and every other visualization size the converter