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:
1 parent
b1a1e5125c
commit
d587749b50
5 files changed
+445
-36
No files matched your search
@@ -17,6 +17,14 @@ import {
|
|||||||
repairOrphanedCatalog,
|
repairOrphanedCatalog,
|
||||||
} from "./catalog-repair";
|
} from "./catalog-repair";
|
||||||
import { ensureDirectories } from "./furni-import";
|
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 { repairMissingIcons } from "./repair-icons";
|
||||||
import { repairMissingNitros } from "./repair-nitros";
|
import { repairMissingNitros } from "./repair-nitros";
|
||||||
|
|
||||||
@@ -161,7 +169,10 @@ export async function runCatalogAudit(
|
|||||||
]);
|
]);
|
||||||
const iconSet = new Set(iconFiles);
|
const iconSet = new Set(iconFiles);
|
||||||
const nitroSet = new Set(nitroFiles);
|
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)),
|
badgeGifFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -172,16 +183,19 @@ export async function runCatalogAudit(
|
|||||||
item_name: string;
|
item_name: string;
|
||||||
public_name: string;
|
public_name: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
interaction_type: string;
|
||||||
}>;
|
}>;
|
||||||
try {
|
try {
|
||||||
const [rows] = (await db.execute(sql`
|
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 [
|
`)) as unknown as [
|
||||||
Array<{
|
Array<{
|
||||||
id: number;
|
id: number;
|
||||||
item_name: string;
|
item_name: string;
|
||||||
public_name: string;
|
public_name: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
interaction_type: string;
|
||||||
}>,
|
}>,
|
||||||
unknown,
|
unknown,
|
||||||
];
|
];
|
||||||
@@ -194,6 +208,21 @@ export async function runCatalogAudit(
|
|||||||
return;
|
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: "items_loaded", total: items.length });
|
||||||
|
|
||||||
onEvent?.({ type: "progress", message: "Loading catalog_items…" });
|
onEvent?.({ type: "progress", message: "Loading catalog_items…" });
|
||||||
@@ -250,6 +279,10 @@ export async function runCatalogAudit(
|
|||||||
const missingCatalogEntries: MissingCatalogEntry[] = [];
|
const missingCatalogEntries: MissingCatalogEntry[] = [];
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const item = items[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)) {
|
if (!itemIdsWithCatalog.has(item.id)) {
|
||||||
issues.push({
|
issues.push({
|
||||||
type: "missing_catalog",
|
type: "missing_catalog",
|
||||||
@@ -258,11 +291,12 @@ 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`,
|
||||||
});
|
});
|
||||||
// Badge items use .gif files, not .nitro bundles.
|
const hasNitro = assetNameCandidates(item.item_name).nitro.some((n) =>
|
||||||
if (
|
nitroSet.has(n),
|
||||||
!badgeSet.has(item.item_name) &&
|
);
|
||||||
nitroSet.has(`${item.item_name}.nitro`)
|
// 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({
|
missingCatalogEntries.push({
|
||||||
classname: item.item_name,
|
classname: item.item_name,
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
@@ -331,9 +365,13 @@ export async function runCatalogAudit(
|
|||||||
const missingNitroClassnames: string[] = [];
|
const missingNitroClassnames: string[] = [];
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
// Badge items use .gif files, not .nitro bundles.
|
// Badges, pets, effects, bots and sticky notes never ship a furni
|
||||||
if (badgeSet.has(item.item_name)) continue;
|
// .nitro bundle — skip the asset check for them.
|
||||||
if (!nitroSet.has(`${item.item_name}.nitro`)) {
|
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);
|
missingNitroClassnames.push(item.item_name);
|
||||||
issues.push({
|
issues.push({
|
||||||
type: "missing_nitro",
|
type: "missing_nitro",
|
||||||
@@ -358,10 +396,13 @@ export async function runCatalogAudit(
|
|||||||
const missingIconClassnames: string[] = [];
|
const missingIconClassnames: string[] = [];
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
// Badge items use .gif icons (stored in album1584), not .png icon files.
|
// Badges use album1584 .gif icons; pets, effects, bots and sticky notes
|
||||||
if (badgeSet.has(item.item_name)) continue;
|
// never ship a furni icon file — skip the asset check for them.
|
||||||
const iconFileName = `${item.item_name}_icon.png`;
|
if (NO_ASSET_KINDS.has(kindOf(item.item_name))) continue;
|
||||||
if (!iconSet.has(iconFileName)) {
|
const hasIcon = assetNameCandidates(item.item_name).icon.some((n) =>
|
||||||
|
iconSet.has(n),
|
||||||
|
);
|
||||||
|
if (!hasIcon) {
|
||||||
missingIconClassnames.push(item.item_name);
|
missingIconClassnames.push(item.item_name);
|
||||||
issues.push({
|
issues.push({
|
||||||
type: "missing_icon",
|
type: "missing_icon",
|
||||||
@@ -412,7 +453,10 @@ export async function runCatalogAudit(
|
|||||||
// and drop the ones the repair fixed from the issue list.
|
// and drop the ones the repair fixed from the issue list.
|
||||||
const stillMissing: string[] = [];
|
const stillMissing: string[] = [];
|
||||||
for (const classname of missingIconClassnames) {
|
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);
|
stillMissing.push(classname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,7 +511,10 @@ export async function runCatalogAudit(
|
|||||||
// and drop the ones the repair fixed from the issue list.
|
// and drop the ones the repair fixed from the issue list.
|
||||||
const stillMissing: string[] = [];
|
const stillMissing: string[] = [];
|
||||||
for (const classname of missingNitroClassnames) {
|
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);
|
stillMissing.push(classname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<N> 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<N> 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<N>`, interaction `pet<N>`). 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<N>` 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<CatalogItemKind> = 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<CatalogItemKind> = 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<string>();
|
||||||
|
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`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user