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
@@ -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<string, unknown> | null = null;
|
||||
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 {
|
||||
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;
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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";
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user