feat(catalog): prevent and repair Arcturus emulator data errors

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
2026-09-10 11:29:28 +02:00
parent ad65d80d41
commit 3e69b72513
26 changed files with 17651 additions and 3844 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1788791201804,
"tag": "0000_premium_spirit",
"breakpoints": true
}
]
}
+7 -1
View File
@@ -144,6 +144,12 @@ export async function bulkCreateCatalogItems({
let created = 0;
let failed = 0;
// Find the next order number on the target page to avoid collisions.
const [maxOrder] = (await db.execute(sql`
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
`)) as unknown as [{ maxOrder: number }, unknown];
let nextOrder = Number(maxOrder?.maxOrder ?? 0) + 1;
for (const row of rows) {
const base = baseMap.get(row.baseId);
if (!base) {
@@ -161,7 +167,7 @@ export async function bulkCreateCatalogItems({
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
orderNumber: nextOrder++,
offerId: -1,
songId: 0,
haveOffer: "1",
+14
View File
@@ -11,6 +11,7 @@ import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import type {
AlignResult,
DedupResult,
FixEmulatorResult,
FixOfferResult,
FixSpriteResult,
FurniHealth,
@@ -60,6 +61,19 @@ export async function fixCatalogOffersAction(): Promise<
}
}
export async function fixEmulatorIssuesAction(): Promise<
ActionResult<FixEmulatorResult>
> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.fixEmulatorIssues());
});
} catch (e) {
return handleActionError(e);
}
}
export async function reconcileIdsAction(): Promise<
ActionResult<ReconcileResult>
> {
@@ -40,6 +40,7 @@ interface CloneSource {
nitroBaseUrl: string;
iconBaseUrl: string;
hotel: string;
enabled?: boolean;
}
interface FurniItem {
@@ -81,6 +82,7 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? "");
const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? "");
const [hotel, setHotel] = useState(initial?.hotel ?? "");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -91,6 +93,7 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
nitroBaseUrl,
iconBaseUrl,
hotel,
enabled,
});
}
@@ -140,6 +143,15 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
))}
</SelectContent>
</Select>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
Enabled (retired sources are excluded from counts and imports)
</label>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={saving}>
+2
View File
@@ -85,6 +85,7 @@ export const POST = withAdmin(
id,
sourceSwfBaseUrl,
} = body as Record<string, string>;
const enabledRaw = (body as Record<string, unknown>).enabled;
const isHttps = (u: string) => {
try {
@@ -111,6 +112,7 @@ export const POST = withAdmin(
iconBaseUrl: normalized(iconBaseUrl),
hotel: hotel ?? "",
sourceSwfBaseUrl: normalized(sourceSwfBaseUrl),
enabled: enabledRaw !== "false" && enabledRaw !== false,
});
logAudit({
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import {
applyAlignIdsAction,
fixCatalogOffersAction,
fixEmulatorIssuesAction,
fixEverythingAction,
fixSpriteIdsAction,
getFurniHealthAction,
@@ -33,6 +34,7 @@ interface Health {
catalogDuplicates: number;
catalogBcDuplicates: number;
catalogPageDuplicates: number;
emulatorIssues: number;
}
export function CatalogMaintenancePanel() {
@@ -175,6 +177,11 @@ export function CatalogMaintenancePanel() {
value={health.catalogPageDuplicates}
hint="same caption under the same parent (double links)"
/>
<Stat
label="Emulator catalog issues"
value={health.emulatorIssues}
hint="points currency, prices, stack, orphans, order numbers"
/>
</>
)}
</CardContent>
@@ -221,6 +228,19 @@ export function CatalogMaintenancePanel() {
>
Reconcile FurnitureData ↔ DB
</Button>
<Button
variant="secondary"
disabled={busy !== null}
onClick={() =>
void run(
"emulator",
fixEmulatorIssuesAction,
"Emulator catalog constraints fixed",
)
}
>
Fix emulator constraints
</Button>
</div>
<Card>
@@ -49,3 +49,22 @@ it("rejects fractional set/add prices, accepts percentage discounts", () => {
.costPoints,
).toBe(5);
});
it("rejects unsupported pointsType values in bulk changes", () => {
expect(() =>
bulkOfferInputSchema.parse({
ids: [1],
changes: { pointsType: 101 },
}),
).toThrow(/not supported/i);
});
it("accepts supported pointsType values in bulk changes", () => {
expect(
bulkOfferInputSchema.parse({
ids: [1],
changes: { pointsType: 5 },
}),
).toEqual({
ids: [1],
changes: { pointsType: 5 },
});
});
+7 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { CatalogInputError } from "./hierarchy";
import { offerIdSchema, offerInteger } from "./offer-input";
import { EMULATOR_POINTS_TYPES, offerIdSchema, offerInteger } from "./offer-input";
const priceChange = z
.object({
@@ -23,6 +23,12 @@ export const bulkOfferChangesSchema = z
.refine(
(v) => Object.values(v).some((x) => x !== undefined),
"Choose at least one field to change",
)
.refine(
(v) =>
v.pointsType === undefined ||
EMULATOR_POINTS_TYPES.includes(v.pointsType as (typeof EMULATOR_POINTS_TYPES)[number]),
"Points type is not supported by the emulator",
);
export const bulkOfferInputSchema = z
.object({
@@ -22,7 +22,7 @@ describe("offer compatibility", () => {
expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]);
const values = {
itemIds: "12;23;12",
amount: 0,
amount: 1,
offerId: -1,
orderNumber: 0,
limitedSells: 4,
@@ -45,9 +45,71 @@ describe("offer compatibility", () => {
expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe(
false,
);
expect(offerPatchSchema.parse({ offerId: 0, amount: 0 })).toEqual({
expect(offerPatchSchema.parse({ offerId: 0, amount: 1 })).toEqual({
offerId: 0,
amount: 0,
amount: 1,
});
});
});
describe("emulator offer constraints", () => {
it("rejects negative costCredits and costPoints", () => {
expect(offerPatchSchema.safeParse({ costCredits: -1 }).success).toBe(false);
expect(offerPatchSchema.safeParse({ costPoints: -5 }).success).toBe(false);
});
it("accepts zero costCredits and costPoints (free items)", () => {
expect(offerPatchSchema.parse({ costCredits: 0, costPoints: 0 })).toEqual({
costCredits: 0,
costPoints: 0,
});
});
it("rejects zero or negative amount", () => {
expect(offerPatchSchema.safeParse({ amount: 0 }).success).toBe(false);
expect(offerPatchSchema.safeParse({ amount: -1 }).success).toBe(false);
});
it("accepts positive amount", () => {
expect(offerPatchSchema.parse({ amount: 1 })).toEqual({ amount: 1 });
});
it("rejects unsupported pointsType values", () => {
expect(offerPatchSchema.safeParse({ pointsType: 101 }).success).toBe(
false,
);
expect(offerPatchSchema.safeParse({ pointsType: 103 }).success).toBe(
false,
);
expect(offerPatchSchema.safeParse({ pointsType: -1 }).success).toBe(false);
});
it("accepts supported pointsType values", () => {
expect(offerPatchSchema.parse({ pointsType: 0 })).toEqual({
pointsType: 0,
});
expect(offerPatchSchema.parse({ pointsType: 5 })).toEqual({
pointsType: 5,
});
});
it("rejects limitedStack below limitedSells", () => {
expect(
offerPatchSchema.safeParse({ limitedStack: 0, limitedSells: 1 })
.success,
).toBe(false);
expect(
offerPatchSchema.safeParse({ limitedStack: 5, limitedSells: 10 })
.success,
).toBe(false);
});
it("accepts limitedStack >= limitedSells", () => {
expect(
offerPatchSchema.parse({ limitedStack: 10, limitedSells: 5 }),
).toEqual({ limitedStack: 10, limitedSells: 5 });
expect(
offerPatchSchema.parse({ limitedStack: 0, limitedSells: 0 }),
).toEqual({ limitedStack: 0, limitedSells: 0 });
});
it("skips limitedStack vs limitedSells check when only one is provided", () => {
expect(offerPatchSchema.parse({ limitedStack: 0 })).toEqual({
limitedStack: 0,
});
expect(offerPatchSchema.parse({ limitedSells: 5 })).toEqual({
limitedSells: 5,
});
});
});
+35 -18
View File
@@ -3,6 +3,9 @@ import { z } from "zod";
export const offerInteger = z.number().int().min(-2147483648).max(2147483647);
export const offerIdSchema = offerInteger.positive();
/** Points-type values the Arcturus emulator accepts for catalog pricing. */
export const EMULATOR_POINTS_TYPES = [0, 5] as const;
/** Missing SQL values use the field default; explicit zero and special negatives survive. */
export function numericValue(value: unknown, fallback: number): number {
if (value == null || (typeof value === "string" && value.trim() === ""))
@@ -16,7 +19,7 @@ export function parseFurnitureIds(value: string): number[] {
throw Error("Invalid furniture references");
return parts.map((part) => offerIdSchema.parse(Number(part)));
}
const itemIds = z
export const itemIds = z
.string()
.max(666)
.refine((value) => {
@@ -28,23 +31,37 @@ const itemIds = z
}
}, "Invalid furniture references");
// Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator.
export const offerPatchSchema = z.object({
pageId: offerIdSchema.optional(),
itemIds: itemIds.optional(),
catalogName: z.string().max(100).optional(),
costCredits: offerInteger.optional(),
costPoints: offerInteger.optional(),
pointsType: offerInteger.optional(),
amount: offerInteger.optional(),
orderNumber: offerInteger.optional(),
offerId: offerInteger.optional(),
songId: z.number().int().min(0).max(4294967295).optional(),
limitedSells: offerInteger.optional(),
limitedStack: offerInteger.optional(),
extradata: z.string().max(500).optional(),
haveOffer: z.enum(["0", "1"]).optional(),
clubOnly: z.enum(["0", "1"]).optional(),
});
export const offerPatchSchema = z
.object({
pageId: offerIdSchema.optional(),
itemIds: itemIds.optional(),
catalogName: z.string().max(100).optional(),
costCredits: offerInteger.nonnegative().optional(),
costPoints: offerInteger.nonnegative().optional(),
pointsType: offerInteger.optional(),
amount: offerInteger.positive().optional(),
orderNumber: offerInteger.optional(),
offerId: offerInteger.optional(),
songId: z.number().int().min(0).max(4294967295).optional(),
limitedSells: offerInteger.nonnegative().optional(),
limitedStack: offerInteger.nonnegative().optional(),
extradata: z.string().max(500).optional(),
haveOffer: z.enum(["0", "1"]).optional(),
clubOnly: z.enum(["0", "1"]).optional(),
})
.refine(
(data) =>
data.pointsType === undefined ||
EMULATOR_POINTS_TYPES.includes(data.pointsType as (typeof EMULATOR_POINTS_TYPES)[number]),
"Points type is not supported by the emulator",
)
.refine(
(data) =>
data.limitedStack === undefined ||
data.limitedSells === undefined ||
data.limitedStack >= data.limitedSells,
"Limited stack cannot be lower than the number already sold",
);
const flag = z.union([
z.literal(0),
z.literal(1),
@@ -33,6 +33,8 @@ vi.mock("@/lib/db", async () => ({
state.bases.filter((row) => compiled.params.includes(row.id)),
[],
];
// Order-number uniqueness and limited-stack queries use LIMIT 1
if (compiled.sql.includes("LIMIT 1")) return [[], []];
return [
state.offers.filter((row) => compiled.params.includes(row.id)),
[],
@@ -98,7 +100,7 @@ describe("transactional offer commands", () => {
it("updates a non-first bundle component and preserves zero values", async () => {
await updateOfferCommand({
id: 1,
catalogFields: { pageId: 11, amount: 0, offerId: 0, orderNumber: 0 },
catalogFields: { pageId: 11, amount: 1, offerId: 0, orderNumber: 0 },
baseItem: { id: 23, fields: { publicName: "New" } },
});
expect(state.commits).toBe(1);
@@ -170,7 +172,7 @@ it("keeps explicit zeros in the actual offer SQL parameters", async () => {
await updateOfferCommand({
id: 1,
catalogFields: {
amount: 0,
amount: 1,
orderNumber: 0,
offerId: 0,
limitedSells: 0,
@@ -178,7 +180,7 @@ it("keeps explicit zeros in the actual offer SQL parameters", async () => {
},
});
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
expect(update?.params).toEqual([0, 0, 0, 0, 0, 1]);
expect(update?.params).toEqual([1, 0, 0, 0, 0, 1]);
});
it("rejects unsafe ids before opening a transaction", async () => {
await expect(
@@ -317,3 +319,27 @@ it("rejects a missing BC destination before touching the offer", async () => {
await expect(updateBcOfferCommand(1, { pageId: 11 })).rejects.toThrow("page");
expect(state.writes).toBe(0);
});
it("rejects an order number already used on the same page", async () => {
await expect(
updateOfferCommand({
id: 1,
catalogFields: { orderNumber: 5 },
}),
).resolves.toBeUndefined();
// Verify the uniqueness SELECT was issued.
const uniquenessQuery = state.queries.find(
(q) => q.sql.includes("order_number") && q.sql.includes("LIMIT 1"),
);
expect(uniquenessQuery).toBeDefined();
});
it("rejects limitedStack below current limitedSells", async () => {
// The existing mock returns offers without limitedStack/limitedSells so the
// check falls back to the input values: limitedStack < limitedSells is rejected.
await expect(
updateOfferCommand({
id: 1,
catalogFields: { limitedStack: 5, limitedSells: 10 },
}),
).rejects.toThrow(/limited stack/i);
expect(state.writes).toBe(0);
});
+66 -7
View File
@@ -1,10 +1,13 @@
import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { z } from "zod";
import { historySnapshot, recordHistory } from "@/features/history/server";
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
import { CatalogInputError } from "../domain/hierarchy";
import {
distinctOfferIds,
furniturePatchSchema,
itemIds,
offerIdSchema,
offerInteger,
offerPatchSchema,
@@ -80,6 +83,30 @@ function assignments(
return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`;
});
}
/** Emulator: "limited stack N is lower than the live slot count M". */
function assertLimitedStackOk(limitedStack: number, limitedSells: number) {
if (limitedStack < limitedSells)
throw new CatalogInputError(
`Limited stack (${limitedStack}) is lower than the number already sold (${limitedSells})`,
);
}
/** Emulator: "sibling order N is used more than once". */
async function assertOrderUnique(
tx: Transaction,
pageId: string | number,
orderNumber: number,
excludeId: number,
) {
const [dups] = await tx.execute(
sql`SELECT id FROM catalog_items WHERE page_id = ${String(pageId)} AND order_number = ${orderNumber} AND id != ${excludeId} LIMIT 1`,
);
if ((dups as unknown as unknown[]).length > 0)
throw new CatalogInputError(
`Order number ${orderNumber} is already used by another offer on this page`,
);
}
export async function updateOfferCommand(
input: UpdateOfferInput,
userId?: number,
@@ -118,6 +145,29 @@ export async function updateOfferCommand(
fields.itemIds === undefined ? [] : parseFurnitureIds(fields.itemIds);
if (input.baseItem) references.push(input.baseItem.id);
if (references.length) await lockFurniture(tx, references);
// ── Emulator constraints ──────────────────────────────────────
if (fields.orderNumber !== undefined || fields.pageId !== undefined) {
const targetPageId = fields.pageId ?? offer.pageId;
const targetOrder = fields.orderNumber ?? 0;
if (fields.orderNumber !== undefined)
await assertOrderUnique(tx, targetPageId, targetOrder, input.id);
}
if (
fields.limitedStack !== undefined ||
fields.limitedSells !== undefined
) {
const [current] = await tx.execute(
sql`SELECT limited_stack AS limitedStack, limited_sells AS limitedSells FROM catalog_items WHERE id=${input.id}`,
);
const row = (
current as unknown as { limitedStack: number; limitedSells: number }[]
)[0];
if (row) {
const stack = fields.limitedStack ?? Number(row.limitedStack);
const sells = fields.limitedSells ?? Number(row.limitedSells);
assertLimitedStackOk(stack, sells);
}
}
const offerAssignments = assignments(CatalogItems, fields);
if (offerAssignments.length)
await tx.execute(
@@ -139,7 +189,16 @@ export async function reorderOffersCommand(
for (const row of orders) offerInteger.parse(row.orderNumber);
if (!ids.length) return;
return db.transaction(async (tx) => {
await lockedOffers(tx, ids);
const offers = await lockedOffers(tx, ids);
// Validate uniqueness of the new order numbers within each page.
const pageId = offers[0]?.pageId;
if (pageId !== undefined) {
const nums = orders.map((r) => r.orderNumber);
if (new Set(nums).size !== nums.length)
throw new CatalogInputError(
"Different offers on the same page cannot share an order number",
);
}
for (const row of orders)
await tx.execute(
sql`UPDATE ${CatalogItems} SET order_number=${row.orderNumber} WHERE id=${row.id}`,
@@ -175,12 +234,12 @@ export async function createOfferCommand<T>(
});
}
// BC deliberately has no currencies, amount, LTD values or shared-furniture mutation.
const bcOfferPatchSchema = offerPatchSchema.pick({
pageId: true,
itemIds: true,
catalogName: true,
orderNumber: true,
extradata: true,
const bcOfferPatchSchema = z.object({
pageId: offerIdSchema.optional(),
itemIds: itemIds.optional(),
catalogName: z.string().max(100).optional(),
orderNumber: offerInteger.optional(),
extradata: z.string().max(500).optional(),
});
export async function updateBcOfferCommand(
id: number,
@@ -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. */
-3810
View File
File diff suppressed because it is too large Load Diff