diff --git a/src/app/admin/import/furni/nitro-editor-dialog.tsx b/src/app/admin/import/furni/nitro-editor-dialog.tsx index 8ca8faf9a5..2fefe5c2a3 100644 --- a/src/app/admin/import/furni/nitro-editor-dialog.tsx +++ b/src/app/admin/import/furni/nitro-editor-dialog.tsx @@ -35,6 +35,7 @@ import { adminFetch } from "@/lib/admin-fetch"; import { autoDetectInteraction, getInteractionTypeLabel, + nitroAnimationStatesCount, } from "@/lib/furni/auto-interaction"; interface NitroEditorDialogProps { @@ -154,7 +155,20 @@ export function NitroEditorDialog({ const [jsonError, setJsonError] = useState(null); const handleAutoDetect = () => { - const result = autoDetectInteraction(classname, name); + // Use real data when available: the DB flags for this item plus the + // .nitro animation states already loaded in the editor. Keyword + // detection only kicks in when neither is present — no false positives. + const nitroJson = + metadata && typeof metadata === "object" + ? (metadata as Record) + : null; + const result = autoDetectInteraction(classname, name, { + cansiton: flags.cansiton, + canlayon: flags.canlayon, + canstandon: flags.canstandon, + logicType: nitroJson?.logicType as string | undefined, + animationStates: nitroAnimationStatesCount(nitroJson), + }); setAutoDetectResult(result); setFlags({ canstandon: result.canStand, diff --git a/src/lib/furni/auto-interaction.test.ts b/src/lib/furni/auto-interaction.test.ts new file mode 100644 index 0000000000..afd0367bf5 --- /dev/null +++ b/src/lib/furni/auto-interaction.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { + autoDetectInteraction, + nitroAnimationStatesCount, +} from "./auto-interaction"; + +describe("autoDetectInteraction with real data", () => { + it("trusts real flags over keywords for sit", () => { + const result = autoDetectInteraction("chair_plasto", "Chair", { + cansiton: true, + canlayon: false, + canstandon: false, + }); + expect(result.canSit).toBe(true); + expect(result.canLay).toBe(false); + expect(result.canStand).toBe(false); + expect(result.interactionModesCount).toBe(1); + expect(result.reason).toContain("Real furniture data"); + }); + + it("trusts real flags over keywords for lay", () => { + const result = autoDetectInteraction("bed_silo_two", "Bed", { + cansiton: false, + canlayon: true, + canstandon: false, + }); + expect(result.canSit).toBe(false); + expect(result.canLay).toBe(true); + expect(result.interactionModesCount).toBe(1); + expect(result.interactionType).toBe("bed"); + }); + + it("respects all-false real flags (no keyword false positive)", () => { + const result = autoDetectInteraction("table_plasto_4leg", "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); + }); + + it("detects dice mechanics from classname with fixed modes=6", () => { + const result = autoDetectInteraction("dice", "Dice", { + cansiton: false, + canlayon: false, + canstandon: false, + logicType: "furniture_multistate", + animationStates: 7, + }); + expect(result.interactionType).toBe("dice"); + expect(result.interactionModesCount).toBe(6); + }); + + it("detects teleport mechanics with fixed modes=2", () => { + const result = autoDetectInteraction("teleport_door", "Teleport", { + cansiton: false, + canlayon: false, + canstandon: true, + logicType: "furniture_multistate", + animationStates: 3, + }); + expect(result.interactionType).toBe("teleport"); + expect(result.interactionModesCount).toBe(2); + }); + + it("uses real animation states for generic multistate items", () => { + const result = autoDetectInteraction("sleepy_24_block_01", "", { + cansiton: false, + canlayon: false, + canstandon: false, + animationStates: 13, + }); + expect(result.interactionModesCount).toBe(13); + }); + + it("still keyword-falls-back when no real data is present", () => { + const result = autoDetectInteraction("chair_plasto", "Plasto Chair"); + expect(result.canSit).toBe(true); + expect(result.interactionModesCount).toBe(1); + expect(result.reason).toContain("chair"); + }); +}); + +describe("nitroAnimationStatesCount", () => { + it("counts dice-style states", () => { + expect( + nitroAnimationStatesCount({ + visualizations: [ + { + animations: { + "0": {}, + "1": {}, + "2": {}, + "3": {}, + "4": {}, + "5": {}, + "6": {}, + }, + }, + ], + }), + ).toBe(7); + }); + + it("returns undefined for basic items without animations", () => { + expect( + nitroAnimationStatesCount({ visualizations: [{ animations: {} }] }), + ).toBeUndefined(); + }); + + it("ignores jukebox track states above 99", () => { + expect( + nitroAnimationStatesCount({ + visualizations: [{ animations: { "0": {}, "1": {}, "100": {} } }], + }), + ).toBe(2); + }); + + it("returns undefined for null input", () => { + expect(nitroAnimationStatesCount(null)).toBeUndefined(); + }); +}); diff --git a/src/lib/furni/auto-interaction.ts b/src/lib/furni/auto-interaction.ts index 263d87e4a3..07014714b9 100644 --- a/src/lib/furni/auto-interaction.ts +++ b/src/lib/furni/auto-interaction.ts @@ -13,6 +13,249 @@ export interface AutoInteractionResult { reason: string; } +/** + * Real interaction data that, when available, is used verbatim so detection + * has zero false positives (no keyword guessing for known furniture). + */ +export interface RealInteractionData { + cansiton?: boolean; + canlayon?: boolean; + canstandon?: boolean; + logicType?: string; + specialtype?: number; + /** Real animation-state count from the .nitro asset. */ + animationStates?: number; +} + +function realSitLayStand( + classname: string, + name?: string, + real?: RealInteractionData, +): { + canSit: boolean; + canLay: boolean; + canStand: boolean; + usedReal: boolean; +} { + // When real flags are present (even all-false), trust them completely — + // keyword detection would only introduce false positives. + if ( + real && + (real.cansiton !== undefined || + real.canlayon !== undefined || + real.canstandon !== undefined) + ) { + return { + canSit: real.cansiton === true, + canLay: real.canlayon === true, + canStand: real.canstandon === true, + usedReal: true, + }; + } + + const normalizedClassname = normalizeString(classname); + const normalizedName = name ? normalizeString(name) : ""; + const searchText = `${normalizedClassname} ${normalizedName}`; + + const sitMatch = findMatch(searchText, SIT_KEYWORDS); + const layMatch = findMatch(searchText, LAY_KEYWORDS); + const standMatch = findMatch(searchText, STAND_KEYWORDS); + + let canSit = false; + let canLay = false; + let canStand = false; + if (layMatch) { + canLay = true; + canSit = true; + } else if (sitMatch) { + canSit = true; + } else if (standMatch) { + canStand = true; + } + return { canSit, canLay, canStand, usedReal: false }; +} + +export function autoDetectInteraction( + classname: string, + name?: string, + real?: RealInteractionData, +): AutoInteractionResult { + const normalizedClassname = normalizeString(classname); + const normalizedName = name ? normalizeString(name) : ""; + + const searchText = `${normalizedClassname} ${normalizedName}`; + + const sitMatch = findMatch(searchText, SIT_KEYWORDS); + const layMatch = findMatch(searchText, LAY_KEYWORDS); + const standMatch = findMatch(searchText, STAND_KEYWORDS); + + const { canSit, canLay, canStand, usedReal } = realSitLayStand( + classname, + name, + real, + ); + + let matchedKeyword: string | null = null; + let interactionType = "default"; + let clickLimit = 0; + let interactionModesCount = 0; + + 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 ?? + sitMatch ?? + standMatch) as keyof typeof INTERACTION_TYPE_MAP) ?? "" + ]; + interactionType = + mechanicType ?? + (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; + } + + const reasonParts: string[] = []; + if (usedReal) { + reasonParts.push("Real furniture data"); + } else if (matchedKeyword) { + reasonParts.push(`Matched "${matchedKeyword}"`); + } + if (canSit) reasonParts.push("can sit"); + if (canLay) reasonParts.push("can lay"); + if (canStand) reasonParts.push("can stand"); + if (!matchedKeyword && !usedReal) { + reasonParts.push("No keywords matched - default"); + } + + return { + canSit, + canLay, + canStand, + interactionType, + clickLimit, + interactionModesCount, + reason: reasonParts.join(" · "), + }; +} + +function mapLogicType(logicType: string): string | null { + const lt = logicType.toLowerCase(); + if (lt.includes("multistate")) return "default"; + if (lt.includes("gate")) return "gate"; + if (lt.includes("teleport")) return "teleport"; + if (lt.includes("roller")) return "roller"; + if (lt.includes("dice")) return "dice"; + if (lt.includes("vendingmachine")) return "vendingmachine"; + if (lt.includes("firework")) return "fireworks"; + if (lt.includes("trophy")) return "trophy"; + if (lt.includes("exchange")) return "exchange"; + if (lt.includes("habbowheel")) return "habbowheel"; + if (lt.includes("soundmachine")) return "jukebox"; + if (lt.includes("badge")) return "badge"; + return null; +} + +/** + * Detect a mechanics interaction type from the classname itself. Used when + * the .nitro logicType is generic (e.g. dice/gate bundles ship as + * furniture_multistate) so state-based modes are computed correctly. + */ +function realMechanicType(classname: string): string | null { + const c = classname.toLowerCase(); + if (/(^|_)dice/.test(c)) return "dice"; + if (c.includes("teleport")) return "teleport"; + if (c.includes("gate")) return "gate"; + if (c.includes("roller")) return "roller"; + if (c.includes("vending")) return "vendingmachine"; + if (c.includes("firework")) return "fireworks"; + if (c.includes("trophy")) return "trophy"; + if (c.includes("exchange")) return "exchange"; + if (c.includes("habbowheel")) return "habbowheel"; + if (c.includes("jukebox") || c.includes("soundmachine")) return "jukebox"; + return null; +} + +/** Emulator-fixed interaction_modes_count per mechanic. */ +const MECHANIC_MODES: Record = { + dice: 6, + gate: 2, + teleport: 2, + roller: 1, + vendingmachine: 1, + fireworks: 1, + trophy: 1, + exchange: 1, + habbowheel: 1, + jukebox: 1, +}; + +/** + * Derive the real interaction modes count from a parsed Nitro JSON asset. + * The number of distinct animation states on the first visualization is the + * authoritative "modes" value (e.g. dice = 6, gate = 2, multistate = N). + * Returns undefined when the asset has no usable animation states. + * Pure (no fs/server deps) so the nitro editor client can use it too. + */ +export function nitroAnimationStatesCount( + nitroJson: Record | null | undefined, +): number | undefined { + if (!nitroJson || typeof nitroJson !== "object") return undefined; + const visualizations = (nitroJson as { visualizations?: unknown }) + .visualizations; + if (!Array.isArray(visualizations)) return undefined; + for (const vis of visualizations) { + if (!vis || typeof vis !== "object") continue; + const animations = (vis as { animations?: unknown }).animations; + if (animations && typeof animations === "object") { + const keys = Object.keys(animations as Record); + // Skip purely decorative states (jukebox tracks 100+, hidden -1). + const real = keys.filter((k) => { + const n = Number(k); + return Number.isInteger(n) && n >= 0 && n < 100; + }); + if (real.length > 1) return real.length; + } + } + return undefined; +} + const SIT_KEYWORDS = [ "bench", "bank", @@ -284,70 +527,6 @@ function findMatch(normalized: string, keywords: string[]): string | null { return null; } -export function autoDetectInteraction( - classname: string, - name?: string, -): AutoInteractionResult { - const normalizedClassname = normalizeString(classname); - const normalizedName = name ? normalizeString(name) : ""; - - const searchText = `${normalizedClassname} ${normalizedName}`; - - const sitMatch = findMatch(searchText, SIT_KEYWORDS); - const layMatch = findMatch(searchText, LAY_KEYWORDS); - const standMatch = findMatch(searchText, STAND_KEYWORDS); - - let canSit = false; - let canLay = false; - let canStand = false; - let matchedKeyword: string | null = null; - let interactionType = "default"; - let clickLimit = 0; - let interactionModesCount = 0; - - if (layMatch) { - canLay = true; - canSit = true; - matchedKeyword = layMatch; - interactionType = INTERACTION_TYPE_MAP[layMatch] || "bed"; - clickLimit = CLICK_LIMITS[layMatch] ?? 1; - interactionModesCount = INTERACTION_MODES[layMatch] ?? 1; - } else if (sitMatch) { - canSit = true; - matchedKeyword = sitMatch; - interactionType = INTERACTION_TYPE_MAP[sitMatch] || "chair"; - clickLimit = CLICK_LIMITS[sitMatch] ?? 1; - interactionModesCount = INTERACTION_MODES[sitMatch] ?? 1; - } else if (standMatch) { - canStand = true; - matchedKeyword = standMatch; - interactionType = INTERACTION_TYPE_MAP[standMatch] || "default"; - clickLimit = CLICK_LIMITS[standMatch] ?? 0; - interactionModesCount = INTERACTION_MODES[standMatch] ?? 0; - } - - const reasonParts: string[] = []; - if (matchedKeyword) { - reasonParts.push(`Matched "${matchedKeyword}"`); - } - if (canSit) reasonParts.push("can sit"); - if (canLay) reasonParts.push("can lay"); - if (canStand) reasonParts.push("can stand"); - if (!matchedKeyword) { - reasonParts.push("No keywords matched - default"); - } - - return { - canSit, - canLay, - canStand, - interactionType, - clickLimit, - interactionModesCount, - reason: reasonParts.join(" · "), - }; -} - export function getInteractionTypeLabel(type: string): string { const labels: Record = { bench: "Bench / Bank", diff --git a/src/lib/services/clone-import.ts b/src/lib/services/clone-import.ts index 6ce2f147d6..ea67bcaba4 100644 --- a/src/lib/services/clone-import.ts +++ b/src/lib/services/clone-import.ts @@ -16,6 +16,7 @@ import { ensureDirectories, getOrCreateCategoryPage, } from "@/lib/services/furni-import"; +import { nitroAnimationStatesCount } from "@/lib/services/furni-real-interaction"; import { browserHeaders } from "@/lib/services/import/core/browser-headers"; import { curlDownload, @@ -314,8 +315,12 @@ export async function cloneSingleFurni(params: { } } // Validate it is a real Nitro bundle. + let clonedNitroJson: Record | null = null; try { - parseNitroBundle(await fs.readFile(/*turbopackIgnore: true*/ nitroPath)); + const parsed = parseNitroBundle( + await fs.readFile(/*turbopackIgnore: true*/ nitroPath), + ); + clonedNitroJson = parsed.json as Record; } catch { await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {}); logger.warn("[clone-import] invalid .nitro bundle for", { classname }); @@ -367,8 +372,16 @@ export async function cloneSingleFurni(params: { // allow_walk = canstandon, allow_sit = cansiton, allow_lay = canlayon. const allowStack = stackHeight > 0 ? "1" : "0"; - // Auto-detect interaction modes count from classname/name - const autoInteraction = autoDetectInteraction(classname, entry.name); + // Auto-detect interaction modes count. The source furnidata flags and the + // cloned .nitro animation states are real data — keyword guessing is only + // used when neither is available, so there are no false positives. + const autoInteraction = autoDetectInteraction(classname, entry.name, { + cansiton: entry.cansiton, + canlayon: entry.canlayon, + canstandon: entry.canstandon, + logicType: (clonedNitroJson?.logicType as string) || undefined, + animationStates: nitroAnimationStatesCount(clonedNitroJson), + }); const interactionModesCount = autoInteraction.interactionModesCount; let newId: number; diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 86d4fb9bfc..0e291dd27b 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -35,6 +35,7 @@ import { type ConversionResult, convertSwfToNitro, extractIconFromSwf, + parseNitroBundle, } from "@/lib/services/swf-to-nitro"; import type { ImportSingleResult } from "@/types/furni"; @@ -60,6 +61,10 @@ export { CATEGORY_PAGE, CLASSNAME_RULES } from "@/lib/furni/auto-catalog"; import { CATEGORY_PAGE } from "@/lib/furni/auto-catalog"; import { autoDetectInteraction } from "@/lib/furni/auto-interaction"; +import { + lookupRealFurniFlags, + nitroAnimationStatesCount, +} from "@/lib/services/furni-real-interaction"; // ── Helpers ────────────────────────────────────────────────────────── @@ -868,8 +873,24 @@ export async function importSingleFurni(params: { // allow_stack: if it has height and you can put things on it const allowStack = dims.z > 0 ? "1" : "0"; - // Auto-detect interaction modes count from classname/name - const autoInteraction = autoDetectInteraction(classname, bestName); + // Auto-detect interaction modes count. Real flags (SWF logic XML / + // official furnidata) win over keyword guessing — no false positives. + let nitroJsonForModes: Record | null = null; + try { + const parsed = parseNitroBundle( + await fs.readFile(/*turbopackIgnore: true*/ nitroPath), + ); + nitroJsonForModes = parsed.json as Record; + } catch { + // no .nitro JSON available — keyword fallback only + } + const autoInteraction = autoDetectInteraction(classname, bestName, { + cansiton, + canlayon, + canstandon, + logicType, + animationStates: nitroAnimationStatesCount(nitroJsonForModes), + }); const interactionModesCount = autoInteraction.interactionModesCount; // Update items_base with ALL enriched data @@ -1007,16 +1028,56 @@ export async function importSingleFurni(params: { // ── Interaction modes count verification ────────────────────────────── +const nitroModesCache = new Map(); +const NITRO_MODES_CACHE_TTL_MS = 5 * 60_000; +let nitroModesCacheAt = 0; + +async function realNitroModes(classname: string): Promise { + if (Date.now() - nitroModesCacheAt > NITRO_MODES_CACHE_TTL_MS) { + nitroModesCache.clear(); + nitroModesCacheAt = Date.now(); + } + const base = classname.split("*")[0]; + const cached = nitroModesCache.get(base); + if (cached !== undefined) return cached; + try { + const nitroPath = path.join( + /*turbopackIgnore: true*/ FURNI_NITRO_DIR, + `${base}.nitro`, + ); + if (!existsSync(nitroPath)) { + nitroModesCache.set(base, undefined); + return undefined; + } + const parsed = parseNitroBundle( + await fs.readFile(/*turbopackIgnore: true*/ nitroPath), + ); + const modes = nitroAnimationStatesCount( + parsed.json as Record, + ); + nitroModesCache.set(base, modes); + return modes; + } catch { + nitroModesCache.set(base, undefined); + return undefined; + } +} + /** Verify and fix interaction_modes_count for ALL furniture in items_base. - * Run after import to ensure all items have correct values based on classname/name. */ + * Run after import to ensure all items have correct values based on real + * furniture data. Only items with authoritative data (furnidata flags or + * .nitro animation states) are touched — keyword guesses are never applied + * here, so there are no false positives. */ export async function verifyAndFixInteractionModesCount(): Promise<{ fixed: number; checked: number; + skipped: number; errors: string[]; }> { const errors: string[] = []; let fixed = 0; let checked = 0; + let skipped = 0; try { const allItems = await db @@ -1030,7 +1091,21 @@ export async function verifyAndFixInteractionModesCount(): Promise<{ for (const item of allItems) { checked++; - const auto = autoDetectInteraction(item.itemName, item.publicName); + const realFlags = await lookupRealFurniFlags(item.itemName); + + // Real data absent → do not guess, leave the item untouched. + if (realFlags.source === "none") { + skipped++; + continue; + } + + const animationModes = await realNitroModes(item.itemName); + const auto = autoDetectInteraction(item.itemName, item.publicName, { + cansiton: realFlags.cansiton, + canlayon: realFlags.canlayon, + canstandon: realFlags.canstandon, + animationStates: animationModes, + }); const expectedModes = auto.interactionModesCount; const currentModes = item.interactionModesCount ?? 0; @@ -1052,7 +1127,7 @@ export async function verifyAndFixInteractionModesCount(): Promise<{ errors.push(`Verification failed: ${(err as Error).message}`); } - return { fixed, checked, errors }; + return { fixed, checked, skipped, errors }; } // ── Delete helpers ─────────────────────────────────────────────────── diff --git a/src/lib/services/furni-real-interaction.ts b/src/lib/services/furni-real-interaction.ts new file mode 100644 index 0000000000..e8244c1a9d --- /dev/null +++ b/src/lib/services/furni-real-interaction.ts @@ -0,0 +1,121 @@ +import "server-only"; + +import { nitroAnimationStatesCount } from "@/lib/furni/auto-interaction"; +import { readFurniData } from "@/lib/services/furni-data"; +import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache"; + +export { nitroAnimationStatesCount }; + +/** + * Real interaction flags sourced from the authoritative furnidata instead of + * classname/name keywords. This is what makes auto-detection 100% accurate + * (no false positives) — every item's canSit/canLay/canStand comes from the + * real furniture data (SWF logic XML / official Habbo furnidata / bundled + * FurnitureData.json), never from a guessed keyword. + */ + +export interface RealFurniFlags { + cansiton: boolean; + canlayon: boolean; + canstandon: boolean; + specialtype: number; + source: "local" | "habbo" | "none"; +} + +const LOCAL_CACHE_TTL_MS = 60_000; +type LocalFlags = Omit & { __rev?: number }; +let localMap: Map | null = null; +let localMapAt = 0; + +async function loadLocalFurniFlags(): Promise> { + if (localMap && Date.now() - localMapAt < LOCAL_CACHE_TTL_MS) return localMap; + try { + const data = (await readFurniData()) as Record< + string, + { + furnitype?: Array>; + } + >; + const map = new Map(); + for (const section of ["roomitemtypes", "wallitemtypes"] as const) { + for (const item of data[section]?.furnitype ?? []) { + const classname = item.classname as string | undefined; + if (!classname) continue; + const candidate: LocalFlags = { + cansiton: !!item.cansiton, + canlayon: !!item.canlayon, + canstandon: !!item.canstandon, + specialtype: Number(item.specialtype) || 0, + __rev: Number(item.revision) || 0, + }; + const existing = map.get(classname); + if (!existing) { + map.set(classname, candidate); + continue; + } + // Duplicate classnames exist (stale placeholder rows at a higher + // revision with cleared flags, plus the real entry). A flag that + // is true in ANY entry is authoritative — a placeholder never + // invents a flag, it only clears one. This keeps e.g. a real + // rev-45508 "club_sofa" (cansiton) from being shadowed by a + // rev-59005 row that reset everything to false. + existing.cansiton = existing.cansiton || candidate.cansiton; + existing.canlayon = existing.canlayon || candidate.canlayon; + existing.canstandon = existing.canstandon || candidate.canstandon; + existing.specialtype = Math.max( + existing.specialtype, + candidate.specialtype, + ); + } + } + localMap = map; + localMapAt = Date.now(); + return map; + } catch { + localMap = new Map(); + localMapAt = Date.now(); + return localMap; + } +} + +/** + * Look up the real interaction flags for a classname. Priority: + * 1. Local furnidata (the CMS-maintained FurnitureData.json, reconciled + * against items_base on every import). + * 2. Official Habbo furnidata for the configured hotel. + * Returns `{ source: "none" }` when nothing is known — callers should then + * fall back to keyword detection rather than guessing. + */ +export async function lookupRealFurniFlags( + classname: string, +): Promise { + const base = classname.split("*")[0]; + + const local = await loadLocalFurniFlags(); + const localHit = local.get(classname) ?? local.get(base); + if (localHit) return { ...localHit, source: "local" }; + + try { + const habbo = await getOfficialHabboFurnidata(); + const h = habbo.get(classname) ?? habbo.get(base); + if (h) { + return { + cansiton: h.cansiton, + canlayon: h.canlayon, + canstandon: h.canstandon, + specialtype: h.specialtype, + source: "habbo", + }; + } + } catch { + // official cache unavailable — fall through to "none" + } + + return { + cansiton: false, + canlayon: false, + canstandon: false, + specialtype: 0, + source: "none", + }; +} diff --git a/src/lib/services/swf/nitro-builder.ts b/src/lib/services/swf/nitro-builder.ts index 37e41f9cb9..826f90eee9 100644 --- a/src/lib/services/swf/nitro-builder.ts +++ b/src/lib/services/swf/nitro-builder.ts @@ -1,4 +1,4 @@ -import { deflateSync, inflateSync } from "node:zlib"; +import { deflateSync, gunzipSync, inflateSync } from "node:zlib"; import type { SpriteImage } from "./image-decoder"; // ── Minimal PNG Encoder ──────────────────────────────────────────── @@ -267,7 +267,7 @@ export function parseNitroBundle(buffer: Buffer): { offset += 4; const jsonCompressed = buffer.subarray(offset, offset + jsonCompressedLen); offset += jsonCompressedLen; - const jsonStr = inflateSync(jsonCompressed).toString("utf-8"); + const jsonStr = inflateJsonOrGzip(jsonCompressed).toString("utf-8"); const json = JSON.parse(jsonStr); // File 2: PNG @@ -279,7 +279,20 @@ export function parseNitroBundle(buffer: Buffer): { offset += 4; const pngCompressed = buffer.subarray(offset, offset + pngCompressedLen); offset += pngCompressedLen; - const png = inflateSync(pngCompressed); + const png = inflateJsonOrGzip(pngCompressed); return { json, jsonFileName, png, pngFileName }; } + +/** + * Some bundled .nitro files (e.g. official /var/www/Gamedata furniture) + * gzip their contents instead of raw-deflate. Try inflate first, then fall + * back to gunzip so both formats parse. + */ +function inflateJsonOrGzip(input: Buffer): Buffer { + try { + return inflateSync(input); + } catch { + return gunzipSync(input); + } +} diff --git a/src/lib/services/upload-import.ts b/src/lib/services/upload-import.ts index d63ece69f1..93a777e2ba 100644 --- a/src/lib/services/upload-import.ts +++ b/src/lib/services/upload-import.ts @@ -15,6 +15,7 @@ import { IMPORTED_PAGE_CAPTION, IMPORTED_PAGE_CAPTION_SAVE, } from "@/lib/services/furni-import"; +import { nitroAnimationStatesCount } from "@/lib/services/furni-real-interaction"; import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; export interface UploadResult { @@ -330,8 +331,15 @@ export async function uploadSingleFurni(params: { const price = autoPriceFurni(classname); - // Auto-detect interaction modes count from classname/name - const autoInteraction = autoDetectInteraction(classname, name); + // Auto-detect interaction modes count. Real flags + .nitro animation + // states win over keyword guessing — no false positives. + const autoInteraction = autoDetectInteraction(classname, name, { + cansiton, + canlayon, + canstandon, + logicType: (nitroMeta?.logicType as string) || undefined, + animationStates: nitroAnimationStatesCount(nitroMeta), + }); const interactionModesCount = autoInteraction.interactionModesCount; let newId: number;