import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { getNitroFilesRoot } from "@/lib/services/furni-asset-dirs"; import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache"; import { siteSettings } from "@/lib/services/site-settings"; import type { FurniMetadata } from "@/lib/services/swf-to-nitro"; const LOCK_STALE_MS = 60_000; /** * Resolve the FurnitureData file path from the admin setting * `furni_data_url` (e.g. `/nitro-assets/config/FurnitureData.json5`) mapped * under the public/ directory. Falls back to the json5 path if the setting * is unset. */ export async function getFurnitureDataPath(): Promise { const url = (await siteSettings.get("furni_data_url", "/nitro-assets/config/FurnitureData.json5")) ?? "/nitro-assets/config/FurnitureData.json5"; if (path.isAbsolute(url) && !url.startsWith("/")) return url; const relative = url.replace(/^\/+/, ""); // The `relative` segment is admin-configurable so its concrete value is // unknown at build time; without the hint Turbopack traces the entire // public/ tree (~400k files) into the route's NFT. return path.join(/*turbopackIgnore: true*/ process.cwd(), "public", relative); } async function getFurnitureDataWritePaths(): Promise { const primary = await getFurnitureDataPath(); const configuredMirror = (await siteSettings.get("furni_data_mirror_path", "")) ?? ""; const nitroFilesRoot = await getNitroFilesRoot(); const mirror = configuredMirror.trim() ? configuredMirror : nitroFilesRoot ? path.join(/*turbopackIgnore: true*/ nitroFilesRoot, "nitro-assets/gamedata/FurnitureData.json") : ""; const paths = [primary, mirror].filter(Boolean).map((p) => path.normalize(p)); const seen = new Set(); return paths.filter((p) => { const key = process.platform === "win32" ? p.toLowerCase() : p; if (seen.has(key)) return false; seen.add(key); return true; }); } async function getFurnitureDataReadPath(): Promise { const paths = await getFurnitureDataWritePaths(); const existing = paths.find((p) => existsSync(/*turbopackIgnore: true*/ p)); return existing ?? paths[0]; } // ── FurnitureData.json lock + helpers ──────────────────────────────── let furniDataLock: Promise = Promise.resolve(); async function acquireDiskLock(lockPath: string): Promise { const deadline = Date.now() + 30_000; while (true) { try { const fh = await fs.open(lockPath, "wx"); await fh.writeFile(String(process.pid)); await fh.close(); return; } catch (err) { const e = err as NodeJS.ErrnoException; if (e.code !== "EEXIST") throw err; try { const stat = await fs.stat(lockPath); if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) { await fs.unlink(lockPath).catch(() => {}); continue; } } catch { /* lock vanished between checks */ } if (Date.now() > deadline) throw new Error("FurnitureData lock timeout"); await new Promise((r) => setTimeout(r, 100)); } } } export async function withFurniDataLock(fn: () => Promise): Promise { let release: () => void; const acquired = new Promise((r) => { release = r; }); const prev = furniDataLock; furniDataLock = acquired; await prev; const lockBase = await getFurnitureDataReadPath(); await fs.mkdir(path.dirname(lockBase), { recursive: true }); const lockPath = `${lockBase}.lock`; await acquireDiskLock(lockPath); try { return await fn(); } finally { await fs.unlink(lockPath).catch(() => {}); release!(); } } export async function readFurniData(): Promise> { const target = await getFurnitureDataReadPath(); const raw = await fs.readFile(/*turbopackIgnore: true*/ target, "utf-8"); return JSON.parse(raw); } export async function writeFurniData(data: Record): Promise { const targets = await getFurnitureDataWritePaths(); const payload = JSON.stringify(data); for (const target of targets) { await fs.mkdir(path.dirname(target), { recursive: true }); const tmp = `${target}.tmp`; await fs.writeFile(/*turbopackIgnore: true*/ tmp, payload, "utf-8"); await fs.rename(/*turbopackIgnore: true*/ tmp, target); } } export function buildFurniEntry(params: { id: number; classname: string; revision: number; category: string; name: string; description: string; spriteId: number; dims: { x: number; y: number; z: number }; metadata: FurniMetadata | null; habboIt?: HabboItFurniEntry | null; }): Record { const { id, classname, revision, category, name, description, spriteId, dims, metadata, habboIt } = params; // Merge partcolors: prefer habbo.it (has real hex colors), fallback to .nitro extraction const partcolors = habboIt?.partcolors?.color?.length ? habboIt.partcolors : (metadata?.partcolors ?? { color: [] }); return { id, classname, revision: revision || habboIt?.revision || 0, category: category || habboIt?.category || "unknown", defaultdir: metadata?.defaultdir ?? habboIt?.defaultdir ?? 0, xdim: dims.x, ydim: dims.y, partcolors, name, description: description || "", adurl: "", offerid: spriteId, buyout: !!habboIt?.bc, rentofferid: -1, rentbuyout: false, bc: habboIt?.bc ?? false, excludeddynamic: false, customparams: habboIt?.customparams ?? "", specialtype: metadata?.specialtype ?? habboIt?.specialtype ?? 0, canstandon: metadata?.canstandon ?? habboIt?.canstandon ?? false, cansiton: metadata?.cansiton ?? habboIt?.cansiton ?? false, canlayon: metadata?.canlayon ?? habboIt?.canlayon ?? false, furniline: habboIt?.furniline ?? "", environment: habboIt?.environment ?? "", rare: habboIt?.rare ?? false, }; } function upsertEntryInSection( section: { furnitype: Array> }, entry: Record, ) { const classname = entry.classname as string | undefined; if (classname) { const idx = section.furnitype.findIndex((e) => e.classname === classname); if (idx >= 0) { section.furnitype[idx] = entry; return; } } section.furnitype.push(entry); } export async function appendFurniEntry(entry: Record, itemType: string): Promise { await withFurniDataLock(async () => { const furniData = (await readFurniData()) as Record< string, { furnitype: Array> } >; if (itemType === "i") { if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] }; upsertEntryInSection(furniData.wallitemtypes, entry); } else { if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] }; upsertEntryInSection(furniData.roomitemtypes, entry); } await writeFurniData(furniData as Record); }); } export async function removeFurniEntry(classname: string): Promise { return withFurniDataLock(async () => { const furniData = (await readFurniData()) as Record< string, { furnitype: Array> } >; let removed = false; for (const section of ["roomitemtypes", "wallitemtypes"] as const) { if (furniData[section]?.furnitype) { const before = furniData[section].furnitype.length; furniData[section].furnitype = furniData[section].furnitype.filter((e) => e.classname !== classname); if (furniData[section].furnitype.length < before) removed = true; } } if (removed) { await writeFurniData(furniData as Record); } return removed; }); } export async function appendFurniEntriesBatch( entries: Array<{ entry: Record; itemType: string }>, ): Promise { await withFurniDataLock(async () => { const furniData = (await readFurniData()) as Record; for (const { entry, itemType } of entries) { if (itemType === "i") { if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] }; upsertEntryInSection(furniData.wallitemtypes as { furnitype: Array> }, entry); } else { if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] }; upsertEntryInSection(furniData.roomitemtypes as { furnitype: Array> }, entry); } } await writeFurniData(furniData as Record); }); }