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.
This commit is contained in:
openhands committed 2026-08-21 14:02:42 +02:00
1 parent 92b0ba3b18
commit 984b5bf793
5 files changed
+84

No files matched your search

@@ -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();
@@ -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,
});
@@ -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,
});
+14
View File
@@ -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,
+58
View File
@@ -656,3 +656,61 @@ export async function rebuildCatalogOfferIds(): Promise<CatalogOfferRebuildResul
return { checked: catalogRows.length, fixed, fallback };
});
}
export interface SpriteIdVerifyResult {
/** items_base rows examined. */
checked: number;
/** Rows whose sprite_id was corrected to their id. */
fixed: number;
}
/**
* Verify that every items_base.sprite_id matches its id whenever the local
* FurnitureData.json carries the item under that id.
*
* The emulator sends sprite_id to the client and Nitro resolves the furniture
* by looking up that id in the furnidata — a stale sprite_id makes the client
* render a completely different item (e.g. a wired or a custom in place of a
* seaside sofa). Imports always write sprite_id = id, but legacy rows may
* drift; this pass repairs them after every import.
*/
export async function verifyAndFixSpriteIds(): Promise<SpriteIdVerifyResult> {
return withFurniDataLock(async () => {
const furniData = (await readFurniData()) as Record<
string,
{ furnitype?: Array<Record<string, unknown>> }
>;
const classById = new Map<number, Set<string>>();
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<string>();
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 };
});
}