feat: auto interaction detection from real furniture data
CI / check (push) Successful in 31s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m17s

Replace classname/name keyword guessing with real data so sit/lay/stand
has zero false positives:
- autoDetectInteraction() now accepts real flags (cansiton/canlayon/
  canstandon), logicType, and animation-state count; real data wins and
  keywords are only a fallback when no real data exists.
- New furni-real-interaction helper reads the local FurnitureData.json
  (highest-revision entries win; a true flag in any duplicate is kept)
  and falls back to the official Habbo furnidata.
- interaction_modes_count now uses emulator conventions: dice=6,
  gate/teleport=2, roller=1, generic multistate = animation-state count,
  chairs/beds=1, plain items=0.
- verifyAndFixInteractionModesCount only touches items with real data
  and skips everything else (no keyword overwrites).
- parseNitroBundle falls back to gunzip for gzip-compressed .nitro files.
- Wired through furni-import, upload-import, clone-import, and the nitro
  editor auto-detect button.
This commit is contained in:
openhands committed 2026-08-19 20:28:24 +02:00
1 parent 121dda7249
commit 3fa192a108
8 files changed
+626 -78

No files matched your search

+80 -5
View File
@@ -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<string, unknown> | null = null;
try {
const parsed = parseNitroBundle(
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
);
nitroJsonForModes = parsed.json as Record<string, unknown>;
} 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<string, number | undefined>();
const NITRO_MODES_CACHE_TTL_MS = 5 * 60_000;
let nitroModesCacheAt = 0;
async function realNitroModes(classname: string): Promise<number | undefined> {
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<string, unknown>,
);
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 ───────────────────────────────────────────────────