feat: auto interaction detection from real furniture data
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:
1 parent
121dda7249
commit
3fa192a108
8 files changed
+626
-78
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, number> = {
|
||||
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<string, unknown> | 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<string, unknown>);
|
||||
// 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<string, string> = {
|
||||
bench: "Bench / Bank",
|
||||
|
||||
Reference in new issue
Block a user