Files
EpicNext-Cms/src/lib/services/furni-import.ts
T
openhands 7911a25ced
CI / check (push) Successful in 41s
CI / release (push) Skipped
CI / deploy (push) Successful in 46s
feat: verify and fix interaction_modes_count on every import
- Added verifyAndFixInteractionModesCount() to scan all items_base entries
- Auto-detects correct interaction_modes_count from classname/name
- Fixes mismatched values during furniture import
- Reports fixed/checked count in warnings
2026-08-18 22:44:15 +02:00

1365 lines
43 KiB
TypeScript

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,
} 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";
// ── Helpers ──────────────────────────────────────────────────────────
export async function ensureDirectories(): Promise<void> {
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<void> {
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<number> {
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<number> {
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<string, number> | null = null;
let categoryCacheTimestamp = 0;
const CATEGORY_CACHE_TTL_MS = 5 * 60_000; // 5 minutes
export async function getOrCreateCategoryPage(
classname: string,
itemType: string,
): Promise<number> {
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<string, unknown>, 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<void> {
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<string[]> {
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<void> = 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<T>(
insertFn: (nextId: number) => Promise<T>,
): Promise<T> {
const now = Date.now();
if (
catalogNextId === null ||
now - catalogIdLastUsed > CATALOG_ID_REFRESH_MS
) {
let settle!: () => void;
const prev = catalogIdSeedChain;
catalogIdSeedChain = new Promise<void>((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<ImportSingleResult> {
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) {
if (!updateExisting) {
return {
ok: false,
warnings,
error: `Item "${classname}" already exists (id: ${existsByName.id})`,
};
}
// Update existing item - fall through to update logic
}
const [existsById] = await db
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase)
.where(eq(ItemsBase.id, originalId))
.limit(1);
if (existsById && (!existsByName || existsById.itemName !== classname)) {
if (!updateExisting) {
return {
ok: false,
warnings,
error: `spriteId ${originalId} already used by "${existsById.itemName}"`,
};
}
// Update existing item - fall through to update logic
}
// ── 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 {
// 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. Inserting them here throws "Unknown column 'description'".
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 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 from classname/name
const autoInteraction = autoDetectInteraction(classname, bestName);
const interactionModesCount = autoInteraction.interactionModesCount;
// 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");
}
// Verify and fix interaction_modes_count for all furniture
try {
const verification = await verifyAndFixInteractionModesCount();
if (verification.fixed > 0) {
warnings.push(
`Fixed interaction_modes_count for ${verification.fixed} of ${verification.checked} items`,
);
}
for (const err of verification.errors) {
warnings.push(`Interaction modes verification: ${err}`);
}
} catch (err) {
warnings.push(
`Interaction modes verification failed: ${(err as Error).message}`,
);
}
return {
ok: true,
itemId: spriteId,
catalogItemId,
catalogUpdated,
dimensions: dims,
spriteCount: conversionResult?.spriteCount,
warnings,
furniDataEntry: skipFurniDataWrite
? { entry: furniEntry, itemType }
: undefined,
};
}
// ── Interaction modes count verification ──────────────────────────────
/** 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. */
export async function verifyAndFixInteractionModesCount(): Promise<{
fixed: number;
checked: number;
errors: string[];
}> {
const errors: string[] = [];
let fixed = 0;
let checked = 0;
try {
const allItems = await db
.select({
id: ItemsBase.id,
itemName: ItemsBase.itemName,
publicName: ItemsBase.publicName,
interactionModesCount: ItemsBase.interactionModesCount,
})
.from(ItemsBase);
for (const item of allItems) {
checked++;
const auto = autoDetectInteraction(item.itemName, item.publicName);
const expectedModes = auto.interactionModesCount;
const currentModes = item.interactionModesCount ?? 0;
if (currentModes !== expectedModes) {
try {
await db
.update(ItemsBase)
.set({ interactionModesCount: expectedModes })
.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, 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
try {
const itemIdStr = String(spriteId);
await db.execute(
sql`DELETE FROM catalog_items WHERE item_ids = ${itemIdStr} AND catalog_name = ${classname}`,
);
} 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
try {
await db.execute(
sql`DELETE FROM catalog_items WHERE item_ids = ${String(item.spriteId)} AND catalog_name = ${item.classname}`,
);
} 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<string, unknown>);
}
} 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 };
}