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) }) }