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

+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();