From ca6aa97eb230f9097b865d941f91005a4ff33264 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 7 Sep 2026 19:15:35 +0200 Subject: [PATCH] fix(catalog): resolve classname candidates using public_name --- src/lib/services/catalog-audit-live.test.ts | 6 +- src/lib/services/catalog-audit.ts | 44 +-- src/lib/services/item-kind.ts | 33 ++- src/lib/services/repair-icons.ts | 280 +++++++++++--------- src/lib/services/repair-nitros.ts | 153 ++++++----- 5 files changed, 305 insertions(+), 211 deletions(-) diff --git a/src/lib/services/catalog-audit-live.test.ts b/src/lib/services/catalog-audit-live.test.ts index 1d6a81a93f..a723e27b6e 100644 --- a/src/lib/services/catalog-audit-live.test.ts +++ b/src/lib/services/catalog-audit-live.test.ts @@ -193,13 +193,15 @@ describe.skipIf(!runLive)("catalog audit live (read-only)", () => { if (types.NO_ASSET_KINDS.has(kind)) continue; if ( !types - .assetNameCandidates(r.item_name) + .assetNameCandidates(r.item_name, r.public_name) .nitro.some((n) => nitroSet.has(n)) ) { missingNitro++; } 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++; } diff --git a/src/lib/services/catalog-audit.ts b/src/lib/services/catalog-audit.ts index cb4aae9c78..1b880fd13b 100644 --- a/src/lib/services/catalog-audit.ts +++ b/src/lib/services/catalog-audit.ts @@ -320,9 +320,10 @@ export async function runCatalogAudit( itemId: item.id, message: `"${item.item_name}" (ID ${item.id}) has no catalog_items entry — not purchasable`, }); - const hasNitro = assetNameCandidates(item.item_name).nitro.some((n) => - nitroSet.has(n), - ); + const hasNitro = assetNameCandidates( + item.item_name, + item.public_name, + ).nitro.some((n) => nitroSet.has(n)); // Pets ship their assets through the pet pipeline, not as furni // .nitro bundles, so they can get a catalog entry regardless. if (kind === "pet" || hasNitro) { @@ -398,9 +399,10 @@ export async function runCatalogAudit( // Badges, pets, effects, bots and sticky notes never ship a furni // .nitro bundle — skip the asset check for them. if (NO_ASSET_KINDS.has(kindOf(item.item_name))) continue; - const hasNitro = assetNameCandidates(item.item_name).nitro.some((n) => - nitroSet.has(n), - ); + const hasNitro = assetNameCandidates( + item.item_name, + item.public_name, + ).nitro.some((n) => nitroSet.has(n)); if (!hasNitro) { missingNitroClassnames.push(item.item_name); issues.push({ @@ -429,9 +431,10 @@ export async function runCatalogAudit( // Badges use album1584 .gif icons; pets, effects, bots and sticky notes // never ship a furni icon file — skip the asset check for them. if (NO_ASSET_KINDS.has(kindOf(item.item_name))) continue; - const hasIcon = assetNameCandidates(item.item_name).icon.some((n) => - iconSet.has(n), - ); + const hasIcon = assetNameCandidates( + item.item_name, + item.public_name, + ).icon.some((n) => iconSet.has(n)); if (!hasIcon) { missingIconClassnames.push(item.item_name); issues.push({ @@ -493,9 +496,11 @@ export async function runCatalogAudit( // left over cannot be restored from any source → legacy/broken. const stillMissing: string[] = []; for (const classname of missingIconClassnames) { - const hasIcon = assetNameCandidates(classname).icon.some((n) => - iconSet.has(n), - ); + const item = itemByName.get(classname); + const hasIcon = assetNameCandidates( + classname, + item?.public_name, + ).icon.some((n) => iconSet.has(n)); if (!hasIcon) { stillMissing.push(classname); } @@ -566,9 +571,11 @@ export async function runCatalogAudit( // left over cannot be restored from any source → legacy/broken. const stillMissing: string[] = []; for (const classname of missingNitroClassnames) { - const hasNitro = assetNameCandidates(classname).nitro.some((n) => - nitroSet.has(n), - ); + const item = itemByName.get(classname); + const hasNitro = assetNameCandidates( + classname, + item?.public_name, + ).nitro.some((n) => nitroSet.has(n)); if (!hasNitro) { stillMissing.push(classname); } @@ -714,9 +721,10 @@ export async function runCatalogAudit( } if (NO_CATALOG_KINDS.has(kind)) continue; if (postCatalogIds.has(item.id)) continue; - const hasNitro = assetNameCandidates(item.item_name).nitro.some((n) => - nitroSet.has(n), - ); + const hasNitro = assetNameCandidates( + item.item_name, + item.public_name, + ).nitro.some((n) => nitroSet.has(n)); if (kind === "pet" || hasNitro) { missingCatalogEntries.push({ classname: item.item_name, diff --git a/src/lib/services/item-kind.ts b/src/lib/services/item-kind.ts index 51dbadab20..f76e4dc5a9 100644 --- a/src/lib/services/item-kind.ts +++ b/src/lib/services/item-kind.ts @@ -77,8 +77,15 @@ export const NO_CATALOG_KINDS: ReadonlySet = new Set([ * files. Returns `.nitro` and icon candidates (most specific first): * the exact classname, the star-variant base, the star→underscore name and the * 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[]; icon: string[]; } { @@ -97,12 +104,24 @@ export function assetNameCandidates(classname: string): { const starUnderscore = 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 { - nitro: unique([classname, starBase, dotUnderscore, starUnderscore]).map( - (n) => `${n}.nitro`, - ), - icon: unique([classname, starUnderscore, dotUnderscore, starBase]).map( - (n) => `${n}_icon.png`, - ), + nitro: unique(bases).map((n) => `${n}.nitro`), + icon: unique(bases).map((n) => `${n}_icon.png`), }; } diff --git a/src/lib/services/repair-icons.ts b/src/lib/services/repair-icons.ts index 0a7c6cda90..ae393848b8 100644 --- a/src/lib/services/repair-icons.ts +++ b/src/lib/services/repair-icons.ts @@ -169,16 +169,18 @@ export async function repairMissingIcons( let items: Array<{ id: number; item_name: string; + public_name: string; type: string; interaction_type: string; }>; try { 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 [ Array<{ id: number; item_name: string; + public_name: string; type: string; interaction_type: string; }>, @@ -207,18 +209,40 @@ export async function repairMissingIcons( const total = items.length; 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 // .png icon files to repair. Skip them. if (dbSkipSet.has(classname)) { 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__icon.png (the classname already - // includes the `badge_` prefix, so fileName matches correctly). + // A file is present if ANY candidate name exists in every target. const presentDirs = iconDirs.filter((dir) => - existsSync(getRuntimePath(dir, fileName)), + fileNames.some((f) => existsSync(getRuntimePath(dir, f))), ); const missingDirs = iconDirs.filter((dir) => !presentDirs.includes(dir)); @@ -230,51 +254,57 @@ export async function repairMissingIcons( const warnings: string[] = []; // 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) { - try { - const copyErrors = await copyIconAcrossDirs( - presentDirs[0], - missingDirs, - fileName, - ); - warnings.push(...copyErrors); - ok = copyErrors.length === 0; - } catch (err) { - warnings.push( - `copy across icon dirs failed: ${(err as Error).message}`, - ); + for (const f of fileNames) { + const src = iconDirs.find((d) => existsSync(getRuntimePath(d, f))); + if (!src) continue; + const dst = missingDirs.filter((d) => d !== src); + if (dst.length === 0) continue; + try { + const copyErrors = await copyIconAcrossDirs(src, dst, f); + warnings.push(...copyErrors); + } catch (err) { + warnings.push( + `copy across icon dirs failed: ${(err as Error).message}`, + ); + } } } // 2) Extract from a local .nitro bundle (checked across every target). if (!ok) { - let foundNitro = false; - for (const dir of nitroDirs) { - const nitroPath = getRuntimePath(dir, `${classname}.nitro`); - if (!existsSync(nitroPath)) continue; - foundNitro = true; - try { - const nitroBuf = await fs.readFile(nitroPath); - const icon = extractFurniIconPng(nitroBuf); - if (icon) { - const writeErrors = await writeIconToDirs( - icon, - missingDirs, - fileName, + for (const n of names) { + let foundNitro = false; + for (const dir of nitroDirs) { + const nitroPath = getRuntimePath(dir, `${n}.nitro`); + if (!existsSync(nitroPath)) continue; + foundNitro = true; + try { + const nitroBuf = await fs.readFile(nitroPath); + const icon = extractFurniIconPng(nitroBuf); + if (icon) { + const writeErrors = await writeIconToDirs( + icon, + 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"); - } catch (err) { - warnings.push( - `extract from .nitro failed: ${(err as Error).message}`, - ); + if (ok) break; } + 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. @@ -289,102 +319,105 @@ export async function repairMissingIcons( } if (sources.length === 0) sources = DEFAULT_ICON_SOURCES; - for (const source of sources) { + for (const n of names) { const iconCandidates = [ - `${classname}_icon.png`, - // Variant classnames (e.g. `table_silo_med*10`) often only exist - // on sources under their base name (e.g. `table_silo_med_icon.png`) - // 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`] + fileNameFor(n), + ...(n.includes("*") + ? [fileNameFor(n.split("*")[0]), fileNameFor(n.replace(/\*/g, "_"))] : []), + ...(n.includes(".") ? [fileNameFor(n.replace(/\./g, "_"))] : []), ]; - for (const iconName of iconCandidates) { - try { - const dl = await downloadFile( - `${source.iconBaseUrl}/${iconName}`, - /*turbopackIgnore: true*/ getRuntimePath( - targets.iconDir, - fileName, - ), - { maxRetries: 1, validate: "png" }, - ); - if (dl.ok) { - const icon = await fs.readFile( + for (const source of sources) { + for (const iconName of iconCandidates) { + try { + const dl = await downloadFile( + templateUrl( + source.iconBaseUrl, + n, + iconName.replace(/_icon\.png$/, ""), + ), /*turbopackIgnore: true*/ getRuntimePath( targets.iconDir, - fileName, + fileNameFor(n), ), + { maxRetries: 1, validate: "png" }, ); - const writeErrors = await writeIconToDirs( - icon, - missingDirs, - fileName, - ); - 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) { + if (dl.ok) { + const icon = await fs.readFile( + /*turbopackIgnore: true*/ getRuntimePath( + targets.iconDir, + fileNameFor(n), + ), + ); const writeErrors = await writeIconToDirs( icon, missingDirs, - fileName, + fileNameFor(n), ); warnings.push(...writeErrors); ok = writeErrors.length === 0; - } else { - warnings.push(`no icon asset in ${source.name} .nitro bundle`); + break; } - } else { - warnings.push(`.nitro not found on ${source.name}`); - } - } catch (err) { - 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), + 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`, + ); + 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) => { const classname = item.item_name; - const result = await processItem(classname); + const result = await processItem(item); if (result.status === "skipped") { skipped++; onEvent?.({ @@ -433,7 +466,6 @@ export async function repairMissingIcons( message: result.message, }); }); - onEvent?.({ type: "batch_complete", succeeded, @@ -443,3 +475,11 @@ export async function repairMissingIcons( 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)); +} diff --git a/src/lib/services/repair-nitros.ts b/src/lib/services/repair-nitros.ts index 08909e1dfc..b9050a5918 100644 --- a/src/lib/services/repair-nitros.ts +++ b/src/lib/services/repair-nitros.ts @@ -162,16 +162,18 @@ export async function repairMissingNitros( let items: Array<{ id: number; item_name: string; + public_name: string; type: string; interaction_type: string; }>; try { 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 [ Array<{ id: number; item_name: string; + public_name: string; type: string; interaction_type: string; }>, @@ -200,16 +202,37 @@ export async function repairMissingNitros( const total = items.length; 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 // furni .nitro bundles to repair. Skip them. if (skipSet.has(classname)) { 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) => - existsSync(getRuntimePath(dir, fileName)), + fileNames.some((f) => existsSync(getRuntimePath(dir, f))), ); 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. // gamedata), so just fill the ones that are missing it. if (presentDirs.length > 0) { - try { - const copyErrors = await copyNitroAcrossDirs( - presentDirs[0], - missingDirs, - fileName, - ); - warnings.push(...copyErrors); - ok = copyErrors.length === 0; - } catch (err) { - warnings.push( - `copy across .nitro dirs failed: ${(err as Error).message}`, - ); + for (const f of fileNames) { + const src = nitroDirs.find((d) => existsSync(getRuntimePath(d, f))); + if (!src) continue; + const dst = missingDirs.filter((d) => d !== src); + if (dst.length === 0) continue; + try { + const copyErrors = await copyNitroAcrossDirs(src, dst, f); + warnings.push(...copyErrors); + } catch (err) { + warnings.push( + `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; - const nitroCandidates = [ - `${classname}.nitro`, - // Variant classnames (e.g. `table_silo_med*10`) often only exist - // on sources under their base name (e.g. `table_silo_med.nitro`) - // or with the `*` replaced by `_` (e.g. `table_silo_med_10.nitro`). - ...(classname.includes("*") - ? [ - `${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 n of names) { + const nitroCandidates = [ + fileNameFor(n), + ...(n.includes("*") + ? [fileNameFor(n.split("*")[0]), fileNameFor(n.replace(/\*/g, "_"))] + : []), + ...(n.includes(".") ? [fileNameFor(n.replace(/\./g, "_"))] : []), + ]; - for (const source of sources) { - for (const nitroName of nitroCandidates) { - const tmpPath = getRuntimePath( - os.tmpdir(), - `.repair_nitro_${classname}_${Math.random().toString(36).slice(2, 8)}.nitro`, - ); - try { - const dl = await downloadFile( - `${source.nitroBaseUrl}/${nitroName}`, - tmpPath, - { maxRetries: 1 }, + for (const source of sources) { + for (const nitroName of nitroCandidates) { + const tmpPath = getRuntimePath( + os.tmpdir(), + `.repair_nitro_${classname}_${Math.random().toString(36).slice(2, 8)}.nitro`, ); - if (dl.ok) { - // Validate it is a real Nitro bundle before writing it - // anywhere (mirrors clone-import.ts). - const buffer = await fs.readFile(tmpPath); - parseNitroBundle(buffer); - const writeErrors = await writeNitroToDirs( - buffer, - missingDirs, - fileName, + try { + const dl = await downloadFile( + templateUrl(source.nitroBaseUrl, n, n), + tmpPath, + { maxRetries: 1 }, ); - warnings.push(...writeErrors); - ok = writeErrors.length === 0; - break; + if (dl.ok) { + // Validate it is a real Nitro bundle before writing it + // 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; } @@ -316,7 +333,7 @@ export async function repairMissingNitros( await runPool(items, REPAIR_CONCURRENCY, async (item, i) => { const classname = item.item_name; - const result = await processItem(classname); + const result = await processItem(item); if (result.status === "skipped") { skipped++; onEvent?.({ @@ -360,6 +377,14 @@ export async function repairMissingNitros( 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 * bundles include size 32 (and every other visualization size the converter