import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { getGamedataRoot, getNitroFilesRoot, } from "@/lib/services/furni-asset-dirs"; import type { OfficialHabboFurniEntry } 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; const DEFAULT_FURNI_DATA_URL = "/gamedata/config/FurnitureData.json"; /** * Resolve the FurnitureData file path from the admin setting * `furni_data_url`. The production `/gamedata` mount maps to the configured * Gamedata filesystem root; other relative URLs map under public/. */ export async function getFurnitureDataPath(): Promise { const url = (await siteSettings.get("furni_data_url", DEFAULT_FURNI_DATA_URL)) ?? DEFAULT_FURNI_DATA_URL; if (path.isAbsolute(url) && !url.startsWith("/")) return url; if (url === "/gamedata" || url.startsWith("/gamedata/")) { const gamedataRoot = await getGamedataRoot(); if (gamedataRoot) { const relative = url.replace(/^\/gamedata\/?/, ""); return path.join(/*turbopackIgnore: true*/ gamedataRoot, relative); } } 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); } export async function getFurnitureDataWritePaths(): Promise { const primary = await getFurnitureDataPath(); const configuredMirror = (await siteSettings.get("furni_data_mirror_path", "")) ?? ""; const [nitroFilesRoot, gamedataRoot] = await Promise.all([ getNitroFilesRoot(), getGamedataRoot(), ]); const mirror = configuredMirror.trim() ? configuredMirror : nitroFilesRoot ? path.join( /*turbopackIgnore: true*/ nitroFilesRoot, "nitro-assets/gamedata/FurnitureData.json", ) : ""; const gamedataPath = gamedataRoot ? path.join( /*turbopackIgnore: true*/ gamedataRoot, "config/FurnitureData.json", ) : ""; const paths = [primary, mirror, gamedataPath] .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)); } } } /** Build a map of sprite id → classname from both sections of the furnidata. * If multiple entries share the same id, the first encountered classname wins. */ function furniDataIdOwners( data: Record> }>, ): Map { const owners = new Map(); for (const section of ["roomitemtypes", "wallitemtypes"] as const) { for (const e of data[section]?.furnitype ?? []) { const id = Number(e?.id); if (!Number.isFinite(id) || id <= 0) continue; const classname = typeof e?.classname === "string" ? e.classname : ""; if (!classname) continue; if (!owners.has(id)) owners.set(id, classname); } } return owners; } /** Assert that no entry in `entries` has a spriteId already used by a different classname * in the existing furnidata or within this batch. Throws if a conflict is found. */ function assertNoFurniDataIdConflicts( existingOwners: Map, entries: Array<{ entry: Record; itemType: string }>, ): void { const conflicts: string[] = []; for (const { entry } of entries) { const id = Number(entry?.id); if (!Number.isFinite(id) || id <= 0) continue; const classname = typeof entry?.classname === "string" ? entry.classname : ""; const existing = existingOwners.get(id); if (existing !== undefined && existing !== classname) { conflicts.push( `spriteId ${id} already used by "${existing}" in FurnitureData.json ` + `(attempted "${classname}")`, ); continue; } // reserve the id within this batch so a second entry with the same id + different classname // is also caught (even though the file hasn't been written yet). existingOwners.set(id, classname); } if (conflicts.length > 0) { throw new Error( `FurnitureData spriteId conflicts: ${conflicts.join("; ")}`, ); } } /** Check if a spriteId is already used by a different classname in the local * FurnitureData.json. Returns the existing classname if there's a conflict, * or null if the spriteId is free or the file couldn't be read. */ export async function findFurniDataIdConflict( spriteId: number, classname: string, ): Promise { try { const furniData = await readFurniData(); const owners = furniDataIdOwners( furniData as Record< string, { furnitype: Array> } >, ); const existing = owners.get(spriteId); return existing !== undefined && existing !== classname ? existing : null; } catch { return null; // if file missing/unreadable, treat as no conflict } } 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; officialHabbo?: OfficialHabboFurniEntry | null; }): Record { const { id, classname, revision, category, name, description, spriteId, dims, metadata, officialHabbo, } = params; // Prefer the configured official hotel's real hex colors, then .nitro metadata. const partcolors = officialHabbo?.partcolors?.color?.length ? officialHabbo.partcolors : (metadata?.partcolors ?? { color: [] }); return { id, classname, revision: revision || officialHabbo?.revision || 0, category: category || officialHabbo?.category || "unknown", defaultdir: metadata?.defaultdir ?? officialHabbo?.defaultdir ?? 0, xdim: dims.x, ydim: dims.y, partcolors, name, description: description || "", adurl: "", offerid: spriteId, buyout: !!officialHabbo?.bc, rentofferid: -1, rentbuyout: false, bc: officialHabbo?.bc ?? false, excludeddynamic: false, customparams: officialHabbo?.customparams ?? "", specialtype: metadata?.specialtype ?? officialHabbo?.specialtype ?? 0, canstandon: metadata?.canstandon ?? officialHabbo?.canstandon ?? false, cansiton: metadata?.cansiton ?? officialHabbo?.cansiton ?? false, canlayon: metadata?.canlayon ?? officialHabbo?.canlayon ?? false, furniline: officialHabbo?.furniline ?? "", environment: officialHabbo?.environment ?? "", rare: officialHabbo?.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> } >; // Guard: ensure the spriteId is not already used by a different classname in the file. const existingOwners = furniDataIdOwners(furniData); assertNoFurniDataIdConflicts(existingOwners, [{ entry, itemType }]); 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[] } >; // Guard: ensure no spriteId conflicts with existing file entries // or within this batch (same id + different classname). const existingOwners = furniDataIdOwners( furniData as Record< string, { furnitype: Array> } >, ); assertNoFurniDataIdConflicts(existingOwners, entries); 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); }); }