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:
1 parent
ad65d80d41
commit
3e69b72513
26 files changed
+17651
-3844
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user