diff --git a/src/app/api/admin/import/clone/batch/route.ts b/src/app/api/admin/import/clone/batch/route.ts index 85abca89..47b511fb 100644 --- a/src/app/api/admin/import/clone/batch/route.ts +++ b/src/app/api/admin/import/clone/batch/route.ts @@ -10,8 +10,16 @@ import { getSource } from "@/lib/services/clone-sources"; import { appendFurniEntriesBatch, appendFurniEntry, + reconcileFurniDataWithItemsBase, } from "@/lib/services/furni-data"; +import { + ensureFurniOwnership, + reconcileImportedOfferIds, + syncAssetsToGamedataBundle, + verifyAndFixInteractionModesCount, +} from "@/lib/services/furni-import"; import { runSseBatch } from "@/lib/services/import/core/sse-batch"; +import { rcon } from "@/lib/services/rcon"; interface BatchItem { classname: string; @@ -126,6 +134,22 @@ export const POST = withAdmin( `FurnitureData batch write failed: ${(err as Error).message}`, ); } + + // Post-import consolidation, mirroring the furni batch route: + // keep offer_id / FurnitureData offerid in sync with the new local + // sprite ids, verify interaction_modes_count, and refresh the + // emulator + asset ownership. Never fails the stream — best effort. + try { + await reconcileImportedOfferIds(); + await reconcileFurniDataWithItemsBase(); + await verifyAndFixInteractionModesCount(); + await ensureFurniOwnership(); + await syncAssetsToGamedataBundle(); + await rcon.updateCatalog(); + await rcon.updateItems(); + } catch { + /* best effort — post-import consolidation is non-fatal */ + } }, }); }, diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts index 843e0399..4836a135 100644 --- a/src/app/api/admin/import/furni/batch/route.ts +++ b/src/app/api/admin/import/furni/batch/route.ts @@ -13,6 +13,7 @@ import { importSingleFurni, reconcileImportedOfferIds, syncAssetsToGamedataBundle, + verifyAndFixInteractionModesCount, } from "@/lib/services/furni-import"; import { rcon } from "@/lib/services/rcon"; import type { ImportSingleResult } from "@/types/furni"; @@ -223,8 +224,10 @@ export const POST = withAdmin( let nitrosSynced: string[] = []; let iconsSynced: string[] = []; let furniDataFixedIds = 0; + let furniDataFixedOfferIds = 0; let furniDataMissing = 0; let furniDataConflicts = 0; + let interactionFixed = 0; try { offerIdsFixed = (await reconcileImportedOfferIds()).fixed; ownershipFixed = (await ensureFurniOwnership()).fixed; @@ -233,8 +236,10 @@ export const POST = withAdmin( iconsSynced = assetsSynced.copiedIcons; const furniReconcile = await reconcileFurniDataWithItemsBase(); furniDataFixedIds = furniReconcile.fixedIds; + furniDataFixedOfferIds = furniReconcile.fixedOfferIds; furniDataMissing = furniReconcile.missing; furniDataConflicts = furniReconcile.conflicts; + interactionFixed = (await verifyAndFixInteractionModesCount()).fixed; } catch (err) { console.warn( "[import-furni] Post-import reconcile/ownership failed:", @@ -252,8 +257,10 @@ export const POST = withAdmin( nitrosSynced, iconsSynced, furniDataFixedIds, + furniDataFixedOfferIds, furniDataMissing, furniDataConflicts, + 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 63eec226..1d2b6596 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -24,6 +24,7 @@ import { reconcileImportedOfferIds, resetCatalogPageCache, syncAssetsToGamedataBundle, + verifyAndFixInteractionModesCount, } from "@/lib/services/furni-import"; import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache"; import { listFurniture } from "@/lib/services/habbofurni"; @@ -103,15 +104,14 @@ export const GET = withAdmin( // Stats endpoint const action = request.nextUrl.searchParams.get("action"); if (action === "stats") { - const [totalResult, catalogResult, nitroMissingResult] = + const [totalResult, catalogResult, allIdsResult, referencedResult] = await Promise.all([ db.execute(sql`SELECT COUNT(*) as cnt FROM items_base`), db.execute( sql`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`, ), - db.execute( - sql`SELECT COUNT(*) as cnt FROM items_base WHERE item_name NOT IN (SELECT catalog_name FROM catalog_items)`, - ), + db.execute(sql`SELECT id FROM items_base`), + db.execute(sql`SELECT item_ids FROM catalog_items`), ]); const totalInDb = Number( @@ -120,15 +120,34 @@ export const GET = withAdmin( const inCatalog = Number( (catalogResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt, ); - const notInCatalog = Number( - (nitroMissingResult as unknown as [[{ cnt: bigint }], unknown])[0][0] - .cnt, - ); + + // An item is "in the catalog" when its items_base id is referenced by + // any catalog_items.item_ids (the canonical link — catalog_name can be + // a translated display name, so matching on it is unreliable). + const allIds = ( + allIdsResult as unknown as [Array<{ id: number }>, unknown] + )[0].map((r) => Number(r.id)); + const referenced = new Set(); + for (const row of ( + referencedResult as unknown as [ + Array<{ item_ids: string | null }>, + unknown, + ] + )[0]) { + for (const part of String(row.item_ids ?? "").split(/[;,]/)) { + const n = Number(part.trim()); + if (Number.isFinite(n) && n > 0) referenced.add(n); + } + } + const notInCatalog = allIds.filter((id) => !referenced.has(id)).length; // Count missing .nitro files by checking filesystem const [importedItems] = (await db.execute(sql` SELECT ib.item_name FROM items_base ib - INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name + WHERE EXISTS ( + SELECT 1 FROM catalog_items ci + WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 + ) LIMIT 10000 `)) as unknown as [Array<{ item_name: string }>, unknown]; @@ -159,7 +178,10 @@ export const GET = withAdmin( const [importedItems] = (await db.execute(sql` SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib - INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name + WHERE EXISTS ( + SELECT 1 FROM catalog_items ci + WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 + ) `)) as unknown as [ Array<{ item_name: string; @@ -419,6 +441,9 @@ export const POST = withAdmin( // Verify the furnidata matches the DB ids const furniReconcile = await reconcileFurniDataWithItemsBase(); + // Verify + fix interaction_modes_count once for the whole batch + const verification = await verifyAndFixInteractionModesCount(); + const succeeded = results.filter((r) => r.ok).length; const failed = results.filter((r) => !r.ok).length; @@ -432,8 +457,10 @@ export const POST = withAdmin( nitrosSynced: assetsSynced.copiedNitros, iconsSynced: assetsSynced.copiedIcons, furniDataFixedIds: furniReconcile.fixedIds, + furniDataFixedOfferIds: furniReconcile.fixedOfferIds, furniDataMissing: furniReconcile.missing, furniDataConflicts: furniReconcile.conflicts, + interactionFixed: verification.fixed, }); } @@ -495,6 +522,9 @@ export const POST = withAdmin( // Verify the furnidata matches the DB ids const furniReconcile = await reconcileFurniDataWithItemsBase(); + // Verify + fix interaction_modes_count (single consolidated pass) + const verification = await verifyAndFixInteractionModesCount(); + logAudit({ userId: ctx.session.user.id, action: "furni_import", @@ -520,8 +550,10 @@ export const POST = withAdmin( nitrosSynced: assetsSynced.copiedNitros, iconsSynced: assetsSynced.copiedIcons, furniDataFixedIds: furniReconcile.fixedIds, + furniDataFixedOfferIds: furniReconcile.fixedOfferIds, furniDataMissing: furniReconcile.missing, furniDataConflicts: furniReconcile.conflicts, + interactionFixed: verification.fixed, spriteCount: result.spriteCount, warnings: result.warnings, }); diff --git a/src/lib/furni/auto-catalog.ts b/src/lib/furni/auto-catalog.ts index 2dc08b55..de783c00 100644 --- a/src/lib/furni/auto-catalog.ts +++ b/src/lib/furni/auto-catalog.ts @@ -83,6 +83,9 @@ export const CLASSNAME_RULES: Array<{ /** Determine the catalog category for a furniture item based on its classname and type. */ export function classifyFurni(classname: string, itemType: string): string { + // Wall items are authoritative by type — a prefix rule (rare_, val_, xmas_, + // …) must never pull a wall item out of the "walls" category. + if (itemType === "wallitem" || itemType === "i") return "walls"; for (const rule of CLASSNAME_RULES) { if (rule.test(classname, itemType)) return rule.cat; } diff --git a/src/lib/services/catalog-repair.ts b/src/lib/services/catalog-repair.ts index 20c74d34..36934530 100644 --- a/src/lib/services/catalog-repair.ts +++ b/src/lib/services/catalog-repair.ts @@ -382,9 +382,12 @@ export async function generateCatalogSql( } } const price = autoPriceFurni(entry.classname); - const displayName = entry.publicName || entry.classname; + // catalog_name must match items_base.item_name (the classname), exactly + // like the app's own import pipeline, so the studio's in-catalog joins + // and the emulator's item lookup stay consistent. offer_id is the item id + // (sprite id), not a literal '-1'. statements.push( - `INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextId}, ${pageId}, ${entry.itemId}, '${escSqlLiteral(displayName)}', ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '');`, + `INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextId}, ${pageId}, ${entry.itemId}, '${escSqlLiteral(entry.classname)}', ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${entry.itemId}, '');`, ); nextId++; generated++; diff --git a/src/lib/services/clone-import.ts b/src/lib/services/clone-import.ts index ea67bcab..89e89a19 100644 --- a/src/lib/services/clone-import.ts +++ b/src/lib/services/clone-import.ts @@ -383,6 +383,10 @@ export async function cloneSingleFurni(params: { animationStates: nitroAnimationStatesCount(clonedNitroJson), }); const interactionModesCount = autoInteraction.interactionModesCount; + // The auto-detected interaction type (wired/gate/teleport/…) is real data + // (source furnidata + cloned .nitro), never a keyword guess — apply it so + // wired and mechanical furniture actually work on the emulator. + const interactionType = autoInteraction.interactionType; let newId: number; try { @@ -393,7 +397,7 @@ export async function cloneSingleFurni(params: { allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count, customparams) VALUES (${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight}, - ${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${interactionModesCount}, ${entry.customparams})`); + ${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, ${interactionType}, ${interactionModesCount}, ${entry.customparams})`); return nextId; }); } catch (err) { @@ -412,9 +416,15 @@ export async function cloneSingleFurni(params: { }; } - // FurnitureData entry — reuse the source's furnitype object, with our id. + // FurnitureData entry — reuse the source's furnitype object, but with our id + // AND our offerid. The source's offerid points at the source hotel's sprite + // ids, which would silently break the emulator's catalog offer lookup. onProgress?.("writing_furnidata"); - const furniDataEntry = { ...entry, id: newId } as Record; + const furniDataEntry = { + ...entry, + id: newId, + offerid: newId, + } as Record; if (deferFurniData) { // Batch mode: the caller collects entries and appends them once at the end. } else { @@ -436,7 +446,7 @@ export async function cloneSingleFurni(params: { await allocateCatalogItemId(async (nextCatalogId) => { await db.execute(sql` INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) - VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`); + VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${newId}, '')`); return nextCatalogId; }); } catch (err) { diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index 727e8c8f..d8e45ea3 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -447,6 +447,8 @@ export async function appendFurniEntriesBatch( export interface FurniDataReconcileResult { /** Entries whose stale spriteId was corrected to the DB id. */ fixedIds: number; + /** Entries whose stale offerid was corrected to the DB id. */ + fixedOfferIds: 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. */ @@ -458,6 +460,8 @@ export interface FurniDataReconcileResult { * repair drift: * - an entry matching the classname but carrying a stale spriteId is updated * to the DB id (items_base is authoritative); + * - the same is done for a stale `offerid`, which must equal the spriteId so + * the emulator's catalog offer lookup stays in sync with the local ids; * - 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 @@ -498,21 +502,33 @@ export async function reconcileFurniDataWithItemsBase(): Promise): number => { + const v = Number(entry.offerid); + return Number.isFinite(v) ? v : -1; + }; + for (const row of rows) { const spriteId = row.id; const existing = byId.get(spriteId); if (existing) { if (existing.entry.classname !== row.itemName) { conflicts++; + } else if (entryOfferId(existing.entry) !== spriteId) { + existing.entry.offerid = spriteId; + fixedOfferIds++; } continue; } const byName = byClass.get(row.itemName); if (byName) { byName.entry.id = spriteId; + if (entryOfferId(byName.entry) !== spriteId) { + byName.entry.offerid = spriteId; + } byId.set(spriteId, byName); fixedIds++; } else { @@ -520,9 +536,9 @@ export async function reconcileFurniDataWithItemsBase(): Promise 0) { + if (fixedIds > 0 || fixedOfferIds > 0) { await writeFurniData(furniData as Record); } - return { fixedIds, missing, conflicts }; + return { fixedIds, fixedOfferIds, missing, conflicts }; }); } diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 66e9fbad..4e9af034 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -544,15 +544,22 @@ export async function importSingleFurni(params: { .from(ItemsBase) .where(eq(ItemsBase.itemName, classname)) .limit(1); - if (existsByName) { - if (!updateExisting) { - return { - ok: false, - warnings, - error: `Item "${classname}" already exists (id: ${existsByName.id})`, - }; - } - // Update existing item - fall through to update logic + if (existsByName && !updateExisting) { + return { + ok: false, + warnings, + error: `Item "${classname}" already exists (id: ${existsByName.id})`, + }; + } + if (existsByName && existsByName.id !== originalId) { + // The classname already occupies a different spriteId. Reassigning the + // id would orphan every reference (catalog item_ids, player items, + // furnidata), so this can never be an "update". + return { + ok: false, + warnings, + error: `Item "${classname}" already exists with id ${existsByName.id} — cannot reassign to spriteId ${originalId}`, + }; } const [existsById] = await db .select({ itemName: ItemsBase.itemName }) @@ -560,16 +567,17 @@ export async function importSingleFurni(params: { .where(eq(ItemsBase.id, originalId)) .limit(1); if (existsById && (!existsByName || existsById.itemName !== classname)) { - if (!updateExisting) { - return { - ok: false, - warnings, - error: `spriteId ${originalId} already used by "${existsById.itemName}"`, - }; - } - // Update existing item - fall through to update logic + // The spriteId is owned by a different classname. Even with + // updateExisting this is a genuine conflict, not an update. + return { + ok: false, + warnings, + error: `spriteId ${originalId} already used by "${existsById.itemName}"`, + }; } + const isUpdate = !!existsByName; + // ── Check if spriteId already used in FurnitureData.json ── const takenBy = await findFurniDataIdConflict(originalId, classname); if (takenBy && !updateExisting) { @@ -604,18 +612,25 @@ export async function importSingleFurni(params: { // ── Insert into DB with enriched data ────────────────────────────── onProgress?.("writing_db"); try { - // NOTE: live Arcturus `items_base` has no description/revision/rare columns — - // those metadata fields are persisted in FurnitureData.json (see entry below), - // not in the DB. Inserting them here throws "Unknown column 'description'". - await db.execute(sql` - INSERT INTO items_base (id, sprite_id, public_name, item_name, type) - VALUES (${spriteId}, ${spriteId}, ${bestName}, ${classname}, ${itemType}) - `); + if (isUpdate) { + // NOTE: live Arcturus `items_base` has no description/revision/rare columns — + // those metadata fields are persisted in FurnitureData.json (see entry below), + // not in the DB. Only refresh the columns this import manages. + await db.execute(sql` + UPDATE items_base SET sprite_id = ${spriteId}, public_name = ${bestName}, item_name = ${classname}, type = ${itemType} + WHERE id = ${spriteId} + `); + } else { + await db.execute(sql` + INSERT INTO items_base (id, sprite_id, public_name, item_name, type) + VALUES (${spriteId}, ${spriteId}, ${bestName}, ${classname}, ${itemType}) + `); + } } catch (err) { return { ok: false, warnings, - error: `DB insert failed: ${(err as Error).message}`, + error: `DB ${isUpdate ? "update" : "insert"} failed: ${(err as Error).message}`, }; } @@ -995,23 +1010,6 @@ export async function importSingleFurni(params: { warnings.push("Catalog entry creation failed"); } - // Verify and fix interaction_modes_count for all furniture - try { - const verification = await verifyAndFixInteractionModesCount(); - if (verification.fixed > 0) { - warnings.push( - `Fixed interaction_modes_count for ${verification.fixed} of ${verification.checked} items`, - ); - } - for (const err of verification.errors) { - warnings.push(`Interaction modes verification: ${err}`); - } - } catch (err) { - warnings.push( - `Interaction modes verification failed: ${(err as Error).message}`, - ); - } - return { ok: true, itemId: spriteId, @@ -1193,11 +1191,13 @@ export async function deleteImportedItem(classname: string): Promise<{ starIdx !== -1 ? classname.substring(0, starIdx) : classname; const iconFileName = classname.replace(/\*/g, "_"); - // 1. Delete catalog_items + // 1. Delete catalog_items — match by the canonical item_ids link only. + // catalog_name may have been translated to a public name, so adding a + // name condition would silently leave an orphaned catalog row behind. try { const itemIdStr = String(spriteId); await db.execute( - sql`DELETE FROM catalog_items WHERE item_ids = ${itemIdStr} AND catalog_name = ${classname}`, + sql`DELETE FROM catalog_items WHERE item_ids = ${itemIdStr}`, ); } catch (err) { errors.push(`catalog_items: ${(err as Error).message}`); @@ -1361,10 +1361,11 @@ export async function rollbackFurniImport( const errorsForItem: string[] = []; - // 1. Delete from catalog_items + // 1. Delete from catalog_items — match by the canonical item_ids link only + // (catalog_name may be a translated display name). try { await db.execute( - sql`DELETE FROM catalog_items WHERE item_ids = ${String(item.spriteId)} AND catalog_name = ${item.classname}`, + sql`DELETE FROM catalog_items WHERE item_ids = ${String(item.spriteId)}`, ); } catch (err) { errorsForItem.push(`catalog_items: ${(err as Error).message}`); diff --git a/src/lib/services/upload-import.ts b/src/lib/services/upload-import.ts index 93a777e2..7117f7f7 100644 --- a/src/lib/services/upload-import.ts +++ b/src/lib/services/upload-import.ts @@ -206,7 +206,7 @@ async function writeSqlMigration(params: { `INSERT INTO catalog_items`, ` (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)`, `SELECT (SELECT COALESCE(MAX(id), 0) + 1 FROM catalog_items), p.id, @next_id, ${escapeSql(params.classname)},`, - ` ${params.price.credits}, ${params.price.points}, ${params.price.pointsType}, 1, 1, '-1', ''`, + ` ${params.price.credits}, ${params.price.points}, ${params.price.pointsType}, 1, 1, @next_id, ''`, `FROM catalog_pages p`, `WHERE p.caption_save = ${escapeSql(captionSave)};`, "", @@ -391,7 +391,7 @@ export async function uploadSingleFurni(params: { catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => { await db.execute(sql` INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) - VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`); + VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${newId}, '')`); return nextCatalogId; }); } catch (err) {