fix(catalog): resolve classname candidates using public_name
This commit is contained in:
1 parent
8c47c3c158
commit
ca6aa97eb2
5 files changed
+305
-211
No files matched your search
@@ -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++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user