feat: add auto furniture interaction detection
- New auto-interaction.ts: detects canSit/canLay/canStand from classname/name keywords - Maps keywords to interaction types (bench, chair, bed, table, etc.) - Returns clickLimit and interactionModesCount for proper click/interaction limits - Nitro editor: added auto-detect button with preview of detected settings - furni-import.ts: auto-sets interaction_modes_count during import - upload-import.ts: auto-detects for direct insert and SQL migrations - clone-import.ts: auto-detects when cloning from other hotels
This commit is contained in:
1 parent
0b4fc4559e
commit
dee843106f
8 files changed
+596
-25
No files matched your search
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
const LAY_KEYWORDS = [
|
||||
"bed",
|
||||
"mattress",
|
||||
"futon",
|
||||
"bunk",
|
||||
"cot",
|
||||
"hammock",
|
||||
"sleeping",
|
||||
"bedroll",
|
||||
"blanket",
|
||||
"pillow",
|
||||
"cushion",
|
||||
"lounger",
|
||||
"sunbed",
|
||||
"daybed",
|
||||
"divan",
|
||||
"waterbed",
|
||||
];
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
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",
|
||||
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",
|
||||
};
|
||||
|
||||
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,
|
||||
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,
|
||||
};
|
||||
|
||||
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,
|
||||
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,
|
||||
};
|
||||
|
||||
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)) {
|
||||
return kw;
|
||||
}
|
||||
}
|
||||
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",
|
||||
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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import { db, ItemsBase } from "@/lib/db";
|
||||
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
||||
import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
||||
@@ -324,16 +325,21 @@ export async function cloneSingleFurni(params: {
|
||||
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
|
||||
// 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);
|
||||
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||
|
||||
let newId: number;
|
||||
try {
|
||||
newId = await allocateItemsBaseId(async (nextId) => {
|
||||
await db.execute(sql`
|
||||
INSERT INTO items_base
|
||||
(id, sprite_id, public_name, item_name, type, width, length, stack_height,
|
||||
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
|
||||
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count, customparams)
|
||||
VALUES
|
||||
(${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight},
|
||||
${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${entry.customparams})`);
|
||||
${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${interactionModesCount}, ${entry.customparams})`);
|
||||
return nextId;
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -59,6 +59,7 @@ export const IMPORTED_PAGE_CAPTION = "Imported Furniture";
|
||||
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";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -117,7 +118,9 @@ export function buildItemsBaseEnrichmentUpdate(params: {
|
||||
canstandon: boolean;
|
||||
allowStack: string;
|
||||
interactionType: string;
|
||||
interactionModesCount?: number;
|
||||
}): SQL {
|
||||
const modesCount = params.interactionModesCount ?? 0;
|
||||
return sql`
|
||||
UPDATE items_base
|
||||
SET width = ${params.dims.x}, length = ${params.dims.y}, stack_height = ${params.dims.z},
|
||||
@@ -125,7 +128,8 @@ export function buildItemsBaseEnrichmentUpdate(params: {
|
||||
allow_lay = ${params.canlayon ? "1" : "0"},
|
||||
allow_walk = ${params.canstandon ? "1" : "0"},
|
||||
allow_stack = ${params.allowStack},
|
||||
interaction_type = ${params.interactionType}
|
||||
interaction_type = ${params.interactionType},
|
||||
interaction_modes_count = ${modesCount}
|
||||
WHERE id = ${params.id}
|
||||
`;
|
||||
}
|
||||
@@ -864,6 +868,10 @@ 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);
|
||||
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||
|
||||
// Update items_base with ALL enriched data
|
||||
try {
|
||||
const update = buildItemsBaseEnrichmentUpdate({
|
||||
@@ -874,6 +882,7 @@ export async function importSingleFurni(params: {
|
||||
canstandon,
|
||||
allowStack,
|
||||
interactionType,
|
||||
interactionModesCount,
|
||||
});
|
||||
await db.execute(update);
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { db, ItemsBase } from "@/lib/db";
|
||||
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
||||
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
|
||||
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
|
||||
import {
|
||||
@@ -158,6 +159,7 @@ async function writeSqlMigration(params: {
|
||||
customparams: string;
|
||||
price: { credits: number; points: number; pointsType: number };
|
||||
categoryKey: string;
|
||||
interactionModesCount: number;
|
||||
}): Promise<string> {
|
||||
const num = await findNextMigrationNumber();
|
||||
const padded = String(num).padStart(4, "0");
|
||||
@@ -193,12 +195,12 @@ async function writeSqlMigration(params: {
|
||||
"",
|
||||
`INSERT INTO items_base`,
|
||||
` (id, sprite_id, public_name, item_name, type, width, length, stack_height,`,
|
||||
` allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)`,
|
||||
` allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count, customparams)`,
|
||||
`VALUES`,
|
||||
` (@next_id, @next_id, ${escapeSql(params.name)}, ${escapeSql(params.classname)},`,
|
||||
` ${escapeSql(params.itemType)}, ${params.xdim}, ${params.ydim}, ${params.stackHeight},`,
|
||||
` ${params.allowStack}, ${params.cansiton ? "1" : "0"}, ${params.canlayon ? "1" : "0"}, ${params.canstandon ? "1" : "0"},`,
|
||||
` ${escapeSql(params.interactionType)}, ${escapeSql(params.customparams)});`,
|
||||
` ${escapeSql(params.interactionType)}, ${params.interactionModesCount}, ${escapeSql(params.customparams)});`,
|
||||
"",
|
||||
`INSERT INTO catalog_items`,
|
||||
` (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)`,
|
||||
@@ -328,16 +330,20 @@ export async function uploadSingleFurni(params: {
|
||||
|
||||
const price = autoPriceFurni(classname);
|
||||
|
||||
// Auto-detect interaction modes count from classname/name
|
||||
const autoInteraction = autoDetectInteraction(classname, name);
|
||||
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||
|
||||
let newId: number;
|
||||
try {
|
||||
newId = await allocateItemsBaseId(async (nextId) => {
|
||||
await db.execute(sql`
|
||||
INSERT INTO items_base
|
||||
(id, sprite_id, public_name, item_name, type, width, length, stack_height,
|
||||
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
|
||||
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count, customparams)
|
||||
VALUES
|
||||
(${nextId}, ${nextId}, ${name}, ${classname}, ${itemType}, ${xdim}, ${ydim}, ${stackHeight},
|
||||
${allowStack}, ${cansiton ? "1" : "0"}, ${canlayon ? "1" : "0"}, ${canstandon ? "1" : "0"}, ${userInteractionType}, ${customparams})`);
|
||||
${allowStack}, ${cansiton ? "1" : "0"}, ${canlayon ? "1" : "0"}, ${canstandon ? "1" : "0"}, ${userInteractionType}, ${interactionModesCount}, ${customparams})`);
|
||||
return nextId;
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -402,6 +408,7 @@ export async function uploadSingleFurni(params: {
|
||||
customparams,
|
||||
price,
|
||||
categoryKey: classifyFurni(classname, itemType),
|
||||
interactionModesCount,
|
||||
});
|
||||
warnings.push(`SQL migration written: ${sqlFile}`);
|
||||
} catch (err) {
|
||||
|
||||
Reference in new issue
Block a user