From d587749b50ef82952b9c0701ea14a47e6f64e310 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 4 Aug 2026 11:07:04 +0200 Subject: [PATCH] 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, pet 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. --- src/lib/services/catalog-audit.ts | 79 +++++++++--- src/lib/services/item-kind.test.ts | 188 +++++++++++++++++++++++++++++ src/lib/services/item-kind.ts | 108 +++++++++++++++++ src/lib/services/repair-icons.ts | 53 ++++++-- src/lib/services/repair-nitros.ts | 53 ++++++-- 5 files changed, 445 insertions(+), 36 deletions(-) create mode 100644 src/lib/services/item-kind.test.ts create mode 100644 src/lib/services/item-kind.ts diff --git a/src/lib/services/catalog-audit.ts b/src/lib/services/catalog-audit.ts index 69a55f4dae..dab6536abd 100644 --- a/src/lib/services/catalog-audit.ts +++ b/src/lib/services/catalog-audit.ts @@ -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(); + 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); } } diff --git a/src/lib/services/item-kind.test.ts b/src/lib/services/item-kind.test.ts new file mode 100644 index 0000000000..91bc2cddc5 --- /dev/null +++ b/src/lib/services/item-kind.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { + assetNameCandidates, + classifyCatalogItem, + isBadgeType, + isPetClassname, + isPetInteraction, + NO_ASSET_KINDS, + NO_CATALOG_KINDS, +} from "./item-kind"; + +describe("isBadgeType", () => { + it("returns true for badge type b (any case)", () => { + expect(isBadgeType("b")).toBe(true); + expect(isBadgeType("B")).toBe(true); + expect(isBadgeType(" b ")).toBe(true); + }); + + it("returns false for furni types", () => { + expect(isBadgeType("s")).toBe(false); + expect(isBadgeType("i")).toBe(false); + expect(isBadgeType("")).toBe(false); + }); +}); + +describe("isPetClassname / isPetInteraction", () => { + it("recognises a0 pet classnames", () => { + expect(isPetClassname("a0 pet0")).toBe(true); + expect(isPetClassname("a0 pet80")).toBe(true); + expect(isPetClassname(" a0 pet13 ")).toBe(true); + expect(isPetClassname("a0_pet0")).toBe(false); + expect(isPetClassname("bunny")).toBe(false); + }); + + it("recognises pet interaction types", () => { + expect(isPetInteraction("pet0")).toBe(true); + expect(isPetInteraction(" pet13 ")).toBe(true); + expect(isPetInteraction("pet0 ")).toBe(true); + expect(isPetInteraction("postit")).toBe(false); + expect(isPetInteraction("default")).toBe(false); + expect(isPetInteraction(null)).toBe(false); + }); +}); + +describe("classifyCatalogItem", () => { + it("classifies badges by type b", () => { + expect( + classifyCatalogItem({ + type: "b", + item_name: "YZI68", + interaction_type: "default", + }), + ).toBe("badge"); + expect( + classifyCatalogItem({ + type: "b", + item_name: "horse", + interaction_type: "default", + }), + ).toBe("badge"); + }); + + it("classifies pets by classname and interaction type", () => { + expect( + classifyCatalogItem({ + type: "s", + item_name: "a0 pet0", + interaction_type: "pet0 ", + }), + ).toBe("pet"); + expect( + classifyCatalogItem({ + type: "s", + item_name: "a0 pet37", + interaction_type: "pet13", + }), + ).toBe("pet"); + }); + + it("classifies effects, bots and sticky notes", () => { + expect( + classifyCatalogItem({ + type: "s", + item_name: "avatar_effect158", + interaction_type: "default", + }), + ).toBe("effect"); + expect( + classifyCatalogItem({ + type: "s", + item_name: "bot_generic", + interaction_type: "default", + }), + ).toBe("bot"); + expect( + classifyCatalogItem({ + type: "s", + item_name: "rentable_bot_visitor_log", + interaction_type: "default", + }), + ).toBe("bot"); + expect( + classifyCatalogItem({ + type: "s", + item_name: "post.it", + interaction_type: "postit", + }), + ).toBe("sticky"); + }); + + it("does not mistake furni classnames that share prefixes", () => { + expect( + classifyCatalogItem({ + type: "s", + item_name: "botanical_garden", + interaction_type: "default", + }), + ).toBe("furni"); + // `effect_faketile` is a real purchasable furni item. + expect( + classifyCatalogItem({ + type: "s", + item_name: "effect_faketile", + interaction_type: "default", + }), + ).toBe("furni"); + }); + + it("classifies normal furniture", () => { + expect( + classifyCatalogItem({ + type: "s", + item_name: "table_silo_med", + interaction_type: "default", + }), + ).toBe("furni"); + expect( + classifyCatalogItem({ + type: "i", + item_name: "wall_poster", + interaction_type: "default", + }), + ).toBe("furni"); + }); +}); + +describe("kind sets", () => { + it("badges and pets never expect furni assets", () => { + expect(NO_ASSET_KINDS.has("badge")).toBe(true); + expect(NO_ASSET_KINDS.has("pet")).toBe(true); + expect(NO_ASSET_KINDS.has("effect")).toBe(true); + expect(NO_ASSET_KINDS.has("bot")).toBe(true); + expect(NO_ASSET_KINDS.has("sticky")).toBe(true); + expect(NO_ASSET_KINDS.has("furni")).toBe(false); + }); + + it("pets are catalog-purchasable, badges and system items are not", () => { + expect(NO_CATALOG_KINDS.has("badge")).toBe(true); + expect(NO_CATALOG_KINDS.has("effect")).toBe(true); + expect(NO_CATALOG_KINDS.has("bot")).toBe(true); + expect(NO_CATALOG_KINDS.has("sticky")).toBe(true); + expect(NO_CATALOG_KINDS.has("pet")).toBe(false); + expect(NO_CATALOG_KINDS.has("furni")).toBe(false); + }); +}); + +describe("assetNameCandidates", () => { + it("returns the exact name first for plain classnames", () => { + expect(assetNameCandidates("table_silo_med")).toEqual({ + nitro: ["table_silo_med.nitro"], + icon: ["table_silo_med_icon.png"], + }); + }); + + it("adds the base classname for star variants", () => { + expect(assetNameCandidates("table_silo_med*10").nitro).toContain( + "table_silo_med.nitro", + ); + expect(assetNameCandidates("table_silo_med*10").icon).toContain( + "table_silo_med_10_icon.png", + ); + }); + + it("adds the underscore name for dotted classnames", () => { + expect(assetNameCandidates("post.it").nitro).toContain("post_it.nitro"); + expect(assetNameCandidates("post.it").icon).toContain("post_it_icon.png"); + }); +}); diff --git a/src/lib/services/item-kind.ts b/src/lib/services/item-kind.ts new file mode 100644 index 0000000000..51dbadab20 --- /dev/null +++ b/src/lib/services/item-kind.ts @@ -0,0 +1,108 @@ +/** + * Classification of `items_base` rows so the catalog audit and repairs know + * exactly which items are expected to ship normal furni assets (`.nitro` + * bundles + `_icon.png` files) and which ones are special: + * + * - **badge** — stored with type `b`. Badges use album1584 `.gif` files and + * never ship furni `.nitro`/`_icon.png` assets. They are awarded, not + * purchased through the catalog. + * - **pet** — pet purchase items (`a0 pet`, interaction `pet`). Pet + * assets live in the pet asset pipeline (`public/nitro-assets/bundled/pet`), + * not the furniture bundle dirs. + * - **effect / bot / sticky** — system items with no furniture visuals at all. + * - **furni** — everything else; must ship a `.nitro` and `_icon.png`. + */ + +export type CatalogItemKind = + | "badge" + | "pet" + | "effect" + | "bot" + | "sticky" + | "furni"; + +export interface CatalogItemLike { + type: string; + item_name: string; + interaction_type?: string | null; +} + +/** Badges are stored in `items_base` with type `b`. */ +export function isBadgeType(type: string): boolean { + return type.trim().toLowerCase() === "b"; +} + +/** Pet purchase items use classnames like `a0 pet0` … `a0 pet80`. */ +export function isPetClassname(itemName: string): boolean { + return /^a0\s*pet\d+$/i.test(itemName.trim()); +} + +/** Pet purchase items use `pet` as their interaction type. */ +export function isPetInteraction(interactionType?: string | null): boolean { + return /^pet\d+$/i.test((interactionType ?? "").trim()); +} + +/** Classify an `items_base` row into a catalog item kind. */ +export function classifyCatalogItem(item: CatalogItemLike): CatalogItemKind { + const name = item.item_name.trim(); + if (isBadgeType(item.type)) return "badge"; + if (isPetClassname(name) || isPetInteraction(item.interaction_type)) { + return "pet"; + } + if (/^avatar_effect/i.test(name)) return "effect"; + if (/^(bot_|rentable_bot_)/i.test(name)) return "bot"; + if (/^post[._]?it/i.test(name)) return "sticky"; + return "furni"; +} + +/** Kinds that never ship furni `.nitro` / `_icon.png` assets. */ +export const NO_ASSET_KINDS: ReadonlySet = new Set([ + "badge", + "pet", + "effect", + "bot", + "sticky", +]); + +/** Kinds that are not purchasable furniture and should not get catalog entries. */ +export const NO_CATALOG_KINDS: ReadonlySet = new Set([ + "badge", + "effect", + "bot", + "sticky", +]); + +/** + * Asset file names for a classname, mirroring how `importSingleFurni` writes + * 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`). + */ +export function assetNameCandidates(classname: string): { + nitro: string[]; + icon: string[]; +} { + const unique = (names: string[]): string[] => { + const seen = new Set(); + const out: string[] = []; + for (const n of names) { + if (!n || seen.has(n)) continue; + seen.add(n); + out.push(n); + } + return out; + }; + + const starBase = classname.split("*")[0]; + const starUnderscore = classname.replace(/\*/g, "_"); + const dotUnderscore = classname.replace(/\./g, "_"); + + return { + nitro: unique([classname, starBase, dotUnderscore, starUnderscore]).map( + (n) => `${n}.nitro`, + ), + icon: unique([classname, starUnderscore, dotUnderscore, starBase]).map( + (n) => `${n}_icon.png`, + ), + }; +} diff --git a/src/lib/services/repair-icons.ts b/src/lib/services/repair-icons.ts index 65b25149e5..25ef227b44 100644 --- a/src/lib/services/repair-icons.ts +++ b/src/lib/services/repair-icons.ts @@ -15,6 +15,7 @@ import { getGamedataRoot, } from "@/lib/services/furni-asset-dirs"; import { downloadFile } from "@/lib/services/import/core/download"; +import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind"; import { ensureDirectories } from "./furni-import"; const REPAIR_CONCURRENCY = 6; @@ -141,28 +142,44 @@ export async function repairMissingIcons( ...targets.mirrorDirs.map((d) => d.nitroDir), ]); - // Load known badge codes from the album directory so we can skip badge - // items — they use .gif files, not .png icons. - let badgeSet: Set | null = null; + // Badges and other special items (pets, effects, bots, sticky notes) never + // ship furni `_icon.png` files — resolve the skip set from the DB so badge + // detection is precise (`items_base.type = 'b'`) instead of relying only on + // which .gif files happen to exist in the album directory. + let skipSet: Set | null = null; try { const gamedataRoot = await getGamedataRoot(); if (gamedataRoot) { const badgeFiles = await fs .readdir(path.join(gamedataRoot, "album1584")) .catch(() => [] as string[]); - badgeSet = new Set( + const albumBadgeSet = new Set( badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)), ); + skipSet = albumBadgeSet; } } catch { - badgeSet = null; + skipSet = null; } - let items: Array<{ id: number; item_name: string }>; + let items: Array<{ + id: number; + item_name: string; + type: string; + interaction_type: string; + }>; try { const [rows] = (await db.execute(sql` - SELECT id, item_name FROM items_base ORDER BY id - `)) as unknown as [Array<{ id: number; item_name: string }>, unknown]; + SELECT id, item_name, type, interaction_type FROM items_base ORDER BY id + `)) as unknown as [ + Array<{ + id: number; + item_name: string; + type: string; + interaction_type: string; + }>, + unknown, + ]; items = rows; } catch (err) { onEvent?.({ @@ -172,13 +189,24 @@ export async function repairMissingIcons( return { succeeded: 0, failed: 0, skipped: 0 }; } + // Build the authoritative skip set from the DB, supplemented by album .gifs. + const dbSkipSet = new Set(); + for (const item of items) { + if (NO_ASSET_KINDS.has(classifyCatalogItem(item))) { + dbSkipSet.add(item.item_name); + } + } + for (const code of skipSet ?? []) { + dbSkipSet.add(code); + } + const total = items.length; onEvent?.({ type: "started", total }); const processItem = async (classname: string) => { - // Badge items that only exist as .gif files (in album1584) don't have + // Special items (badges, pets, effects, bots, sticky notes) don't have // .png icon files to repair. Skip them. - if (badgeSet?.has(classname)) { + if (dbSkipSet.has(classname)) { return { status: "skipped" } as const; } const fileName = `${classname}_icon.png`; @@ -269,6 +297,11 @@ export async function repairMissingIcons( `${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`] + : []), ]; for (const iconName of iconCandidates) { diff --git a/src/lib/services/repair-nitros.ts b/src/lib/services/repair-nitros.ts index df9bed6594..da1d25a455 100644 --- a/src/lib/services/repair-nitros.ts +++ b/src/lib/services/repair-nitros.ts @@ -14,6 +14,7 @@ import { getGamedataRoot, } from "@/lib/services/furni-asset-dirs"; import { downloadFile } from "@/lib/services/import/core/download"; +import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind"; import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; import { ensureDirectories } from "./furni-import"; @@ -137,28 +138,43 @@ export async function repairMissingNitros( ...targets.mirrorDirs.map((d) => d.nitroDir), ]); - // Load known badge codes from the album directory so we can skip badge - // items — they use .gif files, not .nitro bundles. - let badgeSet: Set | null = null; + // Badges and other special items (pets, effects, bots, sticky notes) never + // ship furni `.nitro` bundles — resolve the skip set from the DB so badge + // detection is precise (`items_base.type = 'b'`) instead of relying only on + // which .gif files happen to exist in the album directory. + let albumBadgeSet: Set | null = null; try { const gamedataRoot = await getGamedataRoot(); if (gamedataRoot) { const badgeFiles = await fs .readdir(path.join(gamedataRoot, "album1584")) .catch(() => [] as string[]); - badgeSet = new Set( + albumBadgeSet = new Set( badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)), ); } } catch { - badgeSet = null; + albumBadgeSet = null; } - let items: Array<{ id: number; item_name: string }>; + let items: Array<{ + id: number; + item_name: string; + type: string; + interaction_type: string; + }>; try { const [rows] = (await db.execute(sql` - SELECT id, item_name FROM items_base ORDER BY id - `)) as unknown as [Array<{ id: number; item_name: string }>, unknown]; + SELECT id, item_name, type, interaction_type FROM items_base ORDER BY id + `)) as unknown as [ + Array<{ + id: number; + item_name: string; + type: string; + interaction_type: string; + }>, + unknown, + ]; items = rows; } catch (err) { onEvent?.({ @@ -168,12 +184,24 @@ export async function repairMissingNitros( return { succeeded: 0, failed: 0, skipped: 0 }; } + // Build the authoritative skip set from the DB, supplemented by album .gifs. + const skipSet = new Set(); + for (const item of items) { + if (NO_ASSET_KINDS.has(classifyCatalogItem(item))) { + skipSet.add(item.item_name); + } + } + for (const code of albumBadgeSet ?? []) { + skipSet.add(code); + } + const total = items.length; onEvent?.({ type: "started", total }); const processItem = async (classname: string) => { - // Badge items use .gif files, not .nitro bundles. - if (badgeSet?.has(classname) ?? false) { + // 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`; @@ -229,6 +257,11 @@ export async function repairMissingNitros( `${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) {