fix: precise item classification in catalog audit and repair
CI / check (push) Successful in 23s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m3s

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

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+43 -10
View File
@@ -15,6 +15,7 @@ import {
getGamedataRoot, getGamedataRoot,
} from "@/lib/services/furni-asset-dirs"; } from "@/lib/services/furni-asset-dirs";
import { downloadFile } from "@/lib/services/import/core/download"; import { downloadFile } from "@/lib/services/import/core/download";
import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind";
import { ensureDirectories } from "./furni-import"; import { ensureDirectories } from "./furni-import";
const REPAIR_CONCURRENCY = 6; const REPAIR_CONCURRENCY = 6;
@@ -141,28 +142,44 @@ export async function repairMissingIcons(
...targets.mirrorDirs.map((d) => d.nitroDir), ...targets.mirrorDirs.map((d) => d.nitroDir),
]); ]);
// Load known badge codes from the album directory so we can skip badge // Badges and other special items (pets, effects, bots, sticky notes) never
// items — they use .gif files, not .png icons. // ship furni `_icon.png` files — resolve the skip set from the DB so badge
let badgeSet: Set<string> | null = null; // 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<string> | null = null;
try { try {
const gamedataRoot = await getGamedataRoot(); const gamedataRoot = await getGamedataRoot();
if (gamedataRoot) { if (gamedataRoot) {
const badgeFiles = await fs const badgeFiles = await fs
.readdir(path.join(gamedataRoot, "album1584")) .readdir(path.join(gamedataRoot, "album1584"))
.catch(() => [] as string[]); .catch(() => [] as string[]);
badgeSet = new Set( const albumBadgeSet = new Set(
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)), badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
); );
skipSet = albumBadgeSet;
} }
} catch { } 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 { try {
const [rows] = (await db.execute(sql` const [rows] = (await db.execute(sql`
SELECT id, item_name FROM items_base ORDER BY id SELECT id, item_name, type, interaction_type FROM items_base ORDER BY id
`)) as unknown as [Array<{ id: number; item_name: string }>, unknown]; `)) as unknown as [
Array<{
id: number;
item_name: string;
type: string;
interaction_type: string;
}>,
unknown,
];
items = rows; items = rows;
} catch (err) { } catch (err) {
onEvent?.({ onEvent?.({
@@ -172,13 +189,24 @@ export async function repairMissingIcons(
return { succeeded: 0, failed: 0, skipped: 0 }; return { succeeded: 0, failed: 0, skipped: 0 };
} }
// Build the authoritative skip set from the DB, supplemented by album .gifs.
const dbSkipSet = new Set<string>();
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; const total = items.length;
onEvent?.({ type: "started", total }); onEvent?.({ type: "started", total });
const processItem = async (classname: string) => { 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. // .png icon files to repair. Skip them.
if (badgeSet?.has(classname)) { if (dbSkipSet.has(classname)) {
return { status: "skipped" } as const; return { status: "skipped" } as const;
} }
const fileName = `${classname}_icon.png`; const fileName = `${classname}_icon.png`;
@@ -269,6 +297,11 @@ export async function repairMissingIcons(
`${classname.replace(/\*/g, "_")}_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`]
: []),
]; ];
for (const iconName of iconCandidates) { for (const iconName of iconCandidates) {
+43 -10
View File
@@ -14,6 +14,7 @@ import {
getGamedataRoot, getGamedataRoot,
} from "@/lib/services/furni-asset-dirs"; } from "@/lib/services/furni-asset-dirs";
import { downloadFile } from "@/lib/services/import/core/download"; 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 { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
import { ensureDirectories } from "./furni-import"; import { ensureDirectories } from "./furni-import";
@@ -137,28 +138,43 @@ export async function repairMissingNitros(
...targets.mirrorDirs.map((d) => d.nitroDir), ...targets.mirrorDirs.map((d) => d.nitroDir),
]); ]);
// Load known badge codes from the album directory so we can skip badge // Badges and other special items (pets, effects, bots, sticky notes) never
// items — they use .gif files, not .nitro bundles. // ship furni `.nitro` bundles — resolve the skip set from the DB so badge
let badgeSet: Set<string> | null = null; // 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<string> | null = null;
try { try {
const gamedataRoot = await getGamedataRoot(); const gamedataRoot = await getGamedataRoot();
if (gamedataRoot) { if (gamedataRoot) {
const badgeFiles = await fs const badgeFiles = await fs
.readdir(path.join(gamedataRoot, "album1584")) .readdir(path.join(gamedataRoot, "album1584"))
.catch(() => [] as string[]); .catch(() => [] as string[]);
badgeSet = new Set( albumBadgeSet = new Set(
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)), badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
); );
} }
} catch { } 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 { try {
const [rows] = (await db.execute(sql` const [rows] = (await db.execute(sql`
SELECT id, item_name FROM items_base ORDER BY id SELECT id, item_name, type, interaction_type FROM items_base ORDER BY id
`)) as unknown as [Array<{ id: number; item_name: string }>, unknown]; `)) as unknown as [
Array<{
id: number;
item_name: string;
type: string;
interaction_type: string;
}>,
unknown,
];
items = rows; items = rows;
} catch (err) { } catch (err) {
onEvent?.({ onEvent?.({
@@ -168,12 +184,24 @@ export async function repairMissingNitros(
return { succeeded: 0, failed: 0, skipped: 0 }; return { succeeded: 0, failed: 0, skipped: 0 };
} }
// Build the authoritative skip set from the DB, supplemented by album .gifs.
const skipSet = new Set<string>();
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; const total = items.length;
onEvent?.({ type: "started", total }); onEvent?.({ type: "started", total });
const processItem = async (classname: string) => { const processItem = async (classname: string) => {
// Badge items use .gif files, not .nitro bundles. // Special items (badges, pets, effects, bots, sticky notes) don't have
if (badgeSet?.has(classname) ?? false) { // furni .nitro bundles to repair. Skip them.
if (skipSet.has(classname)) {
return { status: "skipped" } as const; return { status: "skipped" } as const;
} }
const fileName = `${classname}.nitro`; const fileName = `${classname}.nitro`;
@@ -229,6 +257,11 @@ export async function repairMissingNitros(
`${classname.replace(/\*/g, "_")}.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) {