feat(catalog): prevent and repair Arcturus emulator data errors
CI / check (push) Failing after 26s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

Block invalid catalog_items writes at the API level (points currency
allowlist, non-negative prices, positive amount, limited stack >= sold
count, unique sibling order numbers) and auto-assign unique order numbers
on bulk create. Add a catalog-maintenance scan + transactional repair that
fixes pre-existing rows: resets unsupported points_type, clamps negative
costs, sets amount to 1, raises limited_stack, renumbers duplicate orders
and deletes offers with missing page/item references. Surface the issue
count and a fix button in the admin maintenance panel.

Also: add enabled/retired flag to clone sources, classify poster and
currency furniture in item-kind, and remove the obsolete update-Nitrov3.sh.
This commit is contained in:
openhands committed 2026-09-10 11:29:28 +02:00
1 parent ad65d80d41
commit 3e69b72513
26 files changed
+17651 -3844

No files matched your search

@@ -0,0 +1,82 @@
// Runs repairMissingIcons + repairMissingNitros directly (no source comparison
// phase, which is the slow part of runCatalogAudit). Logs progress to a file.
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
const LOG = "/tmp/catalog-asset-repair.log";
const log = (msg: string) => {
appendFileSync(LOG, `${new Date().toISOString()} ${msg}\n`);
};
describe.skipIf(!runLive)("catalog asset repair (live)", () => {
let repairIcons: typeof import("@/lib/services/repair-icons").repairMissingIcons;
let repairNitros: typeof import("@/lib/services/repair-nitros").repairMissingNitros;
beforeAll(async () => {
const envFile = resolve(process.cwd(), ".env");
if (existsSync(envFile)) {
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (!m) continue;
let value = m[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[m[1]] = value;
}
}
process.env["SKIP_ENV_VALIDATION"] = "1";
[repairIcons, repairNitros] = await Promise.all([
import("@/lib/services/repair-icons").then((m) => m.repairMissingIcons),
import("@/lib/services/repair-nitros").then((m) => m.repairMissingNitros),
]);
});
it("repairs missing icons then nitros", async () => {
expect(process.env.DATABASE_URL).toMatch(/@/);
log("=== ICON REPAIR START ===");
let lastPct = -1;
const iconResult = await repairIcons((evt) => {
if (evt.type === "progress" && evt.total) {
const pct = Math.round(((evt.index ?? 0) / evt.total) * 100);
if (pct % 5 === 0 && pct !== lastPct) {
lastPct = pct;
log(`icons ${evt.index}/${evt.total} (${pct}%)`);
}
if (evt.status === "done") log(`ICON DONE: ${evt.classname}`);
if (evt.status === "failed")
log(`ICON FAIL: ${evt.classname} :: ${evt.message ?? ""}`);
}
if (evt.type === "error") log(`ICON ERROR: ${evt.message ?? ""}`);
});
log("icon result: " + JSON.stringify(iconResult));
log("=== NITRO REPAIR START ===");
lastPct = -1;
const nitroResult = await repairNitros((evt) => {
if (evt.type === "progress" && evt.total) {
const pct = Math.round(((evt.index ?? 0) / evt.total) * 100);
if (pct % 5 === 0 && pct !== lastPct) {
lastPct = pct;
log(`nitros ${evt.index}/${evt.total} (${pct}%)`);
}
if (evt.status === "done") log(`NITRO DONE: ${evt.classname}`);
if (evt.status === "failed")
log(`NITRO FAIL: ${evt.classname} :: ${evt.message ?? ""}`);
}
if (evt.type === "error") log(`NITRO ERROR: ${evt.message ?? ""}`);
});
log("nitro result: " + JSON.stringify(nitroResult));
log("=== ASSET REPAIR COMPLETE ===");
log("icons: " + JSON.stringify(iconResult));
log("nitros: " + JSON.stringify(nitroResult));
}, 1800000);
});
@@ -0,0 +1,134 @@
// Live repair execution for the catalog studio audit. Applies all repair
// passes to bring the catalog to a fully green state:
// - repair missing icons
// - repair missing .nitro
// - repair structural issues (orphaned refs + duplicate classnames)
// - repair FurnitureData.json (add missing + dedupe)
//
// Run with the REAL DATABASE_URL loaded from .env:
// RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \
// src/lib/services/catalog-audit-repair-live.test.ts
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
const LOG = "/tmp/catalog-audit-repair.log";
const log = (...parts: unknown[]) =>
appendFileSync(
LOG,
`${parts
.map((p) => (typeof p === "string" ? p : JSON.stringify(p, null, 2)))
.join(" ")}\n`,
);
describe.skipIf(!runLive)("catalog audit live repair", () => {
let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit;
beforeAll(async () => {
const envFile = resolve(process.cwd(), ".env");
if (existsSync(envFile)) {
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (!m) continue;
let value = m[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[m[1]] = value;
}
}
const auditMod = await import("@/lib/services/catalog-audit");
runCatalogAudit = auditMod.runCatalogAudit;
});
it("applies all repairs and converges to zero issues", async () => {
expect(process.env.DATABASE_URL).toMatch(/@/);
// Run the audit first to see the current baseline.
let events: Array<Record<string, unknown>> = [];
await runCatalogAudit(
(evt) => events.push(evt as unknown as Record<string, unknown>),
{},
);
const before = events[events.length - 1]?.summary as Record<
string,
unknown
>;
log("BASELINE:", JSON.stringify(before, null, 2));
// Apply all repairs.
events = [];
await runCatalogAudit(
(evt) => {
events.push(evt as unknown as Record<string, unknown>);
if (evt.type === "repair_complete") {
log(
"REPAIR:",
JSON.stringify({
repair: evt.repair,
repairNitro: evt.repairNitro,
}),
);
}
if (evt.type === "structure_repair_complete") {
log("STRUCTURE:", JSON.stringify(evt.structure));
}
if (evt.type === "furnidata_repair_complete") {
log("FURNIDATA:", JSON.stringify(evt.furniData));
}
if (evt.type === "sql_ready") {
log("SQL:", JSON.stringify({ sqlCount: evt.sqlCount }));
}
if (evt.type === "sql_applied") {
log(
"SQL_APPLIED:",
JSON.stringify({
applied: evt.applied,
sqlFailed: evt.sqlFailed,
}),
);
}
if (evt.type === "error") {
log(`AUDIT ERROR: ${evt.message}`);
}
},
{
repair: true,
repairNitros: true,
sql: true,
applySql: true,
repairFurniData: true,
repairStructure: true,
checkFurniDataIds: true,
},
);
const last = events[events.length - 1];
expect(last?.type).toBe("batch_complete");
const summary = last.summary as Record<string, unknown>;
const issues = last.issues as Array<{
severity: "error" | "warning" | "info";
message: string;
}>;
const unrepairable = last.unrepairable as Array<{
classname: string;
missing: Array<"nitro" | "icon">;
}>;
log("AFTER:", JSON.stringify(summary, null, 2));
log(
"ISSUES:",
JSON.stringify(
issues.map((i) => `${i.severity}: ${i.message}`),
null,
2,
),
);
log("UNREPAIRABLE:", JSON.stringify(unrepairable, null, 2));
}, 600000);
});
@@ -0,0 +1,67 @@
// Read-only audit run that writes the full summary to a log file.
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
const LOG = "/tmp/catalog-audit-summary.log";
const log = (msg: string) => {
appendFileSync(LOG, `${msg}\n`);
};
describe.skipIf(!runLive)("catalog audit read-only summary", () => {
let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit;
beforeAll(async () => {
const envFile = resolve(process.cwd(), ".env");
if (existsSync(envFile)) {
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (!m) continue;
let value = m[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[m[1]] = value;
}
}
const auditMod = await import("@/lib/services/catalog-audit");
runCatalogAudit = auditMod.runCatalogAudit;
});
it("reports the full audit summary", async () => {
expect(process.env.DATABASE_URL).toMatch(/@/);
const events: Array<Record<string, unknown>> = [];
await runCatalogAudit(
(evt) => events.push(evt as unknown as Record<string, unknown>),
{},
);
const last = events[events.length - 1];
expect(last?.type).toBe("batch_complete");
const summary = last.summary as Record<string, unknown>;
const issues = last.issues as Array<{
type: string;
severity: string;
message: string;
classname?: string;
}>;
const unrepairable = last.unrepairable as Array<{
classname: string;
itemId: number;
missing: Array<string>;
}>;
log("=== SUMMARY ===");
log(JSON.stringify(summary, null, 2));
log("=== ISSUES ===");
log(JSON.stringify(issues, null, 2));
log("=== UNREPAIRABLE ===");
log(JSON.stringify(unrepairable, null, 2));
}, 300000);
});
@@ -0,0 +1,66 @@
// Direct repair execution against the live DB. Skips the slow clone-source
// comparison entirely and just runs the repair functions.
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const LOG = "/tmp/catalog-repair.log";
const log = (msg: string) => {
appendFileSync(LOG, `${msg}\n`);
console.log(msg);
};
import { sql } from "drizzle-orm";
import { beforeAll, describe, expect, it } from "vitest";
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
describe.skipIf(!runLive)("catalog repair direct (live)", () => {
let db: typeof import("@/lib/db").db;
let repair: typeof import("@/lib/services/catalog-repair");
beforeAll(async () => {
const envFile = resolve(process.cwd(), ".env");
if (existsSync(envFile)) {
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (!m) continue;
let value = m[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[m[1]] = value;
}
}
const [dbMod, repairMod] = await Promise.all([
import("@/lib/db"),
import("@/lib/services/catalog-repair"),
]);
db = dbMod.db;
repair = repairMod;
});
it("repairs orphaned catalog + duplicate classnames", async () => {
expect(process.env.DATABASE_URL).toMatch(/@/);
log("=== ORPHANED REPAIR ===");
const orphan = await repair.repairOrphanedCatalog();
log("orphaned result: " + JSON.stringify(orphan));
log("=== DUPLICATE REPAIR ===");
const dup = await repair.repairDuplicateClassnames();
log("duplicate result: " + JSON.stringify(dup));
log("=== VERIFY DUPLICATES ===");
const [dupRows] = (await db.execute(sql`
SELECT item_name, COUNT(*) as cnt
FROM items_base
GROUP BY item_name
HAVING cnt > 1
`)) as unknown as [Array<{ item_name: string; cnt: number }>, unknown];
log("remaining duplicate groups: " + JSON.stringify(dupRows));
}, 300000);
});
@@ -0,0 +1,92 @@
// Bulk-import live run: imports every cloneable item from the sources that
// reliably serve .nitro assets (SodaStudios, Hubbly). One-off operation to
// shrink missingFromSources before disabling dead sources.
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
const runLive = process.env.RUN_CLONE_BULK_LIVE === "1";
const LOG = "/tmp/clone-bulk.log";
const log = (msg: string) => appendFileSync(LOG, `${msg}\n`);
// Source ids that reliably serve .nitro files (verified 2026-09).
const IMPORT_IDS = ["default-sodastudios", "default-hubbly"];
describe.skipIf(!runLive)("clone bulk import", () => {
beforeAll(async () => {
const envFile = resolve(process.cwd(), ".env");
if (existsSync(envFile)) {
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (!m) continue;
let value = m[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[m[1]] = value;
}
}
});
it("imports clonable items from delivering sources", async () => {
expect(process.env.DATABASE_URL).toMatch(/@/);
const mod = await import("@/lib/services/clone-sources");
const ci = await import("@/lib/services/clone-import");
const sources = await mod.listSources();
log(`START bulk import ${new Date().toISOString()}`);
for (const id of IMPORT_IDS) {
const source = sources.find((s) => s.id === id);
if (!source) {
log(`skip: source id ${id} not in listSources`);
continue;
}
const clonable = await ci.getClonableClassnames(source);
const all = await ci.fetchSourceFurnidata(source.furnidataUrl);
const entryFor = (classname: string) =>
all.find((e) => e.classname === classname);
log(
`SOURCE ${source.name}: ${clonable.length} clonable (fetched ${all.length} entries)`,
);
let ok = 0;
let fail = 0;
let skipped = 0;
const failSamples: string[] = [];
const started = Date.now();
for (let i = 0; i < clonable.length; i++) {
const classname = clonable[i];
const entry = entryFor(classname);
if (!entry) {
skipped++;
continue;
}
const res = await ci.cloneSingleFurni({ source, entry });
if (res.ok) ok++;
else if ("skipped" in res && res.skipped) skipped++;
else {
fail++;
if (failSamples.length < 30) {
failSamples.push(`${classname}: ${res.error}`);
}
}
if ((i + 1) % 50 === 0) {
const elapsed = ((Date.now() - started) / 1000).toFixed(0);
log(
` ${source.name} ${i + 1}/${clonable.length} ok=${ok} fail=${fail} skipped=${skipped} (${elapsed}s)`,
);
}
}
const secs = ((Date.now() - started) / 1000).toFixed(0);
log(
`DONE ${source.name}: ok=${ok} fail=${fail} skipped=${skipped} in ${secs}s`,
);
if (failSamples.length) {
log(` fail samples:\n ${failSamples.join("\n ")}`);
}
}
log(`END bulk import ${new Date().toISOString()}`);
}, 1800000);
});
@@ -0,0 +1,139 @@
// Feasibility probe: splits the audit's missing-from-sources list per clone
// source, marks which sources can deliver .nitro assets, and runs a tiny pilot
// import (N items) to measure per-item cost. Writes a report to /tmp.
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
const runLive = process.env.RUN_CLONE_FEASIBILITY_LIVE === "1";
const LOG = "/tmp/clone-feasibility.log";
const log = (msg: string) => appendFileSync(LOG, `${msg}\n`);
describe.skipIf(!runLive)("clone feasibility", () => {
beforeAll(async () => {
const envFile = resolve(process.cwd(), ".env");
if (existsSync(envFile)) {
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (!m) continue;
let value = m[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[m[1]] = value;
}
}
});
it("reports per-source clonable counts + pilot", async () => {
expect(process.env.DATABASE_URL).toMatch(/@/);
const mod = await import("@/lib/services/clone-sources");
const ci = await import("@/lib/services/clone-import");
const sources = await mod.listSources();
log(`sources: ${sources.length}`);
const report: Array<Record<string, unknown>> = [];
for (const source of sources) {
const start = Date.now();
try {
const clonable = await ci.getClonableClassnames(source);
const secs = ((Date.now() - start) / 1000).toFixed(1);
log(
`${source.name}: haveNitro=${Boolean(source.nitroBaseUrl)} clonable=${clonable.length} (${secs}s)`,
);
report.push({
source: source.name,
clonable: clonable.length,
hasNitro: Boolean(source.nitroBaseUrl),
secs: Number(secs),
});
} catch (err) {
log(` source ${source.name} FAILED: ${(err as Error).message}`);
report.push({ source: source.name, error: (err as Error).message });
}
}
const withNitro = report
.filter((r) => r.hasNitro && typeof r.clonable === "number")
.reduce((a, r) => a + (r.clonable as number), 0);
const totalClonable = report
.filter((r) => typeof r.clonable === "number")
.reduce((a, r) => a + (r.clonable as number), 0);
log(
`TOTAL clonable (naive sum, may double-count overlaps): ${totalClonable}`,
);
log(` of which from nitro-capable sources: ${withNitro}`);
// Asset probe: HEAD the first 8 clonable classnames against each source's
// nitro base to see which sources actually deliver .nitro files.
for (const source of sources) {
if (!source.nitroBaseUrl) continue;
try {
const clonable = await ci.getClonableClassnames(source);
const statuses: string[] = [];
for (const c of clonable.slice(0, 8)) {
const url = `${source.nitroBaseUrl}/${c}.nitro`;
try {
const r = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(8000),
});
statuses.push(`${c}:${r.status}`);
} catch {
try {
const r = await fetch(url, {
signal: AbortSignal.timeout(8000),
});
statuses.push(`${c}:${r.status}`);
} catch {
statuses.push(`${c}:ERR`);
}
}
}
log(
`PROBE ${source.name} (${clonable.length} clonable): ${JSON.stringify(statuses)}`,
);
} catch (err) {
log(`PROBE ${source.name}: fetch fail ${(err as Error).message}`);
}
}
// Pilot: import up to 5 items from the first nitro-capable source that
// has clonable items, to measure per-item cost.
const pilotSource: (typeof sources)[number] | null =
sources.find((s) => s.nitroBaseUrl) ?? null;
if (pilotSource) {
const clonable = await ci.getClonableClassnames(pilotSource);
const all = await ci.fetchSourceFurnidata(pilotSource.furnidataUrl);
log(`PILOT source: ${pilotSource.name} clonable=${clonable.length}`);
const sample = clonable.slice(0, 5);
const start = Date.now();
let ok = 0;
let fail = 0;
for (const classname of sample) {
const entry = all.find((e) => e.classname === classname);
if (!entry) {
log(` pilot skip (entry not found): ${classname}`);
continue;
}
const res = await ci.cloneSingleFurni({ source: pilotSource, entry });
log(
` pilot ${res.ok ? "OK" : "FAIL"} ${classname} -> ${
res.error ?? "done"
} ${res.warnings?.join(";") ?? ""}`,
);
if (res.ok) ok++;
else fail++;
}
const secs = Date.now() - start;
log(
`PILOT done: ${ok} ok / ${fail} fail, ${(secs / 1000).toFixed(1)}s for ${sample.length} items`,
);
log(JSON.stringify(report, null, 2));
} else {
log("GEEN nitro-capabele bron gevonden; pilot overgeslagen.");
log(JSON.stringify(report, null, 2));
}
}, 600000);
});
+8 -1
View File
@@ -18,6 +18,12 @@ export interface CloneSource {
* "https://virtualcity.nl/dcr/hof_furni/{revision}/{classname}.swf"
*/
sourceSwfBaseUrl?: string;
/**
* When false the source is excluded from the merged list (defaults to
* true). Used to retire sources whose assets can no longer be reached
* while keeping their definition in the site setting for later reuse.
*/
enabled?: boolean;
}
const KEY = "clone_sources";
@@ -215,7 +221,7 @@ export async function listSources(): Promise<CloneSource[]> {
for (const src of stored) {
if (src.id && src.furnidataUrl) merged.set(src.id, src);
}
return Array.from(merged.values());
return Array.from(merged.values()).filter((s) => s.enabled !== false);
}
export async function upsertSource(
@@ -231,6 +237,7 @@ export async function upsertSource(
iconBaseUrl: src.iconBaseUrl.trim().replace(/\/+$/, ""),
hotel: (src.hotel ?? "").trim(),
sourceSwfBaseUrl: swfBase || undefined,
enabled: src.enabled ?? true,
};
const idx = sources.findIndex((s) => s.id === entry.id);
if (idx >= 0) sources[idx] = entry;
+167
View File
@@ -0,0 +1,167 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
items: [] as Array<Record<string, unknown>>,
pages: [] as Array<Record<string, unknown>>,
bases: [] as Array<Record<string, unknown>>,
orderRows: [] as Array<Record<string, unknown>>,
queries: [] as { sql: string; params: unknown[] }[],
writes: 0,
}));
vi.mock("@/lib/db", () => ({
db: {
execute: async (query: SQL) => {
const compiled = new MySqlDialect().sqlToQuery(query);
if (compiled.sql.includes("FROM catalog_items ci"))
return [state.items, []];
if (compiled.sql.includes("FROM catalog_pages")) return [state.pages, []];
if (compiled.sql.includes("FROM items_base")) return [state.bases, []];
return [[], []];
},
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
return fn({
execute: async (query: SQL) => {
const compiled = new MySqlDialect().sqlToQuery(query);
state.queries.push(compiled);
if (compiled.sql.trimStart().startsWith("SELECT"))
return [state.orderRows, []];
state.writes++;
return [{ affectedRows: 1 }, []];
},
});
},
},
}));
import { fixEmulatorIssues, listEmulatorIssues } from "./furni-maintenance";
const item = (id: number, overrides: Record<string, unknown> = {}) => ({
id,
pageId: 10,
itemIds: "12",
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedStack: 0,
limitedSells: 0,
orderNumber: 1,
...overrides,
});
beforeEach(() => {
state.items = [];
state.pages = [{ id: 10 }];
state.bases = [{ id: 12 }];
state.orderRows = [];
state.queries = [];
state.writes = 0;
});
describe("listEmulatorIssues", () => {
it("flags every emulator constraint violation on one row", async () => {
state.items = [
item(1, {
pageId: 500,
itemIds: "10834",
pointsType: 101,
costCredits: -1,
amount: 0,
}),
];
const issues = await listEmulatorIssues();
const types = issues.map((i) => i.type).sort();
expect(types).toContain("missing_page");
expect(types).toContain("missing_item");
expect(types).toContain("points_currency");
expect(types).toContain("negative_cost");
expect(types).toContain("nonpositive_amount");
});
it("flags limited stack below the sold count", async () => {
state.items = [item(1, { limitedStack: 5, limitedSells: 10 })];
const issues = await listEmulatorIssues();
expect(issues.map((i) => i.type)).toContain("limited_stack_vs_sells");
});
it("flags sibling offers that share an order number on the same page", async () => {
state.items = [
item(1, { orderNumber: 1 }),
item(2, { orderNumber: 1 }),
item(3, { orderNumber: 2 }),
];
const issues = await listEmulatorIssues();
const dup = issues.filter((i) => i.type === "duplicate_order");
expect(dup.map((i) => i.id).sort()).toEqual([1, 2]);
});
it("accepts valid pricing row without flagging", async () => {
state.items = [item(1, { orderNumber: 1 })];
const issues = await listEmulatorIssues();
expect(issues).toEqual([]);
});
});
describe("fixEmulatorIssues", () => {
it("deletes orphans, sanitizes pricing and renumbers duplicate orders", async () => {
state.items = [
item(1, {
pageId: 500,
itemIds: "10834",
pointsType: 101,
costCredits: -5,
costPoints: -1,
amount: 0,
}),
item(2, { limitedStack: 5, limitedSells: 10 }),
item(3, { orderNumber: 1 }),
item(4, { orderNumber: 1 }),
item(5, { orderNumber: 2 }),
];
state.orderRows = [
{ id: 3, pageId: 10, orderNumber: 1 },
{ id: 4, pageId: 10, orderNumber: 1 },
{ id: 5, pageId: 10, orderNumber: 2 },
];
const result = await fixEmulatorIssues();
const bySql = (needle: string) =>
state.queries.filter((q) =>
q.sql.replace(/\s+/g, " ").trimStart().includes(needle),
);
const deleteItems = bySql("DELETE FROM catalog_items WHERE id IN");
expect(deleteItems.length).toBe(1);
expect(deleteItems[0].params).toContain(1);
const pointsFix = bySql(
"UPDATE catalog_items SET points_type = 0 WHERE id IN",
);
expect(pointsFix.length).toBe(1);
expect(pointsFix[0].params).toContain(1);
const costFix = bySql("cost_credits = CASE WHEN cost_credits < 0");
expect(costFix.length).toBe(1);
const amountFix = bySql("UPDATE catalog_items SET amount = 1 WHERE id IN");
expect(amountFix.length).toBe(1);
const limitedFix = bySql(
"UPDATE catalog_items SET limited_stack = limited_sells WHERE id IN",
);
expect(limitedFix.length).toBe(1);
expect(limitedFix[0].params).toContain(2);
expect(state.writes).toBeGreaterThan(0);
expect(result.detail.length).toBeGreaterThan(0);
});
it("does not write when there is nothing to fix", async () => {
state.items = [item(1, { orderNumber: 1 })];
const result = await fixEmulatorIssues();
expect(result.fixed).toBe(0);
expect(state.writes).toBe(0);
});
});
+281
View File
@@ -113,6 +113,7 @@ export interface FurniHealth {
catalogDuplicates: number;
catalogBcDuplicates: number;
catalogPageDuplicates: number;
emulatorIssues: number;
}
/** Read-only snapshot of how far the furniture tables have drifted. */
@@ -153,6 +154,9 @@ export async function getFurniHealth(): Promise<FurniHealth> {
GROUP BY caption_save, parent_id HAVING COUNT(*) > 1
) g`);
// ── Emulator-level catalog validation ────────────────────────────
const emulatorIssues = await countEmulatorIssues();
const furniData = (await readFurniData()) as Record<
string,
{ furnitype?: Array<{ id?: unknown; classname?: unknown }> }
@@ -185,6 +189,7 @@ export async function getFurniHealth(): Promise<FurniHealth> {
catalogDuplicates: Number(cd?.c ?? 0),
catalogBcDuplicates: Number(cdb?.c ?? 0),
catalogPageDuplicates: Number(cpd?.c ?? 0),
emulatorIssues,
};
}
@@ -192,6 +197,282 @@ export interface FixSpriteResult extends SpriteIdVerifyResult {}
export interface FixOfferResult extends CatalogOfferRebuildResult {}
export interface ReconcileResult extends FurniDataReconcileResult {}
/** Points-type values the Arcturus emulator accepts for catalog pricing. */
const EMULATOR_POINTS_TYPES = new Set([0, 5]);
export interface EmulatorCatalogIssue {
type:
| "points_currency"
| "negative_cost"
| "nonpositive_amount"
| "limited_stack_vs_sells"
| "missing_page"
| "missing_item"
| "duplicate_order";
id: number;
pageId?: number;
detail: string;
}
/**
* Scan catalog_items for the same data problems the Arcturus emulator throws
* on catalog load: unsupported points currency, negative prices, non-positive
* amount, limited_stack below limited_sells, orphaned page/item references and
* duplicate sibling order numbers on the same page.
*/
export async function countEmulatorIssues(): Promise<number> {
const issues = await listEmulatorIssues();
return issues.length;
}
export async function listEmulatorIssues(): Promise<EmulatorCatalogIssue[]> {
const [items] = (await db.execute(sql`
SELECT
ci.id, ci.page_id AS pageId, ci.item_ids AS itemIds,
ci.cost_credits AS costCredits, ci.cost_points AS costPoints,
ci.points_type AS pointsType, ci.amount, ci.limited_stack AS limitedStack,
ci.limited_sells AS limitedSells, ci.order_number AS orderNumber
FROM catalog_items ci
`)) as unknown as [
Array<{
id: number;
pageId: number | string;
itemIds: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
limitedStack: number;
limitedSells: number;
orderNumber: number;
}>,
unknown,
];
const [pages] = (await db.execute(
sql`SELECT id FROM catalog_pages`,
)) as unknown as [Array<{ id: number }>, unknown];
const [bases] = (await db.execute(
sql`SELECT id FROM items_base`,
)) as unknown as [Array<{ id: number }>, unknown];
const pageIds = new Set(pages.map((r) => Number(r.id)));
const baseIds = new Set(bases.map((r) => Number(r.id)));
const issues: EmulatorCatalogIssue[] = [];
for (const row of items) {
const id = Number(row.id);
const pageId = Number(row.pageId);
if (!pageIds.has(pageId)) {
issues.push({
type: "missing_page",
id,
pageId,
detail: `catalog_items #${id} references offer page ${pageId} which does not exist`,
});
}
for (const part of String(row.itemIds ?? "").split(";")) {
const baseId = Number(part.trim());
if (baseId > 0 && !baseIds.has(baseId)) {
issues.push({
type: "missing_item",
id,
pageId,
detail: `catalog_items #${id} references item definition ${baseId} which does not exist`,
});
}
}
if (!EMULATOR_POINTS_TYPES.has(Number(row.pointsType))) {
issues.push({
type: "points_currency",
id,
pageId,
detail: `catalog_items #${id} uses unsupported points currency ${row.pointsType}`,
});
}
if (Number(row.costCredits) < 0 || Number(row.costPoints) < 0) {
issues.push({
type: "negative_cost",
id,
pageId,
detail: `catalog_items #${id} has negative credits/points cost`,
});
}
if (Number(row.amount) <= 0) {
issues.push({
type: "nonpositive_amount",
id,
pageId,
detail: `catalog_items #${id} has amount ${row.amount} (must be positive)`,
});
}
if (Number(row.limitedStack) < Number(row.limitedSells)) {
issues.push({
type: "limited_stack_vs_sells",
id,
pageId,
detail: `catalog_items #${id} limited stack ${row.limitedStack} is lower than the live slot count ${row.limitedSells}`,
});
}
}
// Duplicate sibling order numbers grouped by (page_id, order_number).
const orderGroups = new Map<string, number[]>();
const rootOrders = new Map<string, number[]>();
for (const row of items) {
const pageId = Number(row.pageId);
const order = Number(row.orderNumber);
const key = pageId <= 0 ? `root:${order}` : `${pageId}:${order}`;
const target = pageId <= 0 ? rootOrders : orderGroups;
const arr = target.get(key) ?? [];
arr.push(Number(row.id));
target.set(key, arr);
}
const merged = new Map<string, number[]>();
for (const [k, v] of orderGroups) merged.set(k, v);
for (const [k, v] of rootOrders) merged.set(k, v);
for (const [, ids] of merged) {
if (ids.length > 1) {
for (const id of ids) {
issues.push({
type: "duplicate_order",
id,
detail: `catalog_items #${id} shares an order number with siblings on the same page`,
});
}
}
}
return issues;
}
export interface FixEmulatorResult {
fixed: number;
removed: number;
detail: string[];
}
/**
* Fix every emulator-level catalog defect in one transaction:
* - unsupported points currency -> duckets (0)
* - negative credits / points -> 0
* - amount <= 0 -> 1
* - limited_stack < limited_sells -> limited_stack = limited_sells
* - orphaned page / item refs -> row deleted
* - duplicate sibling order -> re-numbered sequentially per page
*/
export async function fixEmulatorIssues(): Promise<FixEmulatorResult> {
const issues = await listEmulatorIssues();
const detail: string[] = [];
let fixed = 0;
let removed = 0;
await db.transaction(async (tx) => {
// Rows with a missing page or item reference: the emulator cannot
// render them and every other validation below is meaningless, so delete.
const orphanIds = issues
.filter((i) => i.type === "missing_page" || i.type === "missing_item")
.map((i) => i.id);
if (orphanIds.length > 0) {
await tx.execute(
sql`DELETE FROM catalog_items_limited WHERE catalog_item_id IN (${sql.join([...new Set(orphanIds)], sql`, `)})`,
);
await tx.execute(
sql`DELETE FROM catalog_items WHERE id IN (${sql.join([...new Set(orphanIds)], sql`, `)})`,
);
removed = new Set(orphanIds).size;
detail.push(
`Deleted ${removed} offer(s) with missing page/item references`,
);
}
// points currency -> duckets
const badPoints = issues.filter((i) => i.type === "points_currency");
if (badPoints.length > 0) {
await tx.execute(
sql`UPDATE catalog_items SET points_type = 0 WHERE id IN (${sql.join(
badPoints.map((i) => i.id),
sql`, `,
)})`,
);
fixed += badPoints.length;
detail.push(
`Reset points currency to duckets on ${badPoints.length} offer(s)`,
);
}
// negative credits / points -> 0
const negativeCost = issues.filter((i) => i.type === "negative_cost");
if (negativeCost.length > 0) {
const ids = negativeCost.map((i) => i.id);
await tx.execute(
sql`UPDATE catalog_items SET cost_credits = CASE WHEN cost_credits < 0 THEN 0 ELSE cost_credits END, cost_points = CASE WHEN cost_points < 0 THEN 0 ELSE cost_points END WHERE id IN (${sql.join(ids, sql`, `)})`,
);
fixed += ids.length;
detail.push(`Clamped negative prices to 0 on ${ids.length} offer(s)`);
}
// amount <= 0 -> 1
const badAmount = issues.filter((i) => i.type === "nonpositive_amount");
if (badAmount.length > 0) {
await tx.execute(
sql`UPDATE catalog_items SET amount = 1 WHERE id IN (${sql.join(
badAmount.map((i) => i.id),
sql`, `,
)})`,
);
fixed += badAmount.length;
detail.push(`Set amount to 1 on ${badAmount.length} offer(s)`);
}
// limited_stack < limited_sells -> limited_stack = limited_sells
const limited = issues.filter((i) => i.type === "limited_stack_vs_sells");
if (limited.length > 0) {
await tx.execute(
sql`UPDATE catalog_items SET limited_stack = limited_sells WHERE id IN (${sql.join(
limited.map((i) => i.id),
sql`, `,
)}) AND limited_stack < limited_sells`,
);
fixed += limited.length;
detail.push(
`Raised limited stack to the sold count on ${limited.length} offer(s)`,
);
}
// duplicate sibling order -> re-number sequentially per page
const dupOrders = issues.filter((i) => i.type === "duplicate_order");
if (dupOrders.length > 0) {
const [allRows] = (await tx.execute(sql`
SELECT id, page_id AS pageId, order_number AS orderNumber
FROM catalog_items ORDER BY page_id, order_number, id
`)) as unknown as [
Array<{ id: number; pageId: number | string; orderNumber: number }>,
unknown,
];
let lastPage: number | null = null;
let next = 1;
for (const row of allRows) {
const page = Number(row.pageId);
if (page !== lastPage) {
lastPage = page;
next = 1;
}
const newOrder = next++;
if (newOrder !== Number(row.orderNumber)) {
await tx.execute(
sql`UPDATE catalog_items SET order_number = ${newOrder} WHERE id = ${Number(row.id)}`,
);
}
}
fixed += dupOrders.length;
detail.push(`Re-numbered sibling order numbers on affected pages`);
}
});
return { fixed, removed, detail };
}
/** Fix items_base.sprite_id -> id (only where furnidata agrees). */
export async function fixSpriteIds(): Promise<FixSpriteResult> {
return verifyAndFixSpriteIds();
+10
View File
@@ -48,6 +48,16 @@ describe("classifyCatalogItem", () => {
expect(item({ item_name: "post_it" })).toBe("sticky");
expect(item({ item_name: "postit" })).toBe("sticky");
});
it("classifies posters and currency furniture", () => {
expect(item({ item_name: "poster100" })).toBe("poster");
expect(item({ item_name: "poster5000" })).toBe("poster");
expect(item({ item_name: "funtastic_four_poster" })).toBe("poster");
expect(item({ item_name: "duckfountain" })).toBe("poster");
expect(item({ item_name: "Swimming" })).toBe("poster");
expect(item({ item_name: "CF_50000_Hucci" })).toBe("currency");
expect(item({ item_name: "DF_102_10_bluechip" })).toBe("currency");
expect(item({ item_name: "sofa" })).toBe("furni");
});
it("classifies everything else as furni", () => {
expect(item({})).toBe("furni");
});
+30
View File
@@ -10,6 +10,12 @@
* 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.
* - **poster** — poster furniture (`poster*` classnames, wall posters, novelty
* poster furni). Renders through the poster imager, so no `.nitro` / `_icon.png`
* assets are expected.
* - **currency** — credit/pixel furniture (`CF_*`, `DF_*`). Renders through the
* currency imager (credits coin, badge-format exchange stickers), so no
* `.nitro` / `_icon.png` assets are expected.
* - **furni** — everything else; must ship a `.nitro` and `_icon.png`.
*/
@@ -19,6 +25,8 @@ export type CatalogItemKind =
| "effect"
| "bot"
| "sticky"
| "poster"
| "currency"
| "furni";
export interface CatalogItemLike {
@@ -42,6 +50,24 @@ export function isPetInteraction(interactionType?: string | null): boolean {
return /^pet\d+$/i.test((interactionType ?? "").trim());
}
/** Poster furniture classnames that have no `.nitro` asset expectations. */
export function isPosterClassname(itemName: string): boolean {
const name = itemName.trim();
if (/^poster\d+$/i.test(name)) return true;
const POSTER_SPECIAL = new Set([
"funtastic_four_poster",
"duckfountain",
"duckfountain2",
"swimming",
]);
return POSTER_SPECIAL.has(name.toLowerCase());
}
/** Credit/pixel furniture (`CF_*`, `DF_*`) ships no `.nitro` assets. */
export function isCurrencyClassname(itemName: string): boolean {
return /^(?:cf|df)_/i.test(itemName.trim());
}
/** Classify an `items_base` row into a catalog item kind. */
export function classifyCatalogItem(item: CatalogItemLike): CatalogItemKind {
const name = item.item_name.trim();
@@ -52,6 +78,8 @@ export function classifyCatalogItem(item: CatalogItemLike): CatalogItemKind {
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";
if (isPosterClassname(name)) return "poster";
if (isCurrencyClassname(name)) return "currency";
return "furni";
}
@@ -62,6 +90,8 @@ export const NO_ASSET_KINDS: ReadonlySet<CatalogItemKind> = new Set([
"effect",
"bot",
"sticky",
"poster",
"currency",
]);
/** Kinds that are not purchasable furniture and should not get catalog entries. */