feat(catalog): repair missing furniture components with verified status
This commit is contained in:
1 parent
3c24df9b1d
commit
570f3dde44
13 files changed
+438
-164
No files matched your search
@@ -9,6 +9,7 @@ export interface ImportJobItem {
|
||||
attachmentId?: string;
|
||||
}
|
||||
export interface ImportJob {
|
||||
mode?: "repair";
|
||||
id: string;
|
||||
userId: number;
|
||||
createdAt: string;
|
||||
|
||||
@@ -65,3 +65,39 @@ describe("studio inspection", () => {
|
||||
).toBe("conflict");
|
||||
});
|
||||
});
|
||||
|
||||
import { furnitureHealth } from "./studio-inspection";
|
||||
|
||||
it("does not call SQL-only furniture complete", () => {
|
||||
expect(
|
||||
furnitureHealth({
|
||||
...empty,
|
||||
sql: [{ id: 1, spriteId: 2, name: "chair", type: "s" }],
|
||||
}),
|
||||
).toMatchObject({
|
||||
state: "incomplete",
|
||||
missing: ["Nitro", "Icon", "Furnidata", "Catalog offer"],
|
||||
});
|
||||
});
|
||||
it("keeps inaccessible assets unverified", () => {
|
||||
expect(
|
||||
furnitureHealth({ ...empty, nitro: { exists: null, bytes: 0 } }).state,
|
||||
).toBe("unknown");
|
||||
});
|
||||
it("detects mismatched local sprite IDs", () => {
|
||||
expect(
|
||||
furnitureHealth({
|
||||
...empty,
|
||||
sql: [{ id: 1, spriteId: 2, name: "chair", type: "s" }],
|
||||
furnidata: [
|
||||
{
|
||||
id: 3,
|
||||
name: "chair",
|
||||
description: "",
|
||||
revision: 1,
|
||||
type: "flooritem",
|
||||
},
|
||||
],
|
||||
}).state,
|
||||
).toBe("conflict");
|
||||
});
|
||||
@@ -68,3 +68,37 @@ export function compareFurniture(
|
||||
: "present";
|
||||
return { state, fields } as const;
|
||||
}
|
||||
export function furnitureHealth(local: FurnitureInspection) {
|
||||
const missing: string[] = [];
|
||||
if (local.nitro.exists === false) missing.push("Nitro");
|
||||
if (local.icon.exists === false) missing.push("Icon");
|
||||
if (local.furnidataReadable && !local.furnidata.length)
|
||||
missing.push("Furnidata");
|
||||
if (!local.sql.length) missing.push("SQL item");
|
||||
if (!local.catalog.length) missing.push("Catalog offer");
|
||||
const sql = local.sql[0],
|
||||
fd = local.furnidata[0];
|
||||
const conflict =
|
||||
local.sql.length > 1 ||
|
||||
local.furnidata.length > 1 ||
|
||||
!!(
|
||||
sql &&
|
||||
fd &&
|
||||
(sql.spriteId !== fd.id ||
|
||||
(sql.type === "i" ? "wallitem" : "flooritem") !== fd.type)
|
||||
);
|
||||
const unknown =
|
||||
!local.furnidataReadable ||
|
||||
local.nitro.exists === null ||
|
||||
local.icon.exists === null;
|
||||
const state = conflict
|
||||
? "conflict"
|
||||
: unknown
|
||||
? "unknown"
|
||||
: !missing.length
|
||||
? "complete"
|
||||
: missing.length === 5
|
||||
? "new"
|
||||
: "incomplete";
|
||||
return { state, missing } as const;
|
||||
}
|
||||
@@ -513,6 +513,7 @@ export async function importSingleFurni(params: {
|
||||
skipFurniDataWrite?: boolean;
|
||||
updateExisting?: boolean;
|
||||
repairExisting?: boolean;
|
||||
preserveAssets?: boolean;
|
||||
/** Validated matching bundle supplied by a queued import. */
|
||||
providedNitro?: Buffer;
|
||||
onProgress?: (status: string) => void;
|
||||
@@ -702,14 +703,14 @@ export async function importSingleFurni(params: {
|
||||
let iconFailure = "",
|
||||
swfFailure = "";
|
||||
const [iconOkResult, swfOk] = await Promise.all([
|
||||
preservingExisting && existsSync(iconPath)
|
||||
(preservingExisting || params.preserveAssets) && existsSync(iconPath)
|
||||
? Promise.resolve(true)
|
||||
: tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => {
|
||||
iconFailure = detail;
|
||||
}),
|
||||
params.providedNitro
|
||||
params.providedNitro || (params.preserveAssets && existsSync(nitroPath))
|
||||
? Promise.resolve(false)
|
||||
: preservingExisting && existsSync(swfPath)
|
||||
: (preservingExisting || params.preserveAssets) && existsSync(swfPath)
|
||||
? Promise.resolve(true)
|
||||
: tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => {
|
||||
swfFailure = detail;
|
||||
@@ -753,7 +754,12 @@ export async function importSingleFurni(params: {
|
||||
);
|
||||
}
|
||||
|
||||
if (!swfOk && !nitroDownloadOk && !params.providedNitro)
|
||||
if (
|
||||
!swfOk &&
|
||||
!nitroDownloadOk &&
|
||||
!params.providedNitro &&
|
||||
!(params.preserveAssets && existsSync(nitroPath))
|
||||
)
|
||||
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
|
||||
|
||||
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
||||
|
||||
@@ -9,6 +9,7 @@ import { readFurnitureAttachment } from "./furni-attachment";
|
||||
import { patchLocalizedFurniDataEntries } from "./furni-data-i18n";
|
||||
import { ensureDirectories, importSingleFurni } from "./furni-import";
|
||||
import { ImportJobStore } from "./furni-job-store";
|
||||
import { repairFurniture } from "./furniture-repair";
|
||||
import { rcon } from "./rcon";
|
||||
|
||||
const LOCK = "furniture-import-worker:v1";
|
||||
@@ -77,7 +78,9 @@ async function drain() {
|
||||
job.userId,
|
||||
)
|
||||
: undefined;
|
||||
const result = await importSingleFurni({
|
||||
const result = await (job.mode === "repair"
|
||||
? repairFurniture
|
||||
: importSingleFurni)({
|
||||
...item,
|
||||
repairExisting: true,
|
||||
providedNitro,
|
||||
@@ -100,7 +103,7 @@ async function drain() {
|
||||
repairExisting: true,
|
||||
},
|
||||
});
|
||||
if (job.translate)
|
||||
if (job.translate && job.mode !== "repair")
|
||||
try {
|
||||
await patchLocalizedFurniDataEntries([item], true, job.langs);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { normalizeClassname } from "@/lib/furni/classname";
|
||||
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { readFurniData } from "@/lib/services/furni-data";
|
||||
|
||||
async function asset(
|
||||
directory: string,
|
||||
name: string,
|
||||
): Promise<{ exists: boolean | null; bytes: number }> {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(directory, name));
|
||||
return {
|
||||
exists: stat.isFile() ? (stat.size > 0 ? true : null) : false,
|
||||
bytes: stat.size,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
exists: (error as NodeJS.ErrnoException).code === "ENOENT" ? false : null,
|
||||
bytes: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
async function assetCandidates(directory: string, names: string[]) {
|
||||
const results = await Promise.all(
|
||||
[...new Set(names)].map((name) => asset(directory, name)),
|
||||
);
|
||||
return (
|
||||
results.find((result) => result.exists === true) ??
|
||||
results.find((result) => result.exists === null) ??
|
||||
results[0]
|
||||
);
|
||||
}
|
||||
|
||||
export async function inspectFurniture(
|
||||
names: string[],
|
||||
): Promise<FurnitureInspection[]> {
|
||||
const namesSql = sql.join(
|
||||
names.map((name) => sql`${normalizeClassname(name)}`),
|
||||
sql`, `,
|
||||
);
|
||||
const [rows] = (await db.execute(
|
||||
sql`SELECT id, sprite_id AS spriteId, item_name AS classname, public_name AS name, type FROM items_base WHERE LOWER(TRIM(item_name)) IN (${namesSql})`,
|
||||
)) as unknown as [
|
||||
Array<FurnitureInspection["sql"][number] & { classname: string }>,
|
||||
unknown,
|
||||
];
|
||||
const [offers] = (await db.execute(
|
||||
sql`SELECT ib.item_name AS classname, ci.id, ci.page_id AS pageId, ci.cost_credits AS credits, ci.cost_points AS points FROM items_base ib JOIN catalog_items ci ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 WHERE LOWER(TRIM(ib.item_name)) IN (${namesSql})`,
|
||||
)) as unknown as [
|
||||
Array<FurnitureInspection["catalog"][number] & { classname: string }>,
|
||||
unknown,
|
||||
];
|
||||
let readable = true;
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
data = await readFurniData();
|
||||
} catch {
|
||||
readable = false;
|
||||
}
|
||||
const entries = new Map<string, FurnitureInspection["furnidata"]>();
|
||||
for (const [section, type] of [
|
||||
["roomitemtypes", "flooritem"],
|
||||
["wallitemtypes", "wallitem"],
|
||||
] as const) {
|
||||
const list = (
|
||||
data[section] as
|
||||
| { furnitype?: Array<Record<string, unknown>> }
|
||||
| undefined
|
||||
)?.furnitype;
|
||||
if (!Array.isArray(list)) {
|
||||
readable = false;
|
||||
continue;
|
||||
}
|
||||
for (const entry of list) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
readable = false;
|
||||
continue;
|
||||
}
|
||||
const name = String(entry.classname ?? "");
|
||||
const key = normalizeClassname(name);
|
||||
if (!names.some((value) => normalizeClassname(value) === key)) continue;
|
||||
const values = entries.get(key) ?? [];
|
||||
values.push({
|
||||
id: Number(entry.id),
|
||||
name: String(entry.name ?? ""),
|
||||
description: String(entry.description ?? ""),
|
||||
revision: Number(entry.revision ?? 0),
|
||||
type,
|
||||
});
|
||||
entries.set(key, values);
|
||||
}
|
||||
}
|
||||
const dirs = await getFurniAssetDirs();
|
||||
const items: FurnitureInspection[] = [];
|
||||
for (let offset = 0; offset < names.length; offset += 20) {
|
||||
items.push(
|
||||
...(await Promise.all(
|
||||
names.slice(offset, offset + 20).map(async (classname) => {
|
||||
const variants = [
|
||||
classname,
|
||||
...rows
|
||||
.filter(
|
||||
(row) =>
|
||||
normalizeClassname(row.classname) ===
|
||||
normalizeClassname(classname),
|
||||
)
|
||||
.map((row) => row.classname),
|
||||
];
|
||||
const [nitro, icon] = await Promise.all([
|
||||
assetCandidates(
|
||||
dirs.nitroDir,
|
||||
variants.map((name) => `${name.split("*")[0]}.nitro`),
|
||||
),
|
||||
assetCandidates(
|
||||
dirs.iconDir,
|
||||
variants.map((name) => `${name.replaceAll("*", "_")}_icon.png`),
|
||||
),
|
||||
]);
|
||||
return {
|
||||
classname,
|
||||
sql: rows.filter(
|
||||
(row) =>
|
||||
normalizeClassname(row.classname) ===
|
||||
normalizeClassname(classname),
|
||||
),
|
||||
catalog: offers.filter(
|
||||
(row) =>
|
||||
normalizeClassname(row.classname) ===
|
||||
normalizeClassname(classname),
|
||||
),
|
||||
furnidata: entries.get(normalizeClassname(classname)) ?? [],
|
||||
furnidataReadable: readable,
|
||||
nitro,
|
||||
icon,
|
||||
};
|
||||
}),
|
||||
)),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ inspect: vi.fn(), import: vi.fn() }));
|
||||
vi.mock("./furniture-inspection", () => ({ inspectFurniture: mocks.inspect }));
|
||||
vi.mock("./furni-import", () => ({ importSingleFurni: mocks.import }));
|
||||
|
||||
import { repairFurniture } from "./furniture-repair";
|
||||
|
||||
const complete: FurnitureInspection = {
|
||||
classname: "chair",
|
||||
sql: [{ id: 9, spriteId: 10, name: "Local name", type: "s" }],
|
||||
furnidata: [
|
||||
{
|
||||
id: 10,
|
||||
name: "Local name",
|
||||
description: "Keep",
|
||||
revision: 1,
|
||||
type: "flooritem",
|
||||
},
|
||||
],
|
||||
furnidataReadable: true,
|
||||
catalog: [{ id: 2, pageId: 3, credits: 5, points: 0 }],
|
||||
nitro: { exists: true, bytes: 200 },
|
||||
icon: { exists: true, bytes: 100 },
|
||||
};
|
||||
const params = {
|
||||
classname: "chair",
|
||||
id: 50,
|
||||
name: "Source name",
|
||||
description: "Source",
|
||||
type: "flooritem",
|
||||
revision: 2,
|
||||
category: "other",
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.import.mockResolvedValue({ ok: true, itemId: 9, warnings: [] });
|
||||
});
|
||||
it("does nothing for complete furniture", async () => {
|
||||
mocks.inspect.mockResolvedValue([complete]);
|
||||
expect((await repairFurniture(params)).ok).toBe(true);
|
||||
expect(mocks.import).not.toHaveBeenCalled();
|
||||
});
|
||||
it("preserves local IDs, existing furnidata and assets and verifies afterward", async () => {
|
||||
mocks.inspect
|
||||
.mockResolvedValueOnce([{ ...complete, icon: { exists: false, bytes: 0 } }])
|
||||
.mockResolvedValueOnce([complete]);
|
||||
expect((await repairFurniture(params)).ok).toBe(true);
|
||||
expect(mocks.import).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 10,
|
||||
repairExisting: true,
|
||||
preserveAssets: true,
|
||||
skipFurniDataWrite: true,
|
||||
}),
|
||||
);
|
||||
expect(mocks.inspect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("does not report success when a component is still missing", async () => {
|
||||
mocks.inspect.mockResolvedValue([
|
||||
{ ...complete, icon: { exists: false, bytes: 0 } },
|
||||
]);
|
||||
expect(await repairFurniture(params)).toMatchObject({
|
||||
ok: false,
|
||||
error: "Repair incomplete. Still missing: Icon",
|
||||
});
|
||||
});
|
||||
it("blocks conflicting and unreadable local data before mutation", async () => {
|
||||
mocks.inspect.mockResolvedValue([{ ...complete, furnidataReadable: false }]);
|
||||
await expect(repairFurniture(params)).rejects.toThrow("blocked");
|
||||
expect(mocks.import).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { furnitureHealth } from "@/lib/furni/studio-inspection";
|
||||
import { importSingleFurni } from "./furni-import";
|
||||
import { inspectFurniture } from "./furniture-inspection";
|
||||
export async function repairFurniture(
|
||||
params: Parameters<typeof importSingleFurni>[0],
|
||||
): ReturnType<typeof importSingleFurni> {
|
||||
const [before] = await inspectFurniture([params.classname]);
|
||||
const health = furnitureHealth(before);
|
||||
if (health.state === "conflict" || health.state === "unknown")
|
||||
throw Error(
|
||||
"Repair blocked: local data is conflicting or could not be verified",
|
||||
);
|
||||
if (health.state === "complete")
|
||||
return {
|
||||
ok: true,
|
||||
itemId: before.sql[0].id,
|
||||
warnings: ["Already complete; no changes made"],
|
||||
};
|
||||
const result = await importSingleFurni({
|
||||
...params,
|
||||
id: before.sql[0]?.spriteId ?? before.furnidata[0]?.id ?? params.id,
|
||||
repairExisting: true,
|
||||
preserveAssets: true,
|
||||
skipFurniDataWrite: before.furnidata.length > 0,
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
const [after] = await inspectFurniture([params.classname]);
|
||||
const verified = furnitureHealth(after);
|
||||
if (verified.state !== "complete")
|
||||
return {
|
||||
...result,
|
||||
ok: false,
|
||||
error: verified.missing.length
|
||||
? `Repair incomplete. Still missing: ${verified.missing.join(", ")}`
|
||||
: "Repair completed with unresolved local conflicts or unreadable data",
|
||||
};
|
||||
return {
|
||||
...result,
|
||||
warnings: [
|
||||
...result.warnings,
|
||||
"Verified: SQL, furnidata, Nitro, icon and catalog offer are present",
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user