Files
Epicnabbo-Catalogus-Updated…/src/lib/furni/auto-interaction.ts
T
openhands 4b7cb519df fix: furniture interaction detection and automatic catalog offer_id rebuild
Interaction detection:
- Trust SWF-derived sit/lay/stand flags only when the logic XML actually
  contains action data (new hasActions metadata); otherwise fall back to
  keyword detection so custom furni without <action> nodes are still
  classified correctly
- Add French/Dutch/German/Spanish/Italian keywords (chaise, banquette,
  stoel, silla, sedia, stuhl, tafel, mesa, ...) to sit/lay/stand detection
  with token-boundary matching to avoid false positives like bedside_table
- Unify interaction_modes_count priority: mechanic fixed modes, then raw
  animation state count, then sit/lay fallback, then keyword default
- Detect mechanic type even when real flags are not used

Catalog integrity:
- Skip duplicate catalog_items inserts in clone and upload imports
- Re-check live catalog rows before applying generated repair SQL
- Add rebuildCatalogOfferIds() which rebuilds every catalog_items.offer_id
  from the local FurnitureData.json (matched by entry id, then classname)
  and run it after every import path (single, batch, batch-regen, clone)
2026-08-21 13:39:21 +02:00

717 lines
15 KiB
TypeScript

/**
* Auto-detect furniture interaction capabilities from classname/name.
* Determines: canSit, canLay, canStand, interactionType, clickLimit
*/
export interface AutoInteractionResult {
canSit: boolean;
canLay: boolean;
canStand: boolean;
interactionType: string;
clickLimit: number;
interactionModesCount: number;
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;
/**
* True when the sit/lay/stand flags come from a curated source (official
* furnidata) or from SWF logic that actually contains <action> nodes.
* When false the flags were defaulted (asset had no action data at all),
* so keyword detection may still contribute.
*/
hasActionData?: boolean;
}
function realSitLayStand(
classname: string,
name?: string,
real?: RealInteractionData,
): {
canSit: boolean;
canLay: boolean;
canStand: boolean;
usedReal: boolean;
} {
// When real flags are present AND trustworthy (curated furnidata, or SWF
// logic that actually declared <action> nodes), trust them completely —
// keyword detection would only introduce false positives. All-false flags
// without any action data mean "unknown", not "confirmed false".
if (
real &&
(real.hasActionData === true ||
real.cansiton === true ||
real.canlayon === true ||
real.canstandon === true)
) {
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;
// Classname mechanics (dice/gate/teleport/…) are deterministic — detect
// them regardless of whether real flags were trusted.
const mechanicType = realMechanicType(classname);
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 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"));
} else if (layMatch) {
matchedKeyword = layMatch;
interactionType = INTERACTION_TYPE_MAP[layMatch] || "bed";
} else if (sitMatch) {
matchedKeyword = sitMatch;
interactionType = INTERACTION_TYPE_MAP[sitMatch] || "chair";
} else if (standMatch) {
matchedKeyword = standMatch;
interactionType = INTERACTION_TYPE_MAP[standMatch] || "default";
}
if (!usedReal && mechanicType) {
interactionType = mechanicType;
}
// Modes count = how many states clicking cycles through. Priority:
// 1. Emulator-fixed value per mechanic (dice = 6, gate/teleport = 2, …)
// 2. Real animation-state count from the .nitro asset (renderable states)
// 3. Sit/lay furniture without state info → 1
// 4. Keyword default (chairs 1, tables 0) / plain items → 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 if (matchedKeyword) {
interactionModesCount = INTERACTION_MODES[matchedKeyword] ?? 0;
} else {
interactionModesCount = 0;
}
clickLimit =
fixedModes !== undefined
? 1
: canLay || canSit || canStand
? 1
: matchedKeyword
? (CLICK_LIMITS[matchedKeyword] ?? 0)
: 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",
"sofa",
"couch",
"chair",
"stool",
"seat",
"armchair",
"loveseat",
"settee",
"ottoman",
"pouf",
"beanbag",
"bean_bag",
"rocking",
"swing",
"hammock",
"throne",
"wheelchair",
"toilet",
"potty",
// French
"chaise",
"banquette",
"tabouret",
"fauteuil",
"canap",
"banc",
// Dutch
"stoel",
"kruk",
// Spanish
"silla",
"taburete",
// Italian
"sedia",
"poltrona",
// German
"stuhl",
"sessel",
"hocker",
];
const LAY_KEYWORDS = [
"bed",
"mattress",
"futon",
"bunk",
"cot",
"hammock",
"sleeping",
"bedroll",
"blanket",
"pillow",
"cushion",
"lounger",
"sunbed",
"daybed",
"divan",
"waterbed",
// French
"matelas",
"couette",
// Dutch
"bedden",
// Spanish
"cama",
// Italian
"letto",
// German
"bett",
];
const STAND_KEYWORDS = [
"table",
"desk",
"counter",
"bar",
"shelf",
"cabinet",
"wardrobe",
"dresser",
"bookcase",
"rack",
"stand",
"pedestal",
"platform",
"stage",
"floor",
"tile",
"carpet",
"rug",
"path",
"road",
"grass",
"dirt",
"sand",
"snow",
"ice",
"water",
"pool",
"fountain",
// French
"tafel",
"bureau",
"etagere",
// Spanish / Italian
"mesa",
"tavolo",
// German
"schrank",
"kommode",
];
const INTERACTION_TYPE_MAP: Record<string, string> = {
bench: "bench",
bank: "bench",
sofa: "sofa",
couch: "sofa",
chair: "chair",
stool: "stool",
seat: "chair",
armchair: "armchair",
loveseat: "sofa",
settee: "sofa",
ottoman: "ottoman",
pouf: "ottoman",
beanbag: "beanbag",
rocking: "rocking_chair",
swing: "swing",
hammock: "hammock",
throne: "throne",
wheelchair: "wheelchair",
toilet: "toilet",
potty: "toilet",
bed: "bed",
mattress: "bed",
futon: "bed",
bunk: "bunkbed",
cot: "bed",
sleeping: "bed",
bedroll: "bed",
blanket: "bed",
pillow: "bed",
cushion: "bed",
lounger: "lounger",
sunbed: "sunbed",
daybed: "daybed",
divan: "divan",
waterbed: "waterbed",
matelas: "bed",
couette: "bed",
bedden: "bed",
cama: "bed",
letto: "bed",
bett: "bed",
chaise: "chair",
banquette: "bench",
tabouret: "stool",
fauteuil: "armchair",
canap: "sofa",
banc: "bench",
stoel: "chair",
kruk: "stool",
silla: "chair",
taburete: "stool",
sedia: "chair",
poltrona: "armchair",
stuhl: "chair",
sessel: "armchair",
hocker: "stool",
table: "table",
desk: "desk",
counter: "counter",
bar: "bar",
shelf: "shelf",
cabinet: "cabinet",
wardrobe: "wardrobe",
dresser: "dresser",
bookcase: "bookcase",
rack: "rack",
stand: "stand",
pedestal: "pedestal",
platform: "platform",
stage: "stage",
tafel: "table",
bureau: "desk",
etagere: "shelf",
mesa: "table",
tavolo: "table",
schrank: "wardrobe",
kommode: "dresser",
};
const INTERACTION_MODES: Record<string, number> = {
bench: 1,
bank: 1,
sofa: 1,
couch: 1,
chair: 1,
stool: 1,
seat: 1,
armchair: 1,
loveseat: 1,
settee: 1,
ottoman: 1,
pouf: 1,
beanbag: 1,
rocking: 1,
swing: 1,
hammock: 1,
throne: 1,
wheelchair: 1,
toilet: 1,
potty: 1,
bed: 1,
mattress: 1,
futon: 1,
bunk: 1,
cot: 1,
sleeping: 1,
bedroll: 1,
blanket: 1,
pillow: 1,
cushion: 1,
lounger: 1,
sunbed: 1,
daybed: 1,
divan: 1,
waterbed: 1,
matelas: 1,
couette: 1,
bedden: 1,
cama: 1,
letto: 1,
bett: 1,
chaise: 1,
banquette: 1,
tabouret: 1,
fauteuil: 1,
canap: 1,
banc: 1,
stoel: 1,
kruk: 1,
silla: 1,
taburete: 1,
sedia: 1,
poltrona: 1,
stuhl: 1,
sessel: 1,
hocker: 1,
table: 0,
desk: 0,
counter: 0,
bar: 0,
shelf: 0,
cabinet: 0,
wardrobe: 0,
dresser: 0,
bookcase: 0,
rack: 0,
stand: 0,
pedestal: 0,
platform: 0,
stage: 0,
floor: 0,
tile: 0,
carpet: 0,
rug: 0,
path: 0,
road: 0,
grass: 0,
dirt: 0,
sand: 0,
snow: 0,
ice: 0,
water: 0,
pool: 0,
fountain: 0,
tafel: 0,
bureau: 0,
etagere: 0,
mesa: 0,
tavolo: 0,
schrank: 0,
kommode: 0,
};
const CLICK_LIMITS: Record<string, number> = {
bench: 1,
bank: 1,
sofa: 1,
couch: 1,
chair: 1,
stool: 1,
seat: 1,
armchair: 1,
loveseat: 1,
settee: 1,
ottoman: 1,
pouf: 1,
beanbag: 1,
rocking: 1,
swing: 1,
hammock: 1,
throne: 1,
wheelchair: 1,
toilet: 1,
potty: 1,
bed: 1,
mattress: 1,
futon: 1,
bunk: 1,
cot: 1,
sleeping: 1,
bedroll: 1,
blanket: 1,
pillow: 1,
cushion: 1,
lounger: 1,
sunbed: 1,
daybed: 1,
divan: 1,
waterbed: 1,
matelas: 1,
couette: 1,
bedden: 1,
cama: 1,
letto: 1,
bett: 1,
chaise: 1,
banquette: 1,
tabouret: 1,
fauteuil: 1,
canap: 1,
banc: 1,
stoel: 1,
kruk: 1,
silla: 1,
taburete: 1,
sedia: 1,
poltrona: 1,
stuhl: 1,
sessel: 1,
hocker: 1,
table: 0,
desk: 0,
counter: 0,
bar: 0,
shelf: 0,
cabinet: 0,
wardrobe: 0,
dresser: 0,
bookcase: 0,
rack: 0,
stand: 0,
pedestal: 0,
platform: 0,
stage: 0,
floor: 0,
tile: 0,
carpet: 0,
rug: 0,
path: 0,
road: 0,
grass: 0,
dirt: 0,
sand: 0,
snow: 0,
ice: 0,
water: 0,
pool: 0,
fountain: 0,
tafel: 0,
bureau: 0,
etagere: 0,
mesa: 0,
tavolo: 0,
schrank: 0,
kommode: 0,
};
function normalizeString(str: string): string {
return str.toLowerCase().replace(/[^a-z0-9_]/g, "_");
}
function findMatch(normalized: string, keywords: string[]): string | null {
for (const kw of keywords) {
if (!normalized.includes(kw)) continue;
// Short keywords (e.g. "bed", "lit", "bar") must match on token
// boundaries so "bedside_table" or "split" don't false-positive.
if (kw.length < 5) {
const boundary = new RegExp(`(^|_)${kw}(_|$)`);
if (!boundary.test(normalized)) continue;
}
return kw;
}
return null;
}
export function getInteractionTypeLabel(type: string): string {
const labels: Record<string, string> = {
bench: "Bench / Bank",
sofa: "Sofa / Couch",
chair: "Chair",
stool: "Stool",
armchair: "Armchair",
ottoman: "Ottoman / Pouf",
beanbag: "Bean Bag",
rocking_chair: "Rocking Chair",
swing: "Swing",
hammock: "Hammock",
throne: "Throne",
wheelchair: "Wheelchair",
toilet: "Toilet",
bed: "Bed",
bunkbed: "Bunk Bed",
lounger: "Lounger / Sunbed",
daybed: "Daybed",
divan: "Divan",
waterbed: "Waterbed",
table: "Table",
desk: "Desk",
counter: "Counter",
bar: "Bar",
shelf: "Shelf",
cabinet: "Cabinet",
wardrobe: "Wardrobe",
dresser: "Dresser",
bookcase: "Bookcase",
rack: "Rack",
stand: "Stand",
pedestal: "Pedestal",
platform: "Platform",
stage: "Stage",
default: "Default (no interaction)",
};
return labels[type] || type;
}