From 984b5bf7938a3e267ee1e6702f6f4c115b3ffae9 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 21 Aug 2026 14:02:42 +0200 Subject: [PATCH] feat: auto-repair sprite_id drift after every import The emulator sends items_base.sprite_id to the client and Nitro resolves the furniture by looking up that id in FurnitureData.json. Legacy rows where sprite_id drifted from the id made the client render a completely different item (e.g. a wired or custom asset instead of the purchased furniture). Add verifyAndFixSpriteIds() which repairs any row whose sprite_id no longer matches its id while the local furnidata carries the item under that id, and run it after every import path (single, batch, batch-regen, clone) next to the offer_id rebuild. --- src/app/api/admin/import/clone/batch/route.ts | 2 + .../admin/import/furni/batch-regen/route.ts | 6 ++ src/app/api/admin/import/furni/batch/route.ts | 4 ++ src/app/api/admin/import/furni/route.ts | 14 +++++ src/lib/services/furni-data.ts | 58 +++++++++++++++++++ 5 files changed, 84 insertions(+) diff --git a/src/app/api/admin/import/clone/batch/route.ts b/src/app/api/admin/import/clone/batch/route.ts index 68b4aa4238..6c5aaedd79 100644 --- a/src/app/api/admin/import/clone/batch/route.ts +++ b/src/app/api/admin/import/clone/batch/route.ts @@ -12,6 +12,7 @@ import { appendFurniEntry, rebuildCatalogOfferIds, reconcileFurniDataWithItemsBase, + verifyAndFixSpriteIds, } from "@/lib/services/furni-data"; import { ensureFurniOwnership, @@ -144,6 +145,7 @@ export const POST = withAdmin( await reconcileImportedOfferIds(); await reconcileFurniDataWithItemsBase(); await rebuildCatalogOfferIds(); + await verifyAndFixSpriteIds(); await verifyAndFixInteractionModesCount(); await ensureFurniOwnership(); await syncAssetsToGamedataBundle(); 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 66e7c71eab..e68cbddf6a 100644 --- a/src/app/api/admin/import/furni/batch-regen/route.ts +++ b/src/app/api/admin/import/furni/batch-regen/route.ts @@ -12,6 +12,7 @@ import { readFurniData, rebuildCatalogOfferIds, reconcileFurniDataWithItemsBase, + verifyAndFixSpriteIds, } from "@/lib/services/furni-data"; import { downloadFile, @@ -273,6 +274,9 @@ export const POST = withAdmin( // Rebuild every catalog offer_id from the furnidata const offerRebuild = await rebuildCatalogOfferIds(); + // Repair any sprite_id drift so the client renders the right item + const spriteVerify = await verifyAndFixSpriteIds(); + send({ type: "regen_complete", succeeded, @@ -287,6 +291,8 @@ export const POST = withAdmin( furniDataConflicts: furniReconcile.conflicts, offerRebuildChecked: offerRebuild.checked, offerRebuildFixed: offerRebuild.fixed, + spriteChecked: spriteVerify.checked, + spriteFixed: spriteVerify.fixed, 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 bb33540512..179d762c5d 100644 --- a/src/app/api/admin/import/furni/batch/route.ts +++ b/src/app/api/admin/import/furni/batch/route.ts @@ -7,6 +7,7 @@ import { appendFurniEntriesBatch, rebuildCatalogOfferIds, reconcileFurniDataWithItemsBase, + verifyAndFixSpriteIds, } from "@/lib/services/furni-data"; import { ensureDirectories, @@ -229,6 +230,7 @@ export const POST = withAdmin( let furniDataMissing = 0; let furniDataConflicts = 0; let offerRebuildFixed = 0; + let spriteFixed = 0; let interactionFixed = 0; try { offerIdsFixed = (await reconcileImportedOfferIds()).fixed; @@ -242,6 +244,7 @@ export const POST = withAdmin( furniDataMissing = furniReconcile.missing; furniDataConflicts = furniReconcile.conflicts; offerRebuildFixed = (await rebuildCatalogOfferIds()).fixed; + spriteFixed = (await verifyAndFixSpriteIds()).fixed; interactionFixed = (await verifyAndFixInteractionModesCount()).fixed; } catch (err) { console.warn( @@ -264,6 +267,7 @@ export const POST = withAdmin( furniDataMissing, furniDataConflicts, offerRebuildFixed, + spriteFixed, interactionFixed, 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 5845656951..a2007949ff 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -12,6 +12,7 @@ import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { rebuildCatalogOfferIds, reconcileFurniDataWithItemsBase, + verifyAndFixSpriteIds, } from "@/lib/services/furni-data"; import { autoPriceFurni, @@ -175,6 +176,7 @@ export const GET = withAdmin( if (action === "live-reconcile") { const furniReconcile = await reconcileFurniDataWithItemsBase(); const offerRebuild = await rebuildCatalogOfferIds(); + const spriteVerify = await verifyAndFixSpriteIds(); return apiOk({ furniDataFixedIds: furniReconcile.fixedIds, furniDataFixedOfferIds: furniReconcile.fixedOfferIds, @@ -182,6 +184,8 @@ export const GET = withAdmin( furniDataConflicts: furniReconcile.conflicts, offerRebuildChecked: offerRebuild.checked, offerRebuildFixed: offerRebuild.fixed, + spriteChecked: spriteVerify.checked, + spriteFixed: spriteVerify.fixed, }); } @@ -461,6 +465,9 @@ export const POST = withAdmin( // Rebuild every catalog offer_id from the furnidata const offerRebuild = await rebuildCatalogOfferIds(); + // Repair any sprite_id drift so the client renders the right item + const spriteVerify = await verifyAndFixSpriteIds(); + // Verify + fix interaction_modes_count once for the whole batch const verification = await verifyAndFixInteractionModesCount(); @@ -482,6 +489,8 @@ export const POST = withAdmin( furniDataConflicts: furniReconcile.conflicts, offerRebuildChecked: offerRebuild.checked, offerRebuildFixed: offerRebuild.fixed, + spriteChecked: spriteVerify.checked, + spriteFixed: spriteVerify.fixed, interactionFixed: verification.fixed, }); } @@ -547,6 +556,9 @@ export const POST = withAdmin( // Rebuild every catalog offer_id from the furnidata const offerRebuild = await rebuildCatalogOfferIds(); + // Repair any sprite_id drift so the client renders the right item + const spriteVerify = await verifyAndFixSpriteIds(); + // Verify + fix interaction_modes_count (single consolidated pass) const verification = await verifyAndFixInteractionModesCount(); @@ -580,6 +592,8 @@ export const POST = withAdmin( furniDataConflicts: furniReconcile.conflicts, offerRebuildChecked: offerRebuild.checked, offerRebuildFixed: offerRebuild.fixed, + spriteChecked: spriteVerify.checked, + spriteFixed: spriteVerify.fixed, interactionFixed: verification.fixed, spriteCount: result.spriteCount, warnings: result.warnings, diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index 670f280a4c..8106b48149 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -656,3 +656,61 @@ export async function rebuildCatalogOfferIds(): Promise { + return withFurniDataLock(async () => { + const furniData = (await readFurniData()) as Record< + string, + { furnitype?: Array> } + >; + const classById = new Map>(); + for (const section of ["roomitemtypes", "wallitemtypes"] as const) { + for (const e of furniData[section]?.furnitype ?? []) { + const entryId = Number(e?.id); + const cn = typeof e?.classname === "string" ? e.classname : ""; + if (!Number.isFinite(entryId) || !cn) continue; + const set = classById.get(entryId) ?? new Set(); + set.add(cn); + classById.set(entryId, set); + } + } + + const rows = await db + .select({ + id: ItemsBase.id, + spriteId: ItemsBase.spriteId, + itemName: ItemsBase.itemName, + }) + .from(ItemsBase); + + let fixed = 0; + for (const row of rows) { + if (row.spriteId === row.id) continue; + const entries = classById.get(row.id); + if (!entries?.has(row.itemName)) continue; + await db + .update(ItemsBase) + .set({ spriteId: row.id }) + .where(eq(ItemsBase.id, row.id)); + fixed++; + } + return { checked: rows.length, fixed }; + }); +}