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; }); } /** * Patch name/description on existing FurnitureData entries by classname. * Optionally creates a minimal entry when missing. */ export async function patchFurniEntryNames( updates: Array<{ classname: string; itemType: string; name?: string; description?: string; spriteId?: number; createIfMissing?: boolean; }>, ): Promise<{ updated: number; inserted: number }> { if (updates.length === 0) return { updated: 0, inserted: 0 }; return withFurniDataLock(async () => { const furniData = (await readFurniData()) as Record< string, { furnitype: Array> } >; let updated = 0; let inserted = 0; for (const u of updates) { const sectionKey = u.itemType === "i" ? "wallitemtypes" : "roomitemtypes"; if (!furniData[sectionKey]) furniData[sectionKey] = { furnitype: [] }; const section = furniData[sectionKey]; const idx = section.furnitype.findIndex( (e) => e.classname === u.classname, ); if (idx >= 0) { const entry = { ...section.furnitype[idx] }; if (u.name !== undefined) entry.name = u.name; if (u.description !== undefined) entry.description = u.description; section.furnitype[idx] = entry; updated++; } else if (u.createIfMissing) { section.furnitype.push({ id: u.spriteId ?? 0, classname: u.classname, revision: 0, category: "unknown", name: u.name ?? u.classname, description: u.description ?? "", offerid: u.spriteId ?? -1, }); inserted++; } } if (updated > 0 || inserted > 0) { await writeFurniData(furniData as Record); } return { updated, inserted }; }); } export async function appendFurniEntriesBatch( entries: Array<{ entry: Record; itemType: string }>, ): Promise { await withFurniDataLock(async () => { const furniData = (await readFurniData()) as Record< string, { furnitype: unknown[] } >; 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); }); }