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
@@ -35,6 +35,7 @@ import { adminFetch } from "@/lib/admin-fetch";
|
|||||||
import {
|
import {
|
||||||
autoDetectInteraction,
|
autoDetectInteraction,
|
||||||
getInteractionTypeLabel,
|
getInteractionTypeLabel,
|
||||||
|
nitroAnimationStatesCount,
|
||||||
} from "@/lib/furni/auto-interaction";
|
} from "@/lib/furni/auto-interaction";
|
||||||
|
|
||||||
interface NitroEditorDialogProps {
|
interface NitroEditorDialogProps {
|
||||||
@@ -154,7 +155,20 @@ export function NitroEditorDialog({
|
|||||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleAutoDetect = () => {
|
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<string, unknown>)
|
||||||
|
: 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);
|
setAutoDetectResult(result);
|
||||||
setFlags({
|
setFlags({
|
||||||
canstandon: result.canStand,
|
canstandon: result.canStand,
|
||||||
|
|||||||
@@ -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;
|
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 = [
|
const SIT_KEYWORDS = [
|
||||||
"bench",
|
"bench",
|
||||||
"bank",
|
"bank",
|
||||||
@@ -284,70 +527,6 @@ function findMatch(normalized: string, keywords: string[]): string | null {
|
|||||||
return 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 {
|
export function getInteractionTypeLabel(type: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
bench: "Bench / Bank",
|
bench: "Bench / Bank",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ensureDirectories,
|
ensureDirectories,
|
||||||
getOrCreateCategoryPage,
|
getOrCreateCategoryPage,
|
||||||
} from "@/lib/services/furni-import";
|
} from "@/lib/services/furni-import";
|
||||||
|
import { nitroAnimationStatesCount } from "@/lib/services/furni-real-interaction";
|
||||||
import { browserHeaders } from "@/lib/services/import/core/browser-headers";
|
import { browserHeaders } from "@/lib/services/import/core/browser-headers";
|
||||||
import {
|
import {
|
||||||
curlDownload,
|
curlDownload,
|
||||||
@@ -314,8 +315,12 @@ export async function cloneSingleFurni(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Validate it is a real Nitro bundle.
|
// Validate it is a real Nitro bundle.
|
||||||
|
let clonedNitroJson: Record<string, unknown> | null = null;
|
||||||
try {
|
try {
|
||||||
parseNitroBundle(await fs.readFile(/*turbopackIgnore: true*/ nitroPath));
|
const parsed = parseNitroBundle(
|
||||||
|
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
|
||||||
|
);
|
||||||
|
clonedNitroJson = parsed.json as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
|
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
|
||||||
logger.warn("[clone-import] invalid .nitro bundle for", { classname });
|
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.
|
// allow_walk = canstandon, allow_sit = cansiton, allow_lay = canlayon.
|
||||||
const allowStack = stackHeight > 0 ? "1" : "0";
|
const allowStack = stackHeight > 0 ? "1" : "0";
|
||||||
|
|
||||||
// Auto-detect interaction modes count from classname/name
|
// Auto-detect interaction modes count. The source furnidata flags and the
|
||||||
const autoInteraction = autoDetectInteraction(classname, entry.name);
|
// 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;
|
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||||
|
|
||||||
let newId: number;
|
let newId: number;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
type ConversionResult,
|
type ConversionResult,
|
||||||
convertSwfToNitro,
|
convertSwfToNitro,
|
||||||
extractIconFromSwf,
|
extractIconFromSwf,
|
||||||
|
parseNitroBundle,
|
||||||
} from "@/lib/services/swf-to-nitro";
|
} from "@/lib/services/swf-to-nitro";
|
||||||
import type { ImportSingleResult } from "@/types/furni";
|
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 { CATEGORY_PAGE } from "@/lib/furni/auto-catalog";
|
||||||
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
||||||
|
import {
|
||||||
|
lookupRealFurniFlags,
|
||||||
|
nitroAnimationStatesCount,
|
||||||
|
} from "@/lib/services/furni-real-interaction";
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -868,8 +873,24 @@ export async function importSingleFurni(params: {
|
|||||||
// allow_stack: if it has height and you can put things on it
|
// allow_stack: if it has height and you can put things on it
|
||||||
const allowStack = dims.z > 0 ? "1" : "0";
|
const allowStack = dims.z > 0 ? "1" : "0";
|
||||||
|
|
||||||
// Auto-detect interaction modes count from classname/name
|
// Auto-detect interaction modes count. Real flags (SWF logic XML /
|
||||||
const autoInteraction = autoDetectInteraction(classname, bestName);
|
// 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;
|
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||||
|
|
||||||
// Update items_base with ALL enriched data
|
// Update items_base with ALL enriched data
|
||||||
@@ -1007,16 +1028,56 @@ export async function importSingleFurni(params: {
|
|||||||
|
|
||||||
// ── Interaction modes count verification ──────────────────────────────
|
// ── 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.
|
/** 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<{
|
export async function verifyAndFixInteractionModesCount(): Promise<{
|
||||||
fixed: number;
|
fixed: number;
|
||||||
checked: number;
|
checked: number;
|
||||||
|
skipped: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}> {
|
}> {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let fixed = 0;
|
let fixed = 0;
|
||||||
let checked = 0;
|
let checked = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allItems = await db
|
const allItems = await db
|
||||||
@@ -1030,7 +1091,21 @@ export async function verifyAndFixInteractionModesCount(): Promise<{
|
|||||||
|
|
||||||
for (const item of allItems) {
|
for (const item of allItems) {
|
||||||
checked++;
|
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 expectedModes = auto.interactionModesCount;
|
||||||
const currentModes = item.interactionModesCount ?? 0;
|
const currentModes = item.interactionModesCount ?? 0;
|
||||||
|
|
||||||
@@ -1052,7 +1127,7 @@ export async function verifyAndFixInteractionModesCount(): Promise<{
|
|||||||
errors.push(`Verification failed: ${(err as Error).message}`);
|
errors.push(`Verification failed: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { fixed, checked, errors };
|
return { fixed, checked, skipped, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Delete helpers ───────────────────────────────────────────────────
|
// ── Delete helpers ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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<RealFurniFlags, "source"> & { __rev?: number };
|
||||||
|
let localMap: Map<string, LocalFlags> | null = null;
|
||||||
|
let localMapAt = 0;
|
||||||
|
|
||||||
|
async function loadLocalFurniFlags(): Promise<Map<string, LocalFlags>> {
|
||||||
|
if (localMap && Date.now() - localMapAt < LOCAL_CACHE_TTL_MS) return localMap;
|
||||||
|
try {
|
||||||
|
const data = (await readFurniData()) as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
furnitype?: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
const map = new Map<string, LocalFlags>();
|
||||||
|
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<RealFurniFlags> {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { deflateSync, inflateSync } from "node:zlib";
|
import { deflateSync, gunzipSync, inflateSync } from "node:zlib";
|
||||||
import type { SpriteImage } from "./image-decoder";
|
import type { SpriteImage } from "./image-decoder";
|
||||||
|
|
||||||
// ── Minimal PNG Encoder ────────────────────────────────────────────
|
// ── Minimal PNG Encoder ────────────────────────────────────────────
|
||||||
@@ -267,7 +267,7 @@ export function parseNitroBundle(buffer: Buffer): {
|
|||||||
offset += 4;
|
offset += 4;
|
||||||
const jsonCompressed = buffer.subarray(offset, offset + jsonCompressedLen);
|
const jsonCompressed = buffer.subarray(offset, offset + jsonCompressedLen);
|
||||||
offset += jsonCompressedLen;
|
offset += jsonCompressedLen;
|
||||||
const jsonStr = inflateSync(jsonCompressed).toString("utf-8");
|
const jsonStr = inflateJsonOrGzip(jsonCompressed).toString("utf-8");
|
||||||
const json = JSON.parse(jsonStr);
|
const json = JSON.parse(jsonStr);
|
||||||
|
|
||||||
// File 2: PNG
|
// File 2: PNG
|
||||||
@@ -279,7 +279,20 @@ export function parseNitroBundle(buffer: Buffer): {
|
|||||||
offset += 4;
|
offset += 4;
|
||||||
const pngCompressed = buffer.subarray(offset, offset + pngCompressedLen);
|
const pngCompressed = buffer.subarray(offset, offset + pngCompressedLen);
|
||||||
offset += pngCompressedLen;
|
offset += pngCompressedLen;
|
||||||
const png = inflateSync(pngCompressed);
|
const png = inflateJsonOrGzip(pngCompressed);
|
||||||
|
|
||||||
return { json, jsonFileName, png, pngFileName };
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
IMPORTED_PAGE_CAPTION,
|
IMPORTED_PAGE_CAPTION,
|
||||||
IMPORTED_PAGE_CAPTION_SAVE,
|
IMPORTED_PAGE_CAPTION_SAVE,
|
||||||
} from "@/lib/services/furni-import";
|
} from "@/lib/services/furni-import";
|
||||||
|
import { nitroAnimationStatesCount } from "@/lib/services/furni-real-interaction";
|
||||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||||
|
|
||||||
export interface UploadResult {
|
export interface UploadResult {
|
||||||
@@ -330,8 +331,15 @@ export async function uploadSingleFurni(params: {
|
|||||||
|
|
||||||
const price = autoPriceFurni(classname);
|
const price = autoPriceFurni(classname);
|
||||||
|
|
||||||
// Auto-detect interaction modes count from classname/name
|
// Auto-detect interaction modes count. Real flags + .nitro animation
|
||||||
const autoInteraction = autoDetectInteraction(classname, name);
|
// 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;
|
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||||
|
|
||||||
let newId: number;
|
let newId: number;
|
||||||
|
|||||||
Reference in new issue
Block a user