/** * 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`). * * 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, publicName?: 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, "_"); 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(bases).map((n) => `${n}.nitro`), icon: unique(bases).map((n) => `${n}_icon.png`), }; }