feat: add auto furniture interaction detection
CI / check (push) Successful in 25s
CI / release (push) Skipped
CI / deploy (push) Successful in 59s

- 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:
openhands committed 2026-08-18 22:33:40 +02:00
1 parent 0b4fc4559e
commit dee843106f
8 files changed
+596 -25

No files matched your search

+11 -9
View File
@@ -1,17 +1,19 @@
module.exports = { module.exports = {
apps: [ apps: [
{ {
name: "epicnextcms", name: 'epic-next-app',
script: "pnpm", script: 'node_modules/next/dist/bin/next',
args: "start", args: 'start',
instances: "max", cwd: '/var/www/atom-nexst',
exec_mode: "cluster", instances: 1,
node_args: "--v8-pool-size=4", autorestart: true,
max_memory_restart: "1G", watch: false,
max_memory_restart: '1G',
node_args: '--max-old-space-size=1024',
env: { env: {
NODE_ENV: "production", NODE_ENV: 'production',
PORT: 3002 PORT: 3002
} }
} }
] ]
}; };
@@ -32,9 +32,14 @@ import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import {
autoDetectInteraction,
getInteractionTypeLabel,
} from "@/lib/furni/auto-interaction";
interface NitroEditorDialogProps { interface NitroEditorDialogProps {
classname: string; classname: string;
name?: string;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
@@ -122,6 +127,7 @@ function CopyClassname({ value }: { value: string }) {
export function NitroEditorDialog({ export function NitroEditorDialog({
classname, classname,
name = "",
open, open,
onOpenChange, onOpenChange,
}: NitroEditorDialogProps) { }: NitroEditorDialogProps) {
@@ -142,8 +148,22 @@ export function NitroEditorDialog({
cansiton: false, cansiton: false,
canlayon: false, canlayon: false,
}); });
const [autoDetectResult, setAutoDetectResult] = useState<ReturnType<
typeof autoDetectInteraction
> | null>(null);
const [jsonError, setJsonError] = useState<string | null>(null); const [jsonError, setJsonError] = useState<string | null>(null);
const handleAutoDetect = () => {
const result = autoDetectInteraction(classname, name);
setAutoDetectResult(result);
setFlags({
canstandon: result.canStand,
cansiton: result.canSit,
canlayon: result.canLay,
});
toast.success(`Auto-detected: ${result.reason}`);
};
const patchMeta = (path: string, value: unknown) => { const patchMeta = (path: string, value: unknown) => {
setMetadata((prev) => setMetadata((prev) =>
prev == null ? prev : updateNested(prev, path, value), prev == null ? prev : updateNested(prev, path, value),
@@ -497,6 +517,53 @@ export function NitroEditorDialog({
/> />
</div> </div>
</div> </div>
<div className="mt-4 flex flex-col gap-2">
<Button
variant="outline"
size="sm"
onClick={handleAutoDetect}
className="gap-1.5 w-full"
>
<Sparkles className="w-3.5 h-3.5" />
Auto-detect from name
</Button>
{autoDetectResult && (
<div className="rounded-md bg-muted/50 p-3 text-xs space-y-1">
<div className="flex items-center gap-1.5 flex-wrap">
<Badge
variant={
autoDetectResult.canLay ? "default" : "secondary"
}
className="gap-1"
>
{autoDetectResult.canLay ? (
<Layers className="w-2.5 h-2.5" />
) : autoDetectResult.canSit ? (
<MousePointerClick className="w-2.5 h-2.5" />
) : (
<MousePointerClick className="w-2.5 h-2.5" />
)}
{getInteractionTypeLabel(
autoDetectResult.interactionType,
)}
</Badge>
{autoDetectResult.clickLimit > 0 && (
<Badge variant="outline" className="gap-1">
Click limit: {autoDetectResult.clickLimit}
</Badge>
)}
{autoDetectResult.interactionModesCount > 0 && (
<Badge variant="outline" className="gap-1">
Modes: {autoDetectResult.interactionModesCount}
</Badge>
)}
</div>
<p className="text-muted-foreground">
{autoDetectResult.reason}
</p>
</div>
)}
</div>
</section> </section>
</div> </div>
@@ -1374,6 +1374,7 @@ export function StudioClient({
<NitroEditorDialog <NitroEditorDialog
classname={editingClassname ?? ""} classname={editingClassname ?? ""}
name={detail?.name ?? ""}
open={editingClassname !== null} open={editingClassname !== null}
onOpenChange={(open) => !open && setEditingClassname(null)} onOpenChange={(open) => !open && setEditingClassname(null)}
/> />
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { eq, inArray, sql } from "drizzle-orm"; import { eq, inArray, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db"; 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 { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { extractFurniIconPng } from "@/lib/services/clone-icon"; 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_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
// 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
const autoInteraction = autoDetectInteraction(classname, entry.name);
const interactionModesCount = autoInteraction.interactionModesCount;
let newId: number; let newId: number;
try { try {
newId = await allocateItemsBaseId(async (nextId) => { newId = await allocateItemsBaseId(async (nextId) => {
await db.execute(sql` await db.execute(sql`
INSERT INTO items_base INSERT INTO items_base
(id, sprite_id, public_name, item_name, type, width, length, stack_height, (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 VALUES
(${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight}, (${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; return nextId;
}); });
} catch (err) { } catch (err) {
+10 -1
View File
@@ -59,6 +59,7 @@ export const IMPORTED_PAGE_CAPTION = "Imported Furniture";
export { CATEGORY_PAGE, CLASSNAME_RULES } from "@/lib/furni/auto-catalog"; 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";
// ── Helpers ────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────
@@ -117,7 +118,9 @@ export function buildItemsBaseEnrichmentUpdate(params: {
canstandon: boolean; canstandon: boolean;
allowStack: string; allowStack: string;
interactionType: string; interactionType: string;
interactionModesCount?: number;
}): SQL { }): SQL {
const modesCount = params.interactionModesCount ?? 0;
return sql` return sql`
UPDATE items_base UPDATE items_base
SET width = ${params.dims.x}, length = ${params.dims.y}, stack_height = ${params.dims.z}, 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_lay = ${params.canlayon ? "1" : "0"},
allow_walk = ${params.canstandon ? "1" : "0"}, allow_walk = ${params.canstandon ? "1" : "0"},
allow_stack = ${params.allowStack}, allow_stack = ${params.allowStack},
interaction_type = ${params.interactionType} interaction_type = ${params.interactionType},
interaction_modes_count = ${modesCount}
WHERE id = ${params.id} 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 // 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
const autoInteraction = autoDetectInteraction(classname, bestName);
const interactionModesCount = autoInteraction.interactionModesCount;
// Update items_base with ALL enriched data // Update items_base with ALL enriched data
try { try {
const update = buildItemsBaseEnrichmentUpdate({ const update = buildItemsBaseEnrichmentUpdate({
@@ -874,6 +882,7 @@ export async function importSingleFurni(params: {
canstandon, canstandon,
allowStack, allowStack,
interactionType, interactionType,
interactionModesCount,
}); });
await db.execute(update); await db.execute(update);
} catch (err) { } catch (err) {
+11 -4
View File
@@ -2,6 +2,7 @@ import { existsSync, promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase } from "@/lib/db";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data"; import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
import { import {
@@ -158,6 +159,7 @@ async function writeSqlMigration(params: {
customparams: string; customparams: string;
price: { credits: number; points: number; pointsType: number }; price: { credits: number; points: number; pointsType: number };
categoryKey: string; categoryKey: string;
interactionModesCount: number;
}): Promise<string> { }): Promise<string> {
const num = await findNextMigrationNumber(); const num = await findNextMigrationNumber();
const padded = String(num).padStart(4, "0"); const padded = String(num).padStart(4, "0");
@@ -193,12 +195,12 @@ async function writeSqlMigration(params: {
"", "",
`INSERT INTO items_base`, `INSERT INTO items_base`,
` (id, sprite_id, public_name, item_name, type, width, length, stack_height,`, ` (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`, `VALUES`,
` (@next_id, @next_id, ${escapeSql(params.name)}, ${escapeSql(params.classname)},`, ` (@next_id, @next_id, ${escapeSql(params.name)}, ${escapeSql(params.classname)},`,
` ${escapeSql(params.itemType)}, ${params.xdim}, ${params.ydim}, ${params.stackHeight},`, ` ${escapeSql(params.itemType)}, ${params.xdim}, ${params.ydim}, ${params.stackHeight},`,
` ${params.allowStack}, ${params.cansiton ? "1" : "0"}, ${params.canlayon ? "1" : "0"}, ${params.canstandon ? "1" : "0"},`, ` ${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`, `INSERT INTO catalog_items`,
` (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)`, ` (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); const price = autoPriceFurni(classname);
// Auto-detect interaction modes count from classname/name
const autoInteraction = autoDetectInteraction(classname, name);
const interactionModesCount = autoInteraction.interactionModesCount;
let newId: number; let newId: number;
try { try {
newId = await allocateItemsBaseId(async (nextId) => { newId = await allocateItemsBaseId(async (nextId) => {
await db.execute(sql` await db.execute(sql`
INSERT INTO items_base INSERT INTO items_base
(id, sprite_id, public_name, item_name, type, width, length, stack_height, (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 VALUES
(${nextId}, ${nextId}, ${name}, ${classname}, ${itemType}, ${xdim}, ${ydim}, ${stackHeight}, (${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; return nextId;
}); });
} catch (err) { } catch (err) {
@@ -402,6 +408,7 @@ export async function uploadSingleFurni(params: {
customparams, customparams,
price, price,
categoryKey: classifyFurni(classname, itemType), categoryKey: classifyFurni(classname, itemType),
interactionModesCount,
}); });
warnings.push(`SQL migration written: ${sqlFile}`); warnings.push(`SQL migration written: ${sqlFile}`);
} catch (err) { } catch (err) {
+99 -9
View File
File diff suppressed because it is too large. Load diff