From 4b7cb519dfda2fe587cae0e0a5d6a3ac09c8a659 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 21 Aug 2026 13:39:21 +0200 Subject: [PATCH] fix: furniture interaction detection and automatic catalog offer_id rebuild Interaction detection: - Trust SWF-derived sit/lay/stand flags only when the logic XML actually contains action data (new hasActions metadata); otherwise fall back to keyword detection so custom furni without nodes are still classified correctly - Add French/Dutch/German/Spanish/Italian keywords (chaise, banquette, stoel, silla, sedia, stuhl, tafel, mesa, ...) to sit/lay/stand detection with token-boundary matching to avoid false positives like bedside_table - Unify interaction_modes_count priority: mechanic fixed modes, then raw animation state count, then sit/lay fallback, then keyword default - Detect mechanic type even when real flags are not used Catalog integrity: - Skip duplicate catalog_items inserts in clone and upload imports - Re-check live catalog rows before applying generated repair SQL - Add rebuildCatalogOfferIds() which rebuilds every catalog_items.offer_id from the local FurnitureData.json (matched by entry id, then classname) and run it after every import path (single, batch, batch-regen, clone) --- 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 | 18 +- src/lib/furni/auto-interaction.test.ts | 42 +++- src/lib/furni/auto-interaction.ts | 212 +++++++++++++++--- src/lib/services/catalog-repair.ts | 10 + src/lib/services/clone-import.ts | 25 ++- src/lib/services/furni-data.ts | 95 +++++++- src/lib/services/furni-import.ts | 31 ++- src/lib/services/swf/index.ts | 1 + src/lib/services/swf/xml-processor.ts | 4 + src/lib/services/upload-import.ts | 24 +- src/types/furni.ts | 2 + 14 files changed, 418 insertions(+), 58 deletions(-) diff --git a/src/app/api/admin/import/clone/batch/route.ts b/src/app/api/admin/import/clone/batch/route.ts index 47b511fb..68b4aa42 100644 --- a/src/app/api/admin/import/clone/batch/route.ts +++ b/src/app/api/admin/import/clone/batch/route.ts @@ -10,6 +10,7 @@ import { getSource } from "@/lib/services/clone-sources"; import { appendFurniEntriesBatch, appendFurniEntry, + rebuildCatalogOfferIds, reconcileFurniDataWithItemsBase, } from "@/lib/services/furni-data"; import { @@ -142,6 +143,7 @@ export const POST = withAdmin( try { await reconcileImportedOfferIds(); await reconcileFurniDataWithItemsBase(); + await rebuildCatalogOfferIds(); 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 8fc5d2cb..66e7c71e 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, + rebuildCatalogOfferIds, reconcileFurniDataWithItemsBase, } from "@/lib/services/furni-data"; import { @@ -269,6 +270,9 @@ export const POST = withAdmin( // Verify the furnidata matches the DB ids const furniReconcile = await reconcileFurniDataWithItemsBase(); + // Rebuild every catalog offer_id from the furnidata + const offerRebuild = await rebuildCatalogOfferIds(); + send({ type: "regen_complete", succeeded, @@ -281,6 +285,8 @@ export const POST = withAdmin( furniDataFixedIds: furniReconcile.fixedIds, furniDataMissing: furniReconcile.missing, furniDataConflicts: furniReconcile.conflicts, + offerRebuildChecked: offerRebuild.checked, + offerRebuildFixed: offerRebuild.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 4836a135..bb335405 100644 --- a/src/app/api/admin/import/furni/batch/route.ts +++ b/src/app/api/admin/import/furni/batch/route.ts @@ -5,6 +5,7 @@ import { logAudit } from "@/lib/services/audit"; import { getSource } from "@/lib/services/clone-sources"; import { appendFurniEntriesBatch, + rebuildCatalogOfferIds, reconcileFurniDataWithItemsBase, } from "@/lib/services/furni-data"; import { @@ -227,6 +228,7 @@ export const POST = withAdmin( let furniDataFixedOfferIds = 0; let furniDataMissing = 0; let furniDataConflicts = 0; + let offerRebuildFixed = 0; let interactionFixed = 0; try { offerIdsFixed = (await reconcileImportedOfferIds()).fixed; @@ -239,6 +241,7 @@ export const POST = withAdmin( furniDataFixedOfferIds = furniReconcile.fixedOfferIds; furniDataMissing = furniReconcile.missing; furniDataConflicts = furniReconcile.conflicts; + offerRebuildFixed = (await rebuildCatalogOfferIds()).fixed; interactionFixed = (await verifyAndFixInteractionModesCount()).fixed; } catch (err) { console.warn( @@ -260,6 +263,7 @@ export const POST = withAdmin( furniDataFixedOfferIds, furniDataMissing, furniDataConflicts, + offerRebuildFixed, 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 711fa866..58456569 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -9,7 +9,10 @@ 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 { + rebuildCatalogOfferIds, + reconcileFurniDataWithItemsBase, +} from "@/lib/services/furni-data"; import { autoPriceFurni, CATEGORY_PAGE, @@ -171,11 +174,14 @@ export const GET = withAdmin( // Live furnidata reconcile (without hotel RCON reload) if (action === "live-reconcile") { const furniReconcile = await reconcileFurniDataWithItemsBase(); + const offerRebuild = await rebuildCatalogOfferIds(); return apiOk({ furniDataFixedIds: furniReconcile.fixedIds, furniDataFixedOfferIds: furniReconcile.fixedOfferIds, furniDataMissing: furniReconcile.missing, furniDataConflicts: furniReconcile.conflicts, + offerRebuildChecked: offerRebuild.checked, + offerRebuildFixed: offerRebuild.fixed, }); } @@ -452,6 +458,9 @@ export const POST = withAdmin( // Verify the furnidata matches the DB ids const furniReconcile = await reconcileFurniDataWithItemsBase(); + // Rebuild every catalog offer_id from the furnidata + const offerRebuild = await rebuildCatalogOfferIds(); + // Verify + fix interaction_modes_count once for the whole batch const verification = await verifyAndFixInteractionModesCount(); @@ -471,6 +480,8 @@ export const POST = withAdmin( furniDataFixedOfferIds: furniReconcile.fixedOfferIds, furniDataMissing: furniReconcile.missing, furniDataConflicts: furniReconcile.conflicts, + offerRebuildChecked: offerRebuild.checked, + offerRebuildFixed: offerRebuild.fixed, interactionFixed: verification.fixed, }); } @@ -533,6 +544,9 @@ export const POST = withAdmin( // Verify the furnidata matches the DB ids const furniReconcile = await reconcileFurniDataWithItemsBase(); + // Rebuild every catalog offer_id from the furnidata + const offerRebuild = await rebuildCatalogOfferIds(); + // Verify + fix interaction_modes_count (single consolidated pass) const verification = await verifyAndFixInteractionModesCount(); @@ -564,6 +578,8 @@ export const POST = withAdmin( furniDataFixedOfferIds: furniReconcile.fixedOfferIds, furniDataMissing: furniReconcile.missing, furniDataConflicts: furniReconcile.conflicts, + offerRebuildChecked: offerRebuild.checked, + offerRebuildFixed: offerRebuild.fixed, interactionFixed: verification.fixed, spriteCount: result.spriteCount, warnings: result.warnings, diff --git a/src/lib/furni/auto-interaction.test.ts b/src/lib/furni/auto-interaction.test.ts index afd0367b..f13ae722 100644 --- a/src/lib/furni/auto-interaction.test.ts +++ b/src/lib/furni/auto-interaction.test.ts @@ -30,16 +30,52 @@ describe("autoDetectInteraction with real data", () => { expect(result.interactionType).toBe("bed"); }); - it("respects all-false real flags (no keyword false positive)", () => { + it("respects curated all-false real flags (no keyword false positive)", () => { const result = autoDetectInteraction("table_plasto_4leg", "Table", { cansiton: false, canlayon: false, canstandon: false, + hasActionData: true, + }); + expect(result.canSit).toBe(false); + expect(result.canLay).toBe(false); + expect(result.canStand).toBe(false); + expect(result.interactionModesCount).toBe(0); + }); + + it("falls back to keywords when flags carry no action data", () => { + const result = autoDetectInteraction("chaise_metal_lamarr", "Chaise", { + cansiton: false, + canlayon: false, + canstandon: false, + animationStates: 4, + }); + expect(result.canSit).toBe(true); + expect(result.canStand).toBe(false); + expect(result.interactionModesCount).toBe(4); + }); + + it("detects French bench (banquette) with raw state count", () => { + const result = autoDetectInteraction("banquette_lamarr", "Banquette", { + cansiton: false, + canlayon: false, + canstandon: false, + animationStates: 6, + }); + expect(result.canSit).toBe(true); + expect(result.interactionType).toBe("bench"); + expect(result.interactionModesCount).toBe(6); + }); + + it("does not match short keywords without token boundaries", () => { + const result = autoDetectInteraction("bedside_table", "Bedside Table", { + cansiton: false, + canlayon: false, + canstandon: false, }); expect(result.canSit).toBe(false); expect(result.canLay).toBe(false); - expect(result.canStand).toBe(false); - expect(result.interactionModesCount).toBe(0); + expect(result.interactionType).toBe("table"); }); it("detects dice mechanics from classname with fixed modes=6", () => { diff --git a/src/lib/furni/auto-interaction.ts b/src/lib/furni/auto-interaction.ts index 07014714..61180fac 100644 --- a/src/lib/furni/auto-interaction.ts +++ b/src/lib/furni/auto-interaction.ts @@ -25,6 +25,13 @@ export interface RealInteractionData { specialtype?: number; /** Real animation-state count from the .nitro asset. */ animationStates?: number; + /** + * True when the sit/lay/stand flags come from a curated source (official + * furnidata) or from SWF logic that actually contains nodes. + * When false the flags were defaulted (asset had no action data at all), + * so keyword detection may still contribute. + */ + hasActionData?: boolean; } function realSitLayStand( @@ -37,13 +44,16 @@ function realSitLayStand( canStand: boolean; usedReal: boolean; } { - // When real flags are present (even all-false), trust them completely — - // keyword detection would only introduce false positives. + // When real flags are present AND trustworthy (curated furnidata, or SWF + // logic that actually declared nodes), trust them completely — + // keyword detection would only introduce false positives. All-false flags + // without any action data mean "unknown", not "confirmed false". if ( real && - (real.cansiton !== undefined || - real.canlayon !== undefined || - real.canstandon !== undefined) + (real.hasActionData === true || + real.cansiton === true || + real.canlayon === true || + real.canstandon === true) ) { return { canSit: real.cansiton === true, @@ -100,11 +110,14 @@ export function autoDetectInteraction( let clickLimit = 0; let interactionModesCount = 0; + // Classname mechanics (dice/gate/teleport/…) are deterministic — detect + // them regardless of whether real flags were trusted. + const mechanicType = realMechanicType(classname); + if (usedReal) { // interactionType: the classname mechanic wins when detectable (dice/gate/ // teleport ship as generic furniture_multistate in .nitro, so logicType // alone cannot distinguish them), then the real logicType mapping. - const mechanicType = realMechanicType(classname); const keywordType = INTERACTION_TYPE_MAP[ ((layMatch ?? @@ -116,40 +129,45 @@ export function autoDetectInteraction( (real?.logicType && real.logicType !== "furniture_basic" ? (mapLogicType(real.logicType) ?? "default") : (keywordType ?? "default")); - - // Real modes count. Emulator convention for state-based mechanics is - // fixed (dice = 6, gate/teleport = 2, roller = 1). Generic multistate - // items get the real animation-state count; chairs/beds = 1; plain = 0. - const fixedModes = MECHANIC_MODES[interactionType]; - if (fixedModes !== undefined) { - interactionModesCount = fixedModes; - } else if ( - real?.animationStates !== undefined && - real.animationStates > 1 - ) { - interactionModesCount = real.animationStates; - } else if (canLay || canSit) { - interactionModesCount = 1; - } else { - interactionModesCount = 0; - } - clickLimit = canLay || canSit || canStand ? 1 : 0; } else if (layMatch) { matchedKeyword = layMatch; interactionType = INTERACTION_TYPE_MAP[layMatch] || "bed"; - clickLimit = CLICK_LIMITS[layMatch] ?? 1; - interactionModesCount = INTERACTION_MODES[layMatch] ?? 1; } else if (sitMatch) { matchedKeyword = sitMatch; interactionType = INTERACTION_TYPE_MAP[sitMatch] || "chair"; - clickLimit = CLICK_LIMITS[sitMatch] ?? 1; - interactionModesCount = INTERACTION_MODES[sitMatch] ?? 1; } else if (standMatch) { matchedKeyword = standMatch; interactionType = INTERACTION_TYPE_MAP[standMatch] || "default"; - clickLimit = CLICK_LIMITS[standMatch] ?? 0; - interactionModesCount = INTERACTION_MODES[standMatch] ?? 0; } + if (!usedReal && mechanicType) { + interactionType = mechanicType; + } + + // Modes count = how many states clicking cycles through. Priority: + // 1. Emulator-fixed value per mechanic (dice = 6, gate/teleport = 2, …) + // 2. Real animation-state count from the .nitro asset (renderable states) + // 3. Sit/lay furniture without state info → 1 + // 4. Keyword default (chairs 1, tables 0) / plain items → 0 + const fixedModes = MECHANIC_MODES[interactionType]; + if (fixedModes !== undefined) { + interactionModesCount = fixedModes; + } else if (real?.animationStates !== undefined && real.animationStates > 1) { + interactionModesCount = real.animationStates; + } else if (canLay || canSit) { + interactionModesCount = 1; + } else if (matchedKeyword) { + interactionModesCount = INTERACTION_MODES[matchedKeyword] ?? 0; + } else { + interactionModesCount = 0; + } + clickLimit = + fixedModes !== undefined + ? 1 + : canLay || canSit || canStand + ? 1 + : matchedKeyword + ? (CLICK_LIMITS[matchedKeyword] ?? 0) + : 0; const reasonParts: string[] = []; if (usedReal) { @@ -278,6 +296,26 @@ const SIT_KEYWORDS = [ "wheelchair", "toilet", "potty", + // French + "chaise", + "banquette", + "tabouret", + "fauteuil", + "canap", + "banc", + // Dutch + "stoel", + "kruk", + // Spanish + "silla", + "taburete", + // Italian + "sedia", + "poltrona", + // German + "stuhl", + "sessel", + "hocker", ]; const LAY_KEYWORDS = [ @@ -297,6 +335,17 @@ const LAY_KEYWORDS = [ "daybed", "divan", "waterbed", + // French + "matelas", + "couette", + // Dutch + "bedden", + // Spanish + "cama", + // Italian + "letto", + // German + "bett", ]; const STAND_KEYWORDS = [ @@ -328,6 +377,16 @@ const STAND_KEYWORDS = [ "water", "pool", "fountain", + // French + "tafel", + "bureau", + "etagere", + // Spanish / Italian + "mesa", + "tavolo", + // German + "schrank", + "kommode", ]; const INTERACTION_TYPE_MAP: Record = { @@ -366,6 +425,27 @@ const INTERACTION_TYPE_MAP: Record = { daybed: "daybed", divan: "divan", waterbed: "waterbed", + matelas: "bed", + couette: "bed", + bedden: "bed", + cama: "bed", + letto: "bed", + bett: "bed", + chaise: "chair", + banquette: "bench", + tabouret: "stool", + fauteuil: "armchair", + canap: "sofa", + banc: "bench", + stoel: "chair", + kruk: "stool", + silla: "chair", + taburete: "stool", + sedia: "chair", + poltrona: "armchair", + stuhl: "chair", + sessel: "armchair", + hocker: "stool", table: "table", desk: "desk", counter: "counter", @@ -380,6 +460,13 @@ const INTERACTION_TYPE_MAP: Record = { pedestal: "pedestal", platform: "platform", stage: "stage", + tafel: "table", + bureau: "desk", + etagere: "shelf", + mesa: "table", + tavolo: "table", + schrank: "wardrobe", + kommode: "dresser", }; const INTERACTION_MODES: Record = { @@ -418,6 +505,27 @@ const INTERACTION_MODES: Record = { daybed: 1, divan: 1, waterbed: 1, + matelas: 1, + couette: 1, + bedden: 1, + cama: 1, + letto: 1, + bett: 1, + chaise: 1, + banquette: 1, + tabouret: 1, + fauteuil: 1, + canap: 1, + banc: 1, + stoel: 1, + kruk: 1, + silla: 1, + taburete: 1, + sedia: 1, + poltrona: 1, + stuhl: 1, + sessel: 1, + hocker: 1, table: 0, desk: 0, counter: 0, @@ -446,6 +554,13 @@ const INTERACTION_MODES: Record = { water: 0, pool: 0, fountain: 0, + tafel: 0, + bureau: 0, + etagere: 0, + mesa: 0, + tavolo: 0, + schrank: 0, + kommode: 0, }; const CLICK_LIMITS: Record = { @@ -484,6 +599,27 @@ const CLICK_LIMITS: Record = { daybed: 1, divan: 1, waterbed: 1, + matelas: 1, + couette: 1, + bedden: 1, + cama: 1, + letto: 1, + bett: 1, + chaise: 1, + banquette: 1, + tabouret: 1, + fauteuil: 1, + canap: 1, + banc: 1, + stoel: 1, + kruk: 1, + silla: 1, + taburete: 1, + sedia: 1, + poltrona: 1, + stuhl: 1, + sessel: 1, + hocker: 1, table: 0, desk: 0, counter: 0, @@ -512,6 +648,13 @@ const CLICK_LIMITS: Record = { water: 0, pool: 0, fountain: 0, + tafel: 0, + bureau: 0, + etagere: 0, + mesa: 0, + tavolo: 0, + schrank: 0, + kommode: 0, }; function normalizeString(str: string): string { @@ -520,9 +663,14 @@ function normalizeString(str: string): string { function findMatch(normalized: string, keywords: string[]): string | null { for (const kw of keywords) { - if (normalized.includes(kw)) { - return kw; + if (!normalized.includes(kw)) continue; + // Short keywords (e.g. "bed", "lit", "bar") must match on token + // boundaries so "bedside_table" or "split" don't false-positive. + if (kw.length < 5) { + const boundary = new RegExp(`(^|_)${kw}(_|$)`); + if (!boundary.test(normalized)) continue; } + return kw; } return null; } diff --git a/src/lib/services/catalog-repair.ts b/src/lib/services/catalog-repair.ts index 701e925d..c8dc8e47 100644 --- a/src/lib/services/catalog-repair.ts +++ b/src/lib/services/catalog-repair.ts @@ -361,6 +361,16 @@ export async function generateCatalogSql( skipped++; continue; } + // Re-check against the live table: the audit snapshot may be stale and + // an import may have created the entry in the meantime. Inserting would + // create a duplicate catalog_items row for the same item_ids. + const [existing] = (await db.execute(sql` + SELECT id FROM catalog_items WHERE item_ids = ${String(entry.itemId)} LIMIT 1 + `)) as unknown as [Array<{ id: number }>, unknown]; + if (existing.length > 0) { + skipped++; + continue; + } let pageId: number; if (organizeSql && parentPageId) { const catKey = classifyFurni(entry.classname, entry.itemType); diff --git a/src/lib/services/clone-import.ts b/src/lib/services/clone-import.ts index 89e89a19..548561ea 100644 --- a/src/lib/services/clone-import.ts +++ b/src/lib/services/clone-import.ts @@ -379,6 +379,8 @@ export async function cloneSingleFurni(params: { cansiton: entry.cansiton, canlayon: entry.canlayon, canstandon: entry.canstandon, + // Source furnidata flags are curated → authoritative over keywords. + hasActionData: true, logicType: (clonedNitroJson?.logicType as string) || undefined, animationStates: nitroAnimationStatesCount(clonedNitroJson), }); @@ -440,15 +442,26 @@ export async function cloneSingleFurni(params: { } // Catalog entry (category sub-page + auto price), serialized id allocation. + // Skip when the item already has a catalog row — re-imports must never + // create a second row for the same item_ids. try { - const pageId = await getOrCreateCategoryPage(classname, itemType); - const price = autoPriceFurni(classname); - await allocateCatalogItemId(async (nextCatalogId) => { - await db.execute(sql` + const [existingCatalog] = (await db.execute(sql` + SELECT id FROM catalog_items WHERE item_ids = ${String(newId)} LIMIT 1 + `)) as unknown as [Array<{ id: number }>, unknown]; + if (existingCatalog.length > 0) { + warnings.push( + `catalog entry already exists (#${existingCatalog[0].id}) — skipped duplicate`, + ); + } else { + const pageId = await getOrCreateCategoryPage(classname, itemType); + const price = autoPriceFurni(classname); + 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, ${newId}, '')`); - return nextCatalogId; - }); + return nextCatalogId; + }); + } } catch (err) { logger.warn("[clone-import] catalog entry failed for", { classname, diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index 322759f0..670f280a 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -1,6 +1,7 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; -import { db, ItemsBase } from "@/lib/db"; +import { eq } from "drizzle-orm"; +import { CatalogItems, db, ItemsBase } from "@/lib/db"; import { getGamedataRoot, getNitroFilesRoot, @@ -563,3 +564,95 @@ export async function reconcileFurniDataWithItemsBase(): Promise { + return withFurniDataLock(async () => { + const furniData = (await readFurniData()) as Record< + string, + { furnitype?: Array> } + >; + const offerById = new Map(); + const offerByClass = new Map(); + for (const section of ["roomitemtypes", "wallitemtypes"] as const) { + for (const e of furniData[section]?.furnitype ?? []) { + const cn = typeof e?.classname === "string" ? e.classname : ""; + const entryId = Number(e?.id); + const offer = Number(e?.offerid); + if ( + cn && + Number.isFinite(offer) && + offer > 0 && + !offerByClass.has(cn) + ) { + offerByClass.set(cn, offer); + } + if ( + Number.isFinite(entryId) && + entryId > 0 && + !offerById.has(entryId) + ) { + offerById.set( + entryId, + Number.isFinite(offer) && offer > 0 ? offer : entryId, + ); + } + } + } + + const itemRows = await db + .select({ id: ItemsBase.id, itemName: ItemsBase.itemName }) + .from(ItemsBase); + const nameById = new Map(); + for (const r of itemRows) nameById.set(r.id, r.itemName); + + const catalogRows = await db + .select({ + id: CatalogItems.id, + itemIds: CatalogItems.itemIds, + offerId: CatalogItems.offerId, + }) + .from(CatalogItems); + + let fixed = 0; + let fallback = 0; + for (const row of catalogRows) { + const itemId = Number(String(row.itemIds ?? "").split(/[;,]/)[0]); + if (!Number.isFinite(itemId) || itemId <= 0) continue; + const classname = nameById.get(itemId); + const furnidataOffer = + offerById.get(itemId) ?? + (classname ? offerByClass.get(classname) : undefined); + const target = furnidataOffer ?? itemId; + if (row.offerId === target) continue; + await db + .update(CatalogItems) + .set({ offerId: target }) + .where(eq(CatalogItems.id, row.id)); + fixed++; + if (furnidataOffer === undefined) fallback++; + } + return { checked: catalogRows.length, fixed, fallback }; + }); +} diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 1aeb785a..a5f75be7 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -848,10 +848,21 @@ export async function importSingleFurni(params: { : { x: 1, y: 1, z: 0 }); const meta = conversionResult?.metadata ?? null; - // Prefer .nitro flags, then the configured official Habbo furnidata. - const cansiton = meta?.cansiton ?? officialHabbo?.cansiton ?? false; - const canlayon = meta?.canlayon ?? officialHabbo?.canlayon ?? false; - const canstandon = meta?.canstandon ?? officialHabbo?.canstandon ?? false; + // Real sit/lay/stand flags: prefer .nitro logic flags, then the configured + // official Habbo furnidata. Only pass them when a real source exists — + // defaulted all-false values must not suppress keyword detection (custom + // SWFs often ship without nodes even for sittable furniture). + const realSource = meta ?? officialHabbo; + const realFlags = realSource + ? { + cansiton: realSource.cansiton ?? false, + canlayon: realSource.canlayon ?? false, + canstandon: realSource.canstandon ?? false, + // Official furnidata is curated → authoritative. SWF-derived flags + // are only authoritative when the logic actually had nodes. + hasActionData: meta ? (meta.hasActions ?? true) : true, + } + : undefined; // Derive interaction_type. Priority: // 1. Wired LOGIC items (wf_act_/wf_trg_/wf_cnd_/wf_slc_/wf_xtra_/wf_var_) @@ -903,9 +914,7 @@ export async function importSingleFurni(params: { // no .nitro JSON available — keyword fallback only } const autoInteraction = autoDetectInteraction(classname, bestName, { - cansiton, - canlayon, - canstandon, + ...realFlags, logicType, animationStates: nitroAnimationStatesCount(nitroJsonForModes), }); @@ -929,9 +938,9 @@ export async function importSingleFurni(params: { const update = buildItemsBaseEnrichmentUpdate({ id: spriteId, dims, - cansiton, - canlayon, - canstandon, + cansiton: autoInteraction.canSit, + canlayon: autoInteraction.canLay, + canstandon: autoInteraction.canStand, allowStack, interactionType, interactionModesCount, @@ -1123,6 +1132,8 @@ export async function verifyAndFixInteractionModesCount(): Promise<{ cansiton: realFlags.cansiton, canlayon: realFlags.canlayon, canstandon: realFlags.canstandon, + // Furnidata flags are curated → authoritative over keywords. + hasActionData: true, animationStates: animationModes, }); diff --git a/src/lib/services/swf/index.ts b/src/lib/services/swf/index.ts index 7b1e90ea..9b29d433 100644 --- a/src/lib/services/swf/index.ts +++ b/src/lib/services/swf/index.ts @@ -425,6 +425,7 @@ export function convertSwfToNitro( canstandon: logic.canstandon, cansiton: logic.cansiton, canlayon: logic.canlayon, + hasActions: logic.hasActions, specialtype: indexData?.specialtype ?? 0, defaultdir: computeDefaultDir(logic.directions), partcolors: extractPartColors(visualizations), diff --git a/src/lib/services/swf/xml-processor.ts b/src/lib/services/swf/xml-processor.ts index bd68908f..ee16c1d3 100644 --- a/src/lib/services/swf/xml-processor.ts +++ b/src/lib/services/swf/xml-processor.ts @@ -226,6 +226,8 @@ export interface LogicResult { canstandon: boolean; cansiton: boolean; canlayon: boolean; + /** True when the logic XML actually declared nodes — flags are then authoritative. */ + hasActions: boolean; action?: { link?: string; startState?: number }; credits?: string; soundSample?: { id: number; noPitch?: boolean }; @@ -242,6 +244,7 @@ export function processLogic(xml: string): LogicResult { canstandon: false, cansiton: false, canlayon: false, + hasActions: false, }; const root = parseXml(xml); if (!root) return defaults; @@ -285,6 +288,7 @@ export function processLogic(xml: string): LogicResult { canstandon, cansiton, canlayon, + hasActions: actions.length > 0, }; // Parse action link/startState diff --git a/src/lib/services/upload-import.ts b/src/lib/services/upload-import.ts index 7117f7f7..b6fde43c 100644 --- a/src/lib/services/upload-import.ts +++ b/src/lib/services/upload-import.ts @@ -337,6 +337,8 @@ export async function uploadSingleFurni(params: { cansiton, canlayon, canstandon, + // Flags come from explicit user input → authoritative over keywords. + hasActionData: true, logicType: (nitroMeta?.logicType as string) || undefined, animationStates: nitroAnimationStatesCount(nitroMeta), }); @@ -387,13 +389,25 @@ export async function uploadSingleFurni(params: { let catalogItemId: number | null = null; try { - const pageId = await getOrCreateCategoryPage(classname, itemType); - catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => { - await db.execute(sql` + // Skip when the item already has a catalog row — re-uploads must never + // create a second row for the same item_ids. + const [existingCatalog] = (await db.execute(sql` + SELECT id FROM catalog_items WHERE item_ids = ${String(newId)} LIMIT 1 + `)) as unknown as [Array<{ id: number }>, unknown]; + if (existingCatalog.length > 0) { + catalogItemId = existingCatalog[0].id; + warnings.push( + `catalog entry already exists (#${catalogItemId}) — skipped duplicate`, + ); + } else { + const pageId = await getOrCreateCategoryPage(classname, itemType); + 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, ${newId}, '')`); - return nextCatalogId; - }); + return nextCatalogId; + }); + } } catch (err) { warnings.push(`catalog entry failed: ${(err as Error).message}`); } diff --git a/src/types/furni.ts b/src/types/furni.ts index 6c58aa71..4f096e39 100644 --- a/src/types/furni.ts +++ b/src/types/furni.ts @@ -24,6 +24,8 @@ export interface FurniMetadata { canstandon: boolean; cansiton: boolean; canlayon: boolean; + /** True when the SWF logic XML declared nodes (flags authoritative). */ + hasActions?: boolean; specialtype: number; defaultdir: number; partcolors: { color: string[] };