From b4968a9967be7d0c91ba821a3461c7d3dc306651 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 18 Aug 2026 20:43:41 +0200 Subject: [PATCH] Reconcile FurnitureData.json with items_base after import After every single import, batch import and batch-regen, cross-check all items_base rows against the local FurnitureData.json: entries whose spriteId drifted are corrected to the DB id, and missing entries plus real id conflicts are reported in the API/SSE response. Entries that are missing entirely are counted (rebuilding them needs SWF/nitro metadata). --- .../admin/import/furni/batch-regen/route.ts | 7 ++ src/app/api/admin/import/furni/batch/route.ts | 15 +++- src/app/api/admin/import/furni/route.ts | 13 +++ src/lib/services/furni-data.ts | 84 +++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/app/api/admin/import/furni/batch-regen/route.ts b/src/app/api/admin/import/furni/batch-regen/route.ts index 9b1252c3..8fc5d2cb 100644 --- a/src/app/api/admin/import/furni/batch-regen/route.ts +++ b/src/app/api/admin/import/furni/batch-regen/route.ts @@ -10,6 +10,7 @@ import { appendFurniEntriesBatch, buildFurniEntry, readFurniData, + reconcileFurniDataWithItemsBase, } from "@/lib/services/furni-data"; import { downloadFile, @@ -265,6 +266,9 @@ export const POST = withAdmin( // Backfill any missing nitros and icons into the emulator's Gamedata bundle const assetsSynced = await syncAssetsToGamedataBundle(); + // Verify the furnidata matches the DB ids + const furniReconcile = await reconcileFurniDataWithItemsBase(); + send({ type: "regen_complete", succeeded, @@ -274,6 +278,9 @@ export const POST = withAdmin( ownershipFixed: ownership.fixed, nitrosSynced: assetsSynced.copiedNitros, iconsSynced: assetsSynced.copiedIcons, + furniDataFixedIds: furniReconcile.fixedIds, + furniDataMissing: furniReconcile.missing, + furniDataConflicts: furniReconcile.conflicts, duration: Date.now() - startTime, }); diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts index cdcf835d..843e0399 100644 --- a/src/app/api/admin/import/furni/batch/route.ts +++ b/src/app/api/admin/import/furni/batch/route.ts @@ -3,7 +3,10 @@ import { apiError } from "@/lib/api-response"; import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; import { getSource } from "@/lib/services/clone-sources"; -import { appendFurniEntriesBatch } from "@/lib/services/furni-data"; +import { + appendFurniEntriesBatch, + reconcileFurniDataWithItemsBase, +} from "@/lib/services/furni-data"; import { ensureDirectories, ensureFurniOwnership, @@ -219,12 +222,19 @@ export const POST = withAdmin( let ownershipFixed: string[] = []; let nitrosSynced: string[] = []; let iconsSynced: string[] = []; + let furniDataFixedIds = 0; + let furniDataMissing = 0; + let furniDataConflicts = 0; try { offerIdsFixed = (await reconcileImportedOfferIds()).fixed; ownershipFixed = (await ensureFurniOwnership()).fixed; const assetsSynced = await syncAssetsToGamedataBundle(); nitrosSynced = assetsSynced.copiedNitros; iconsSynced = assetsSynced.copiedIcons; + const furniReconcile = await reconcileFurniDataWithItemsBase(); + furniDataFixedIds = furniReconcile.fixedIds; + furniDataMissing = furniReconcile.missing; + furniDataConflicts = furniReconcile.conflicts; } catch (err) { console.warn( "[import-furni] Post-import reconcile/ownership failed:", @@ -241,6 +251,9 @@ export const POST = withAdmin( ownershipFixed, nitrosSynced, iconsSynced, + furniDataFixedIds, + furniDataMissing, + furniDataConflicts, duration: Date.now() - startTime, }); diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index e8363fa7..63eec226 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -9,6 +9,7 @@ import { logAudit } from "@/lib/services/audit"; import { getCloneList } from "@/lib/services/clone-import"; import { getSource } from "@/lib/services/clone-sources"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; +import { reconcileFurniDataWithItemsBase } from "@/lib/services/furni-data"; import { autoPriceFurni, CATEGORY_PAGE, @@ -415,6 +416,9 @@ export const POST = withAdmin( // Backfill any missing nitros into the emulator's Gamedata bundle const assetsSynced = await syncAssetsToGamedataBundle(); + // Verify the furnidata matches the DB ids + const furniReconcile = await reconcileFurniDataWithItemsBase(); + const succeeded = results.filter((r) => r.ok).length; const failed = results.filter((r) => !r.ok).length; @@ -427,6 +431,9 @@ export const POST = withAdmin( ownershipFixed: ownership.fixed, nitrosSynced: assetsSynced.copiedNitros, iconsSynced: assetsSynced.copiedIcons, + furniDataFixedIds: furniReconcile.fixedIds, + furniDataMissing: furniReconcile.missing, + furniDataConflicts: furniReconcile.conflicts, }); } @@ -485,6 +492,9 @@ export const POST = withAdmin( // Backfill any missing nitros and icons into the emulator's Gamedata bundle const assetsSynced = await syncAssetsToGamedataBundle(); + // Verify the furnidata matches the DB ids + const furniReconcile = await reconcileFurniDataWithItemsBase(); + logAudit({ userId: ctx.session.user.id, action: "furni_import", @@ -509,6 +519,9 @@ export const POST = withAdmin( ownershipFixed: ownership.fixed, nitrosSynced: assetsSynced.copiedNitros, iconsSynced: assetsSynced.copiedIcons, + furniDataFixedIds: furniReconcile.fixedIds, + furniDataMissing: furniReconcile.missing, + furniDataConflicts: furniReconcile.conflicts, spriteCount: result.spriteCount, warnings: result.warnings, }); diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index c06643f0..727e8c8f 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -1,5 +1,6 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; +import { db, ItemsBase } from "@/lib/db"; import { getGamedataRoot, getNitroFilesRoot, @@ -442,3 +443,86 @@ export async function appendFurniEntriesBatch( await writeFurniData(furniData as Record); }); } + +export interface FurniDataReconcileResult { + /** Entries whose stale spriteId was corrected to the DB id. */ + fixedIds: number; + /** items_base rows with no matching furnitype entry (need re-import). */ + missing: number; + /** spriteIds used by a different classname in the file; left untouched. */ + conflicts: number; +} + +/** + * Cross-check every items_base row against the local FurnitureData.json and + * repair drift: + * - an entry matching the classname but carrying a stale spriteId is updated + * to the DB id (items_base is authoritative); + * - rows missing entirely from the file are counted (they need a re-import + * or batch-regen, since building an entry requires the SWF/nitro metadata); + * - genuine spriteId conflicts (id used by a different classname) are counted + * but never auto-fixed. + */ +export async function reconcileFurniDataWithItemsBase(): Promise { + return withFurniDataLock(async () => { + const rows = await db + .select({ id: ItemsBase.id, itemName: ItemsBase.itemName }) + .from(ItemsBase); + + const furniData = (await readFurniData()) as Record< + string, + { furnitype: Array> } + >; + + const byId = new Map< + number, + { section: string; index: number; entry: Record } + >(); + const byClass = new Map< + string, + { section: string; index: number; entry: Record } + >(); + for (const section of ["roomitemtypes", "wallitemtypes"] as const) { + const arr = furniData[section]?.furnitype ?? []; + for (let i = 0; i < arr.length; i++) { + const e = arr[i]; + const id = Number(e?.id); + if (Number.isFinite(id) && id > 0 && !byId.has(id)) { + byId.set(id, { section, index: i, entry: e }); + } + const cn = typeof e?.classname === "string" ? e.classname : ""; + if (cn && !byClass.has(cn)) { + byClass.set(cn, { section, index: i, entry: e }); + } + } + } + + let fixedIds = 0; + let missing = 0; + let conflicts = 0; + + for (const row of rows) { + const spriteId = row.id; + const existing = byId.get(spriteId); + if (existing) { + if (existing.entry.classname !== row.itemName) { + conflicts++; + } + continue; + } + const byName = byClass.get(row.itemName); + if (byName) { + byName.entry.id = spriteId; + byId.set(spriteId, byName); + fixedIds++; + } else { + missing++; + } + } + + if (fixedIds > 0) { + await writeFurniData(furniData as Record); + } + return { fixedIds, missing, conflicts }; + }); +}