import { execFile } from "node:child_process"; import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; import { and, eq, type SQL, sql } from "drizzle-orm"; import { CatalogPages, db, ItemsBase, LogsFurniImports } from "@/lib/db"; import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel"; import { logger } from "@/lib/logger"; import { DEFAULT_FURNI_ICON_DIR, DEFAULT_FURNI_NITRO_DIR, DEFAULT_FURNI_SWF_DIR, getFurniAssetDirs, getFurniAssetWriteTargets, getGamedataRoot, } from "@/lib/services/furni-asset-dirs"; import { appendFurniEntry, buildFurniEntry, findFurniDataIdConflict, getFurnitureDataWritePaths, removeFurniEntry, } from "@/lib/services/furni-data"; import { lookupOfficialHabboFurni, type OfficialHabboFurniEntry, } from "@/lib/services/habbo-furnidata-cache"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; import { downloadFile, validatePngBytes, validateSwfBytes, } from "@/lib/services/import/core/download"; import { type ConversionResult, convertSwfToNitro, extractIconFromSwf, parseNitroBundle, } from "@/lib/services/swf-to-nitro"; import type { ImportSingleResult } from "@/types/furni"; // Re-export the download helpers now owned by the shared import core. // Existing furni callers keep importing them from here. export { downloadFile, validatePngBytes, validateSwfBytes }; // ── Constants ──────────────────────────────────────────────────────── // turbopackIgnore hints: this module is reached by App Router routes; without // them Turbopack's NFT tracer marks the whole project as a dependency because // it can't prove the dynamic suffixes (file names) are bounded to the prefix. export const FURNI_ICON_DIR = DEFAULT_FURNI_ICON_DIR; export const FURNI_SWF_DIR = DEFAULT_FURNI_SWF_DIR; export const FURNI_NITRO_DIR = DEFAULT_FURNI_NITRO_DIR; export const IMPORTED_PAGE_CAPTION_SAVE = "imported_furni"; export const IMPORTED_PAGE_CAPTION = "Imported Furniture"; // Shared with the admin Studio via the client-safe auto-catalog module so the // on-screen preview always matches what the emulator import applies. 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 ────────────────────────────────────────────────────────── export async function ensureDirectories(): Promise { const targets = await getFurniAssetWriteTargets(); const dirs = [ targets.swfDir, targets.iconDir, targets.nitroDir, ...targets.mirrorDirs.flatMap((d) => [d.swfDir, d.iconDir, d.nitroDir]), ]; await Promise.all( dirs .filter((dir) => !existsSync(/*turbopackIgnore: true*/ dir)) .map((dir) => fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true }), ), ); } async function mirrorFileToDirs( sourcePath: string, fileName: string, dirs: string[], warnings?: string[], ): Promise { if (!existsSync(/*turbopackIgnore: true*/ sourcePath)) return; for (const dir of dirs) { const targetPath = path.join(/*turbopackIgnore: true*/ dir, fileName); const sourceKey = process.platform === "win32" ? sourcePath.toLowerCase() : sourcePath; const targetKey = process.platform === "win32" ? targetPath.toLowerCase() : targetPath; if (sourceKey === targetKey) continue; try { await fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true }); await fs.copyFile( /*turbopackIgnore: true*/ sourcePath, /*turbopackIgnore: true*/ targetPath, ); } catch (err) { warnings?.push( `Mirror copy failed for ${fileName}: ${(err as Error).message}`, ); } } } export function buildItemsBaseEnrichmentUpdate(params: { id: number; dims: { x: number; y: number; z: number }; cansiton: boolean; canlayon: boolean; 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}, allow_sit = ${params.cansiton ? "1" : "0"}, allow_lay = ${params.canlayon ? "1" : "0"}, allow_walk = ${params.canstandon ? "1" : "0"}, allow_stack = ${params.allowStack}, interaction_type = ${params.interactionType}, interaction_modes_count = ${modesCount} WHERE id = ${params.id} `; } /** Keep the lowest-id row for a catalog page and delete any duplicate created * concurrently by a racing import. Returns the surviving page id. */ async function dedupeCatalogPage( captionSave: string, parentId: number, justCreatedId: number, ): Promise { const [rows] = (await db.execute(sql` SELECT MIN(id) AS id FROM catalog_pages WHERE caption_save = ${captionSave} AND parent_id = ${parentId} `)) as unknown as [Array<{ id: number }>, unknown]; const winner = rows[0]?.id ?? justCreatedId; if (winner !== justCreatedId) { // Never delete a page that already received catalog items. await db.execute(sql` DELETE FROM catalog_pages WHERE id = ${justCreatedId} AND NOT EXISTS (SELECT 1 FROM catalog_items WHERE page_id = ${justCreatedId}) `); } return winner; } /** Get or create the root "Imported Furniture" parent page (no items directly on this page). * Caches the page ID in-memory for the process lifetime to avoid redundant DB reads * during batch imports */ export async function getOrCreateImportedParentPage(): Promise { if (parentIdCache !== null) return parentIdCache; const [existing] = await db .select({ id: CatalogPages.id }) .from(CatalogPages) .where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE)) .limit(1); if (existing) { parentIdCache = existing.id; return existing.id; } // Use raw SQL to avoid ENUM mismatch (page_layout, visible, enabled) // All string columns must be '' (not NULL) or the Java emulator may skip the page await db.execute(sql` INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) VALUES (${IMPORTED_PAGE_CAPTION_SAVE}, ${IMPORTED_PAGE_CAPTION}, 'default_3x3', -1, '1', 9999, 263, '1', '1', '', '', '', '', '', '', '', '') `); const [created] = await db .select({ id: CatalogPages.id }) .from(CatalogPages) .where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE)) .limit(1); if (!created) { throw new Error( 'Failed to create "Imported Furniture" parent catalog page', ); } parentIdCache = await dedupeCatalogPage( IMPORTED_PAGE_CAPTION_SAVE, -1, created.id, ); return parentIdCache; } /** Determine the catalog category for a furniture item based on its classname and type. */ export { classifyFurni } from "@/lib/furni/auto-catalog"; import { classifyFurni } from "@/lib/furni/auto-catalog"; /** * Auto-detect catalog price from classname. * - CF_750_xxx → cost_credits = 750 (credit furni = the credit value it represents) * - exe_50, exe_100, exe_200 etc. → cost_credits based on exchange value * - rare_xxx → cost_credits = 25 (rares are expensive) * - wf_xxx → cost_credits = 5 (wired components) * - everything else → cost_credits = 3 (default) * * Returns { credits, points, pointsType } */ export { autoPriceFurni } from "@/lib/furni/auto-catalog"; import { autoPriceFurni } from "@/lib/furni/auto-catalog"; /** Get or create a category sub-page under the imported parent page. * Caches category page IDs in-memory for CATEGORY_CACHE_TTL_MS to avoid * redundant DB lookups during batch imports where many items map to the same * category. */ let categoryPageCache: Map | null = null; let categoryCacheTimestamp = 0; const CATEGORY_CACHE_TTL_MS = 5 * 60_000; // 5 minutes export async function getOrCreateCategoryPage( classname: string, itemType: string, ): Promise { const parentId = await getOrCreateImportedParentPage(); const catKey = classifyFurni(classname, itemType); const captionSave = `imp_${catKey}`; // Check in-memory cache (TTL based) const now = Date.now(); if ( categoryPageCache && now - categoryCacheTimestamp < CATEGORY_CACHE_TTL_MS ) { const cached = categoryPageCache.get(captionSave); if (cached !== undefined) return cached; } const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other; const [existing] = await db .select({ id: CatalogPages.id }) .from(CatalogPages) .where( and( eq(CatalogPages.captionSave, captionSave), eq(CatalogPages.parentId, parentId), ), ) .limit(1); if (existing) { if (!categoryPageCache) categoryPageCache = new Map(); categoryPageCache.set(captionSave, existing.id); return existing.id; } await db.execute(sql` INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) VALUES (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '') `); const [created] = await db .select({ id: CatalogPages.id }) .from(CatalogPages) .where( and( eq(CatalogPages.captionSave, captionSave), eq(CatalogPages.parentId, parentId), ), ) .limit(1); if (!created) { throw new Error( `Failed to create catalog sub-page "${captionSave}" under parent ${parentId}`, ); } if (!categoryPageCache) categoryPageCache = new Map(); const winner = await dedupeCatalogPage(captionSave, parentId, created.id); categoryPageCache.set(captionSave, winner); return winner; } export function resetCatalogPageCache(): void { parentIdCache = null; categoryPageCache = null; categoryCacheTimestamp = 0; } /** * Walk every catalog item under the "Imported Furniture" tree and make its * offer_id match the furnidata sprite id (item_ids). Fixes any stale values * (e.g. legacy -1 rows) after an import. */ export async function reconcileImportedOfferIds(): Promise<{ fixed: number; }> { const parentId = await getOrCreateImportedParentPage(); const [result] = (await db.execute(sql` UPDATE catalog_items ci JOIN catalog_pages cp ON cp.id = ci.page_id SET ci.offer_id = CAST(ci.item_ids AS UNSIGNED) WHERE cp.parent_id = ${parentId} AND ci.offer_id <> CAST(ci.item_ids AS UNSIGNED) `)) as unknown as [Record, unknown]; return { fixed: Number(result.affectedRows ?? 0) }; } const execFileAsync = promisify(execFile); /** * Ensure the furni asset directories (icons, nitros, swf) and the folders * holding FurnitureData.json are owned by www-data:www-data, so nginx and the * emulator can always read freshly imported files. Best-effort: if chown is * unavailable or fails (e.g. running unprivileged), a warning is logged and * import continues. */ export async function ensureFurniOwnership(): Promise<{ fixed: string[] }> { if (process.platform === "win32") return { fixed: [] }; const { swfDir, iconDir, nitroDir, mirrorDirs } = await getFurniAssetWriteTargets(); const furniDataPaths = await getFurnitureDataWritePaths(); const dirs = [ swfDir, iconDir, nitroDir, ...mirrorDirs.flatMap((m) => [m.swfDir, m.iconDir, m.nitroDir]), ...furniDataPaths.map((p) => path.dirname(p)), ] .map((d) => path.normalize(d)) .filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d)); if (dirs.length === 0) return { fixed: [] }; try { await execFileAsync("chown", ["-R", "www-data:www-data", ...dirs]); return { fixed: dirs }; } catch (err) { logger.warn("[import-furni] chown to www-data failed", { dirs, error: (err as Error).message, }); return { fixed: [] }; } } /** Copy a single missing file from source dir into target dir; logs on failure. */ async function copyIfMissing( sourceDir: string, targetDir: string, entry: string, copied: string[], ): Promise { const dest = path.join(targetDir, entry); if (existsSync(dest)) return; try { await fs.copyFile(path.join(sourceDir, entry), dest); copied.push(entry); } catch (err) { logger.warn("[import-furni] gamedata bundle sync copy failed", { entry, error: (err as Error).message, }); } } /** Scan a source dir and copy every file that is missing from the target dir. */ async function syncDirMissingFiles( sourceDir: string, targetDir: string, predicate: (entry: string) => boolean, ): Promise { if (!existsSync(sourceDir)) return []; try { await fs.mkdir(targetDir, { recursive: true }); } catch { return []; } const copied: string[] = []; try { const entries = await fs.readdir(sourceDir); for (const entry of entries) { if (!predicate(entry)) continue; await copyIfMissing(sourceDir, targetDir, entry, copied); } } catch (err) { logger.warn("[import-furni] gamedata bundle sync scan failed", { error: (err as Error).message, }); } return copied; } /** * Backfill any nitro and icon files missing from the emulator's Gamedata * bundle (/var/www/Gamedata/bundled/furniture and /var/www/Gamedata/icons) * so all imported furniture stays renderable in-game even when a mirror * write was skipped. */ export async function syncAssetsToGamedataBundle(): Promise<{ copiedNitros: string[]; copiedIcons: string[]; }> { if (process.platform === "win32") return { copiedNitros: [], copiedIcons: [] }; const gamedataRoot = await getGamedataRoot(); if (!gamedataRoot) return { copiedNitros: [], copiedIcons: [] }; const { nitroDir, iconDir } = await getFurniAssetDirs(); const copiedNitros: string[] = []; const copiedIcons: string[] = []; const nitroTarget = path.join(gamedataRoot, "bundled/furniture"); if (nitroDir !== nitroTarget) { const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) => entry.endsWith(".nitro"), ); copiedNitros.push(...copied); } const iconTarget = path.join(gamedataRoot, "icons"); if (iconDir !== iconTarget) { const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) => entry.endsWith("_icon.png"), ); copiedIcons.push(...copied); } return { copiedNitros, copiedIcons }; } /** * Allocate the next `catalog_items.id` and run the INSERT through a * process-level async mutex so concurrent imports in the same Node process * cannot race on MAX(id). A DB-level lock would require pinning a single * connection for the whole operation; the in-process mutex is enough * for the single-server deployment and avoids pool-connection gymnastics. */ let catalogNextId: number | null = null; let catalogIdLastUsed = 0; let catalogIdSeedChain: Promise = Promise.resolve(); const CATALOG_ID_REFRESH_MS = 60_000; let parentIdCache: number | null = null; /** Allocate a unique catalog_items id. Seeded once with MAX(id)+1, then handed * out from an in-process counter so concurrent imports don't serialize on a * SELECT MAX + INSERT round-trip per item. Re-seeds when idle so rows added * externally don't collide. */ export async function allocateCatalogItemId( insertFn: (nextId: number) => Promise, ): Promise { const now = Date.now(); if ( catalogNextId === null || now - catalogIdLastUsed > CATALOG_ID_REFRESH_MS ) { let settle!: () => void; const prev = catalogIdSeedChain; catalogIdSeedChain = new Promise((r) => { settle = r; }); await prev.catch(() => {}); try { if ( catalogNextId === null || Date.now() - catalogIdLastUsed > CATALOG_ID_REFRESH_MS ) { const [maxIdResult] = (await db.execute(sql` SELECT MAX(id) as maxId FROM catalog_items `)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown]; catalogNextId = Number(maxIdResult[0]?.maxId ?? 0) + 1; } } finally { settle(); } } catalogIdLastUsed = Date.now(); const nextId = catalogNextId; catalogNextId += 1; return await insertFn(nextId); } /** Test seam: drop the cached id counter between tests. */ export function __resetCatalogIdAllocatorForTests(): void { catalogNextId = null; catalogIdLastUsed = 0; catalogIdSeedChain = Promise.resolve(); } // ── Import Single Furni ───────────────────────────────────────────── export async function importSingleFurni(params: { id: number; classname: string; name: string; description: string; type: string; revision: number; category: string; skipFurniDataWrite?: boolean; updateExisting?: boolean; onProgress?: (status: string) => void; /** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */ sourceSwfBaseUrl?: string; /** Per-source nitro bundle + icon base URL. */ nitroBaseUrl?: string; iconBaseUrl?: string; }): Promise { const { id: originalId, classname, name, description, type, revision, category, skipFurniDataWrite, updateExisting, onProgress, sourceSwfBaseUrl, nitroBaseUrl: sourceNitroBaseUrl, iconBaseUrl: sourceIconBaseUrl, } = params; const warnings: string[] = []; // Check if already exists by classname OR by spriteId (primary key collision). const [existsByName] = await db .select({ id: ItemsBase.id }) .from(ItemsBase) .where(eq(ItemsBase.itemName, classname)) .limit(1); if (existsByName && !updateExisting) { return { ok: false, warnings, error: `Item "${classname}" already exists (id: ${existsByName.id})`, }; } if (existsByName && existsByName.id !== originalId) { // The classname already occupies a different spriteId. Reassigning the // id would orphan every reference (catalog item_ids, player items, // furnidata), so this can never be an "update". return { ok: false, warnings, error: `Item "${classname}" already exists with id ${existsByName.id} — cannot reassign to spriteId ${originalId}`, }; } const [existsById] = await db .select({ itemName: ItemsBase.itemName }) .from(ItemsBase) .where(eq(ItemsBase.id, originalId)) .limit(1); if (existsById && (!existsByName || existsById.itemName !== classname)) { // The spriteId is owned by a different classname. Even with // updateExisting this is a genuine conflict, not an update. return { ok: false, warnings, error: `spriteId ${originalId} already used by "${existsById.itemName}"`, }; } const isUpdate = !!existsByName; // ── Check if spriteId already used in FurnitureData.json ── const takenBy = await findFurniDataIdConflict(originalId, classname); if (takenBy && !updateExisting) { return { ok: false, warnings, error: `spriteId ${originalId} already used by "${takenBy}" in FurnitureData.json`, }; } const itemType = type === "wallitem" ? "i" : "s"; const spriteId = originalId; // ── Cross-reference with configured official Habbo furnidata ────── let officialHabbo: OfficialHabboFurniEntry | null = null; try { const hotel = await getHabboGamedataHotel(); officialHabbo = await lookupOfficialHabboFurni(classname); if (officialHabbo) { warnings.push(officialHabboEnrichmentWarning(hotel, officialHabbo.name)); } } catch { // Non-blocking: official Habbo enrichment is optional } // Best values: use configured official Habbo furnidata for enrichment. const bestName = name || officialHabbo?.name || classname; const bestDescription = description || officialHabbo?.description || ""; const bestRevision = revision || officialHabbo?.revision || 0; const bestCategory = category || officialHabbo?.category || "unknown"; // ── Insert into DB with enriched data ────────────────────────────── onProgress?.("writing_db"); try { if (isUpdate) { // NOTE: live Arcturus `items_base` has no description/revision/rare columns — // those metadata fields are persisted in FurnitureData.json (see entry below), // not in the DB. Only refresh the columns this import manages. await db.execute(sql` UPDATE items_base SET sprite_id = ${spriteId}, public_name = ${bestName}, item_name = ${classname}, type = ${itemType} WHERE id = ${spriteId} `); } else { await db.execute(sql` INSERT INTO items_base (id, sprite_id, public_name, item_name, type) VALUES (${spriteId}, ${spriteId}, ${bestName}, ${classname}, ${itemType}) `); } } catch (err) { return { ok: false, warnings, error: `DB ${isUpdate ? "update" : "insert"} failed: ${(err as Error).message}`, }; } // ── Derive file-safe names ──────────────────────────────────────── const rev = revision || 0; const assetTargets = await getFurniAssetWriteTargets(); const { swfDir, iconDir, nitroDir } = assetTargets; const starIdx = classname.indexOf("*"); const hasColor = starIdx !== -1; const baseClassname = hasColor ? classname.substring(0, starIdx) : classname; const iconFileName = classname.replace(/\*/g, "_"); const safeSwfName = baseClassname; const safeNitroName = baseClassname; const swfPath = path.join( /*turbopackIgnore: true*/ swfDir, `${safeSwfName}.swf`, ); const nitroPath = path.join( /*turbopackIgnore: true*/ nitroDir, `${safeNitroName}.nitro`, ); const iconPath = path.join( /*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`, ); const mirrorSwfDirs = assetTargets.mirrorDirs.map((d) => d.swfDir); const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir); const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir); // ── Download assets in parallel (with retry + validation) ──────── // Icon strategy matches Nitro's URL template: // `${hof.furni.url}/icons/%libname%%param%_icon.png` // For color variants (classname has `*`): try color-specific first, // fall back to base-classname icon (Nitro reuses base for all colors // when no color-specific icon exists). onProgress?.("downloading"); const iconColorAware = classname.replace(/\*/g, "_"); // Build candidate URL lists — source-specific URLs first, then Habbo CDN fallback const iconUrls: string[] = []; const swfUrls: string[] = []; // Source-specific icon URLs if (sourceIconBaseUrl) { // e.g. https://habblet.city/nitro/icons/{classname}_icon.png iconUrls.push( `${sourceIconBaseUrl}/${encodeURIComponent(iconColorAware)}_icon.png`, `${sourceIconBaseUrl}/${encodeURIComponent(baseClassname)}_icon.png`, ); } // Source-specific SWF URLs if (sourceSwfBaseUrl) { // e.g. https://virtualc.nl/dcr/hof_furni/{rev}/{classname}.swf swfUrls.push( `${sourceSwfBaseUrl}/hof_furni/${rev}/${encodeURIComponent(safeSwfName)}.swf`, ); } // Official Habbo CDN fallbacks iconUrls.push( `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(iconColorAware)}_icon.png`, `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`, ); swfUrls.push( `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(safeSwfName)}.swf`, ); // Download icon (try all candidate URLs) let iconOk = false; for (const iconUrl of iconUrls) { const dl = await downloadFile(iconUrl, iconPath, { validate: "png" }); if (dl.ok) { iconOk = true; break; } } // Download SWF (try all candidate URLs) let swfOk = false; for (const swfUrl of swfUrls) { const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" }); if (dl.ok) { swfOk = true; break; } } // ── Nitro bundle fallback: try downloading pre-made .nitro ──────── // If SWF download failed, try downloading a pre-made .nitro from the // source's nitro base URL (many retro hotels ship .nitro bundles). let nitroDownloadOk = false; if ( !swfOk && sourceNitroBaseUrl && !existsSync(/*turbopackIgnore: true*/ nitroPath) ) { const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`; const dl = await downloadFile(nitroUrl, nitroPath, { validate: "png" }); if (dl.ok) nitroDownloadOk = true; } // ── Icon fallback: extract from SWF if download failed ──────────── if (!iconOk && (swfOk || existsSync(/*turbopackIgnore: true*/ swfPath))) { try { const swfBuf = await fs.readFile(/*turbopackIgnore: true*/ swfPath); const iconPng = extractIconFromSwf(swfBuf, baseClassname); if (iconPng) { await fs.writeFile(/*turbopackIgnore: true*/ iconPath, iconPng); iconOk = true; warnings.push("Icon extracted from SWF (download failed)"); } else { warnings.push( "Icon download failed and SWF icon extraction returned nothing", ); } } catch { warnings.push("Icon download failed and SWF icon extraction also failed"); } } else if (!iconOk) { warnings.push("Icon image download failed"); } if (!swfOk && !nitroDownloadOk) warnings.push("SWF download failed"); // ── Convert SWF to Nitro ────────────────────────────────────────── onProgress?.("converting"); let conversionResult: ConversionResult | null = null; if (existsSync(/*turbopackIgnore: true*/ nitroPath)) { warnings.push("Nitro file already exists (shared across color variants)"); } else { try { if (existsSync(/*turbopackIgnore: true*/ swfPath)) { const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath); conversionResult = convertSwfToNitro(swfBuffer, baseClassname); await fs.writeFile( /*turbopackIgnore: true*/ nitroPath, conversionResult.bundle, ); if (conversionResult.warnings.length > 0) { warnings.push(...conversionResult.warnings); } } else { warnings.push("SWF not available for Nitro conversion"); } } catch (err) { logger.warn("[import-furni] SWF to Nitro conversion failed", { error: (err as Error).message, }); warnings.push( `SWF to Nitro conversion failed: ${(err as Error).message}`, ); } } // ── Rollback: if conversion/download failed AND no .nitro exists, remove DB record ── // Also validate that the .nitro file is non-trivial (≥ 128 bytes — a bare // Nitro bundle header is larger than that, anything smaller is corrupt/empty). const nitroStat = await fs .stat(/*turbopackIgnore: true*/ nitroPath) .catch(() => null); const nitroValid = nitroStat && nitroStat.size >= 128; if (!nitroValid && !conversionResult) { if (nitroStat) { warnings.push( `Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`, ); try { await fs.unlink(nitroPath); } catch { /* ignore */ } } try { await db.execute( sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`, ); } catch { /* best effort cleanup */ } return { ok: false, warnings, error: "Import rolled back: no .nitro file could be created", }; } await Promise.all([ mirrorFileToDirs(swfPath, `${safeSwfName}.swf`, mirrorSwfDirs, warnings), mirrorFileToDirs( iconPath, `${iconFileName}_icon.png`, mirrorIconDirs, warnings, ), mirrorFileToDirs( nitroPath, `${safeNitroName}.nitro`, mirrorNitroDirs, warnings, ), ]); // ── Build enriched FurnitureData entry ──────────────────────────── const dims = conversionResult?.dimensions || (officialHabbo ? { x: officialHabbo.xdim, y: officialHabbo.ydim, z: 0 } : { x: 1, y: 1, z: 0 }); const meta = conversionResult?.metadata ?? null; // Prefer .nitro flags, then the configured official Habbo furnidata. const cansiton = meta?.cansiton ?? officialHabbo?.cansiton ?? false; const canlayon = meta?.canlayon ?? officialHabbo?.canlayon ?? false; const canstandon = meta?.canstandon ?? officialHabbo?.canstandon ?? false; // Derive interaction_type. Priority: // 1. Wired LOGIC items (wf_act_/wf_trg_/wf_cnd_/wf_slc_/wf_xtra_/wf_var_) // — use the FULL classname so Arcturus can route to the specific // InteractionWired* handler. Collapsing to wf_act/wf_trg/wf_cnd // breaks every wired because the emulator looks up the exact type. // 2. .nitro logicType match for known mechanics. // 3. Non-logic wired (wf_firegate, wf_pressureplate, wf_pyramid, // wf_floor_switch, …) that didn't match step 2 — use the full // classname as a safe fallback instead of 'default'. // 4. Final fallback: default. const logicType = meta?.logicType || ""; let interactionType = "default"; if (/^wf_(act|trg|cnd|slc|xtra|var)_/.test(classname)) { interactionType = classname; } else if (logicType.includes("multistate")) interactionType = "default"; else if (logicType.includes("gate")) interactionType = "gate"; else if (logicType.includes("teleport")) interactionType = "teleport"; else if (logicType.includes("roller")) interactionType = "roller"; else if (logicType.includes("dice")) interactionType = "dice"; else if (logicType.includes("vendingmachine")) interactionType = "vendingmachine"; else if (logicType.includes("firework")) interactionType = "fireworks"; else if (logicType.includes("trophy")) interactionType = "trophy"; else if (logicType.includes("exchange")) interactionType = "exchange"; else if (logicType.includes("habbowheel")) interactionType = "habbowheel"; else if (logicType.includes("soundmachine")) interactionType = "jukebox"; else if (logicType.includes("badge")) interactionType = "badge"; if (interactionType === "default" && /^wf_/.test(classname)) { interactionType = classname; warnings.push( `Wired fallback: using full classname "${classname}" as interaction_type`, ); } // 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. Real flags (SWF logic XML / // official furnidata) win over keyword guessing — no false positives. let nitroJsonForModes: Record | null = null; try { const parsed = parseNitroBundle( await fs.readFile(/*turbopackIgnore: true*/ nitroPath), ); nitroJsonForModes = parsed.json as Record; } catch { // no .nitro JSON available — keyword fallback only } const autoInteraction = autoDetectInteraction(classname, bestName, { cansiton, canlayon, canstandon, logicType, animationStates: nitroAnimationStatesCount(nitroJsonForModes), }); const interactionModesCount = autoInteraction.interactionModesCount; // When the .nitro logicType produced no specific handler, fall back to the // auto-detected type (classname mechanic + keyword + real flags) exactly // like the post-import verification sweep, so newly imported items carry the // correct interaction_type immediately instead of waiting for the sweep. // Never applied to updates — existing emulator handler types must survive. if ( !isUpdate && interactionType === "default" && autoInteraction.interactionType !== "default" ) { interactionType = autoInteraction.interactionType; } // Update items_base with ALL enriched data try { const update = buildItemsBaseEnrichmentUpdate({ id: spriteId, dims, cansiton, canlayon, canstandon, allowStack, interactionType, interactionModesCount, }); await db.execute(update); } catch (err) { warnings.push( `items_base dimension update failed: ${(err as Error).message}`, ); } // Build enriched FurnitureData.json entry using best available data const furniEntry = buildFurniEntry({ id: spriteId, classname, revision: bestRevision, category: bestCategory, name: bestName, description: bestDescription, spriteId, dims, metadata: meta, officialHabbo, }); // ── Add to FurnitureData.json (or defer for batch) ──────────────── if (skipFurniDataWrite) { // Batch mode: return the entry so the caller writes once at the end } else { try { await appendFurniEntry(furniEntry, itemType); } catch (err) { logger.warn("[import-furni] Failed to update FurnitureData.json", { error: (err as Error).message, }); warnings.push("FurnitureData.json update failed"); } } // ── Create catalog entry (with duplicate check + category sub-page) ── let catalogItemId: number | null = null; let catalogUpdated = false; try { const itemIdStr = String(spriteId); // Check if this item already exists anywhere in the catalog const [existingCatalog] = (await db.execute(sql` SELECT id, page_id FROM catalog_items WHERE item_ids = ${itemIdStr} LIMIT 1 `)) as unknown as [Array<{ id: number; page_id: string }>, unknown]; if (existingCatalog.length > 0) { catalogItemId = existingCatalog[0].id; if (updateExisting) { // Update existing catalog entry: price, page, etc. const categoryPageId = await getOrCreateCategoryPage( classname, itemType, ); const pageIdStr = String(categoryPageId); const price = autoPriceFurni(classname); await db.execute(sql` UPDATE catalog_items SET page_id = ${pageIdStr}, cost_credits = ${price.credits}, cost_points = ${price.points}, points_type = ${price.pointsType}, offer_id = ${spriteId} WHERE id = ${catalogItemId} `); catalogUpdated = true; warnings.push( `Item updated in catalog (catalog_item #${catalogItemId}, page ${categoryPageId})`, ); } else { warnings.push( `Item already in catalog (catalog_item #${catalogItemId}, page ${existingCatalog[0].page_id}) — skipped duplicate`, ); } } else { // Get or create the right category sub-page based on classname const categoryPageId = await getOrCreateCategoryPage(classname, itemType); const pageIdStr = String(categoryPageId); const price = autoPriceFurni(classname); catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => { await db.execute(sql` INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextCatalogId}, ${pageIdStr}, ${itemIdStr}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${spriteId}, '') `); return nextCatalogId; }); } } catch (err) { logger.warn("[import-furni] Failed to create catalog entry", { error: (err as Error).message, }); warnings.push("Catalog entry creation failed"); } return { ok: true, itemId: spriteId, catalogItemId, catalogUpdated, dimensions: dims, spriteCount: conversionResult?.spriteCount, warnings, furniDataEntry: skipFurniDataWrite ? { entry: furniEntry, itemType } : undefined, }; } // ── Interaction modes count verification ────────────────────────────── const nitroModesCache = new Map(); const NITRO_MODES_CACHE_TTL_MS = 5 * 60_000; let nitroModesCacheAt = 0; async function realNitroModes(classname: string): Promise { 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, ); nitroModesCache.set(base, modes); return modes; } catch { nitroModesCache.set(base, undefined); return undefined; } } /** Verify and fix interaction values for ALL furniture in items_base. * Run after import to ensure every item's interaction_type, modes count and * sit/lay/stand flags match the 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 .select({ id: ItemsBase.id, itemName: ItemsBase.itemName, publicName: ItemsBase.publicName, allowSit: ItemsBase.allowSit, allowLay: ItemsBase.allowLay, allowWalk: ItemsBase.allowWalk, interactionType: ItemsBase.interactionType, interactionModesCount: ItemsBase.interactionModesCount, }) .from(ItemsBase); for (const item of allItems) { checked++; 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 changes: Partial> = {}; // allow_sit/lay/walk of 2 is emulator-special (e.g. tents lay=2, // walk-through walk=2) — never overwrite those with a 0/1. if ( item.allowSit !== 2 && (item.allowSit ? 1 : 0) !== (auto.canSit ? 1 : 0) ) changes.allowSit = auto.canSit ? 1 : 0; if ( item.allowLay !== 2 && (item.allowLay ? 1 : 0) !== (auto.canLay ? 1 : 0) ) changes.allowLay = auto.canLay ? 1 : 0; if ( item.allowWalk !== 2 && (item.allowWalk ? 1 : 0) !== (auto.canStand ? 1 : 0) ) changes.allowWalk = auto.canStand ? 1 : 0; if ((item.interactionModesCount ?? 0) !== auto.interactionModesCount) changes.interactionModesCount = auto.interactionModesCount; // Only fill interaction_type when the DB still has the generic // "default" (nothing emulator-specific configured). Never overwrite // an existing handler type (e.g. intelligence_bookcase, nest, wf_*) // — those are emulator semantics that real furnidata cannot replace. if ( (item.interactionType === "default" || !item.interactionType) && auto.interactionType !== "default" ) { changes.interactionType = auto.interactionType; } if (Object.keys(changes).length > 0) { try { await db .update(ItemsBase) .set(changes) .where(eq(ItemsBase.id, item.id)); fixed++; } catch (err) { errors.push( `Failed to fix ${item.itemName}: ${(err as Error).message}`, ); } } } } catch (err) { errors.push(`Verification failed: ${(err as Error).message}`); } return { fixed, checked, skipped, errors }; } // ── Delete helpers ─────────────────────────────────────────────────── /** Delete a single imported item: DB records, FurnitureData.json entry, and asset files. */ export async function deleteImportedItem(classname: string): Promise<{ spriteId: number; deletedFiles: string[]; errors: string[]; }> { const errors: string[] = []; // Look up item in DB const [item] = await db .select({ id: ItemsBase.id, type: ItemsBase.type }) .from(ItemsBase) .where(eq(ItemsBase.itemName, classname)) .limit(1); if (!item) { throw new Error(`Item "${classname}" not found in database`); } const spriteId = item.id; const starIdx = classname.indexOf("*"); const assetTargets = await getFurniAssetWriteTargets(); const { swfDir, iconDir, nitroDir } = assetTargets; const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname; const iconFileName = classname.replace(/\*/g, "_"); // 1. Delete catalog_items — match by the canonical item_ids link only. // catalog_name may have been translated to a public name, so adding a // name condition would silently leave an orphaned catalog row behind. try { const itemIdStr = String(spriteId); await db.execute( sql`DELETE FROM catalog_items WHERE item_ids = ${itemIdStr}`, ); } catch (err) { errors.push(`catalog_items: ${(err as Error).message}`); } // 2. Delete items_base try { await db.execute( sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`, ); } catch (err) { errors.push(`items_base: ${(err as Error).message}`); } // 3. Remove from FurnitureData.json try { await removeFurniEntry(classname); } catch (err) { errors.push(`FurnitureData.json: ${(err as Error).message}`); } // 4. Delete files (.nitro, .swf, icon) const filesToDelete = [ path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`), path.join(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`), path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`), ...assetTargets.mirrorDirs.flatMap((dirs) => [ path.join( /*turbopackIgnore: true*/ dirs.nitroDir, `${baseClassname}.nitro`, ), path.join(/*turbopackIgnore: true*/ dirs.swfDir, `${baseClassname}.swf`), path.join( /*turbopackIgnore: true*/ dirs.iconDir, `${iconFileName}_icon.png`, ), ]), ]; const deletedFiles: string[] = []; for (const filePath of filesToDelete) { if (existsSync(/*turbopackIgnore: true*/ filePath)) { try { await fs.unlink(/*turbopackIgnore: true*/ filePath); deletedFiles.push(path.basename(filePath)); } catch (err) { errors.push(`${path.basename(filePath)}: ${(err as Error).message}`); } } } return { spriteId, deletedFiles, errors }; } /** Clean up SWF files that have already been converted to Nitro. */ export async function cleanupConvertedSwfs(): Promise<{ deleted: number; total: number; remaining: number; errors: string[]; }> { const { swfDir, nitroDir } = await getFurniAssetDirs(); if (!existsSync(swfDir)) { return { deleted: 0, total: 0, remaining: 0, errors: [] }; } const swfFiles = (await fs.readdir(swfDir)).filter((f) => f.endsWith(".swf")); let deleted = 0; const errors: string[] = []; for (const swfFile of swfFiles) { const cn = swfFile.replace(".swf", ""); const nitroFilePath = path.join( /*turbopackIgnore: true*/ nitroDir, `${cn}.nitro`, ); if (existsSync(/*turbopackIgnore: true*/ nitroFilePath)) { try { await fs.unlink(path.join(/*turbopackIgnore: true*/ swfDir, swfFile)); deleted++; } catch (err) { errors.push(`Failed to delete ${swfFile}: ${(err as Error).message}`); } } } return { deleted, total: swfFiles.length, remaining: swfFiles.length - deleted, errors, }; } /** Rollback a furni import by import log ID. Returns { ok, rolledBack: number, errors: string[] }. */ export async function rollbackFurniImport( importId: number, rolledBackBy: number, ): Promise<{ ok: boolean; rolledBack: number; errors: string[]; }> { const errors: string[] = []; let rolledBack = 0; // Get the import log const [importLog] = await db .select() .from(LogsFurniImports) .where(eq(LogsFurniImports.id, importId)) .limit(1); if (!importLog) { return { ok: false, rolledBack: 0, errors: [`Import log ${importId} not found`], }; } if (importLog.rolledBack) { return { ok: false, rolledBack: 0, errors: [ `Import ${importId} already rolled back at ${importLog.rolledBackAt}`, ], }; } let details: { items?: Array<{ classname: string; spriteId: number; baseClassname?: string; iconFileName?: string; }>; } = {}; try { details = JSON.parse(importLog.details); } catch { return { ok: false, rolledBack: 0, errors: ["Invalid import details JSON"], }; } if (!details.items || !Array.isArray(details.items)) { return { ok: false, rolledBack: 0, errors: ["No items to rollback"] }; } const assetTargets = await getFurniAssetWriteTargets(); const { swfDir, iconDir, nitroDir } = assetTargets; for (const item of details.items) { const { classname, spriteId: _spriteId, baseClassname } = item; const base = baseClassname || classname.split("*")[0] || classname; const errorsForItem: string[] = []; // 1. Delete from catalog_items — match by the canonical item_ids link only // (catalog_name may be a translated display name). try { await db.execute( sql`DELETE FROM catalog_items WHERE item_ids = ${String(item.spriteId)}`, ); } catch (err) { errorsForItem.push(`catalog_items: ${(err as Error).message}`); } // 2. Delete from items_base try { await db.execute( sql`DELETE FROM items_base WHERE id = ${item.spriteId} AND item_name = ${item.classname}`, ); } catch (err) { errorsForItem.push(`items_base: ${(err as Error).message}`); } // 3. Remove from FurnitureData.json try { const { readFurniData, writeFurniData } = await import( "@/lib/services/furni-data" ); const furniData = (await readFurniData()) as { roomitemtypes?: { furnitype?: Array<{ classname?: string }> }; wallitemtypes?: { furnitype?: Array<{ classname?: string }> }; }; let changed = false; for (const section of ["roomitemtypes", "wallitemtypes"] as const) { const sec = furniData[section] as | { furnitype: Array<{ classname?: string }> } | undefined; if (!sec) continue; const furnitype = sec.furnitype ?? []; const filtered = furnitype.filter( (e) => typeof e.classname !== "string" || e.classname !== item.classname, ); if (filtered.length !== furnitype.length) { ( furniData[section] as { furnitype: Array<{ classname?: string }> } ).furnitype = filtered; changed = true; } } if (changed) { await writeFurniData(furniData as Record); } } catch (err) { errorsForItem.push(`FurnitureData.json: ${(err as Error).message}`); } // 4. Delete asset files const filesToDelete = [ path.join(nitroDir, `${base}.nitro`), path.join(swfDir, `${base}.swf`), path.join(iconDir, `${classname.replace(/\*/g, "_")}_icon.png`), ]; for (const filePath of filesToDelete) { if (existsSync(filePath)) { try { await fs.unlink(filePath); } catch (err) { errorsForItem.push( `delete ${path.basename(filePath)}: ${(err as Error).message}`, ); } } } // Mirror directories for (const dirs of assetTargets.mirrorDirs) { for (const filePath of [ path.join(dirs.nitroDir, `${classname.split("*")[0]}.nitro`), path.join(dirs.swfDir, `${classname.split("*")[0]}.swf`), path.join(dirs.iconDir, `${classname.replace(/\*/g, "_")}_icon.png`), ]) { if (existsSync(filePath)) { try { await fs.unlink(filePath); } catch { /* ignore */ } } } } if (errorsForItem.length === 0) { rolledBack++; } else { errors.push(`${item.classname}: ${errorsForItem.join(", ")}`); } } // Mark as rolled back if (rolledBack > 0) { await db .update(LogsFurniImports) .set({ rolledBack: 1, rolledBackAt: Math.floor(Date.now() / 1000), rolledBackBy, }) .where(eq(LogsFurniImports.id, importId)); } return { ok: errors.length === 0, rolledBack, errors }; }