fix: precise item classification in catalog audit and repair

Detect badges by items_base.type='b' (authoritative, album gifs as
fallback), recognize pet/animals (a0 pet<N>, pet<N> interaction) and
system items (effects, bots, sticky notes), and resolve asset names for
dot/star classname variants so the audit no longer floods with false
missing nitro/icon errors and repair skips non-furni items.
This commit is contained in:
openhands committed 2026-08-04 11:07:04 +02:00
1 parent b1a1e5125c
commit d587749b50
5 files changed
+445 -36

No files matched your search

+63 -16
View File
@@ -17,6 +17,14 @@ import {
repairOrphanedCatalog,
} from "./catalog-repair";
import { ensureDirectories } from "./furni-import";
import {
assetNameCandidates,
type CatalogItemKind,
classifyCatalogItem,
isBadgeType,
NO_ASSET_KINDS,
NO_CATALOG_KINDS,
} from "./item-kind";
import { repairMissingIcons } from "./repair-icons";
import { repairMissingNitros } from "./repair-nitros";
@@ -161,7 +169,10 @@ export async function runCatalogAudit(
]);
const iconSet = new Set(iconFiles);
const nitroSet = new Set(nitroFiles);
const badgeSet = new Set(
// Badge detection: `items_base.type = 'b'` is authoritative. The album1584
// `.gif` listing is kept as a fallback for hotels that store badges with a
// non-standard type.
const badgeGifSet = new Set(
badgeGifFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
);
@@ -172,16 +183,19 @@ export async function runCatalogAudit(
item_name: string;
public_name: string;
type: string;
interaction_type: string;
}>;
try {
const [rows] = (await db.execute(sql`
SELECT id, item_name, public_name, 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;
}>,
unknown,
];
@@ -194,6 +208,21 @@ export async function runCatalogAudit(
return;
}
// item_name → catalog item kind (badge / pet / effect / bot / sticky / furni).
const kindByName = new Map<string, CatalogItemKind>();
for (const item of items) {
let kind = classifyCatalogItem(item);
if (
kind === "furni" &&
(isBadgeType(item.type) || badgeGifSet.has(item.item_name))
) {
kind = "badge";
}
kindByName.set(item.item_name, kind);
}
const kindOf = (name: string): CatalogItemKind =>
kindByName.get(name) ?? "furni";
onEvent?.({ type: "items_loaded", total: items.length });
onEvent?.({ type: "progress", message: "Loading catalog_items…" });
@@ -250,6 +279,10 @@ export async function runCatalogAudit(
const missingCatalogEntries: MissingCatalogEntry[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
const kind = kindOf(item.item_name);
// Badges and system items (effects, bots, sticky notes) are awarded or
// system-managed, not catalog-purchasable — don't flag or auto-add them.
if (NO_CATALOG_KINDS.has(kind)) continue;
if (!itemIdsWithCatalog.has(item.id)) {
issues.push({
type: "missing_catalog",
@@ -258,11 +291,12 @@ export async function runCatalogAudit(
itemId: item.id,
message: `"${item.item_name}" (ID ${item.id}) has no catalog_items entry — not purchasable`,
});
// Badge items use .gif files, not .nitro bundles.
if (
!badgeSet.has(item.item_name) &&
nitroSet.has(`${item.item_name}.nitro`)
) {
const hasNitro = assetNameCandidates(item.item_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) {
missingCatalogEntries.push({
classname: item.item_name,
itemId: item.id,
@@ -331,9 +365,13 @@ export async function runCatalogAudit(
const missingNitroClassnames: string[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
// Badge items use .gif files, not .nitro bundles.
if (badgeSet.has(item.item_name)) continue;
if (!nitroSet.has(`${item.item_name}.nitro`)) {
// 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),
);
if (!hasNitro) {
missingNitroClassnames.push(item.item_name);
issues.push({
type: "missing_nitro",
@@ -358,10 +396,13 @@ export async function runCatalogAudit(
const missingIconClassnames: string[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
// Badge items use .gif icons (stored in album1584), not .png icon files.
if (badgeSet.has(item.item_name)) continue;
const iconFileName = `${item.item_name}_icon.png`;
if (!iconSet.has(iconFileName)) {
// 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),
);
if (!hasIcon) {
missingIconClassnames.push(item.item_name);
issues.push({
type: "missing_icon",
@@ -412,7 +453,10 @@ export async function runCatalogAudit(
// and drop the ones the repair fixed from the issue list.
const stillMissing: string[] = [];
for (const classname of missingIconClassnames) {
if (!iconSet.has(`${classname}_icon.png`)) {
const hasIcon = assetNameCandidates(classname).icon.some((n) =>
iconSet.has(n),
);
if (!hasIcon) {
stillMissing.push(classname);
}
}
@@ -467,7 +511,10 @@ export async function runCatalogAudit(
// and drop the ones the repair fixed from the issue list.
const stillMissing: string[] = [];
for (const classname of missingNitroClassnames) {
if (!nitroSet.has(`${classname}.nitro`)) {
const hasNitro = assetNameCandidates(classname).nitro.some((n) =>
nitroSet.has(n),
);
if (!hasNitro) {
stillMissing.push(classname);
}
}