import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { eq } from "drizzle-orm"; import { CatalogItems, db, ItemsBase } from "@/lib/db"; 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); }); } export interface FurniDataReconcileResult { /** Entries whose stale spriteId was corrected to the DB id. */ fixedIds: number; /** Entries whose stale offerid was corrected to the DB id. */ fixedOfferIds: number; /** Cross-section copies (same classname in both sections) whose stale id was aligned to their offerid. */ normalizedIds: number; /** items_base rows with no matching furnitype entry (need re-import). */ missing: number; /** spriteIds used by a different classname in the file; left untouched. */ conflicts: number; } /** * Cross-check every items_base row against the local FurnitureData.json and * repair drift: * - an entry matching the classname but carrying a stale spriteId is updated * to the DB id (items_base is authoritative); * - the same is done for a stale `offerid`, which must equal the spriteId so * the emulator's catalog offer lookup stays in sync with the local ids; * - rows missing entirely from the file are counted (they need a re-import * or batch-regen, since building an entry requires the SWF/nitro metadata); * - genuine spriteId conflicts (id used by a different classname) are counted * but never auto-fixed. */ export async function reconcileFurniDataWithItemsBase(): Promise { return withFurniDataLock(async () => { const rows = await db .select({ id: ItemsBase.id, itemName: ItemsBase.itemName }) .from(ItemsBase); const furniData = (await readFurniData()) as Record< string, { furnitype: Array> } >; const byId = new Map< number, { section: string; index: number; entry: Record } >(); const byClass = new Map< string, { section: string; index: number; entry: Record } >(); for (const section of ["roomitemtypes", "wallitemtypes"] as const) { const arr = furniData[section]?.furnitype ?? []; for (let i = 0; i < arr.length; i++) { const e = arr[i]; const id = Number(e?.id); if (Number.isFinite(id) && id > 0 && !byId.has(id)) { byId.set(id, { section, index: i, entry: e }); } const cn = typeof e?.classname === "string" ? e.classname : ""; if (cn && !byClass.has(cn)) { byClass.set(cn, { section, index: i, entry: e }); } } } let fixedIds = 0; let fixedOfferIds = 0; let normalizedIds = 0; let missing = 0; let conflicts = 0; const entryOfferId = (entry: Record): number => { const v = Number(entry.offerid); return Number.isFinite(v) ? v : -1; }; for (const row of rows) { const spriteId = row.id; const existing = byId.get(spriteId); if (existing) { if (existing.entry.classname !== row.itemName) { conflicts++; } else if (entryOfferId(existing.entry) !== spriteId) { existing.entry.offerid = spriteId; fixedOfferIds++; } continue; } const byName = byClass.get(row.itemName); if (byName) { byName.entry.id = spriteId; if (entryOfferId(byName.entry) !== spriteId) { byName.entry.offerid = spriteId; } byId.set(spriteId, byName); fixedIds++; } else { missing++; } } // Normalize cross-section duplicates (e.g. a wall item listed in both // roomitemtypes and wallitemtypes). The canonical copy already has the // correct id/offerid; a stray copy may carry a legacy wrong `id` while // its offerid still points at the right sprite. Only fix when the // offerid maps to an items_base row whose classname matches this entry // exactly, so we never guess. const dbById = new Map(); for (const row of rows) dbById.set(row.id, row.itemName); for (const section of ["roomitemtypes", "wallitemtypes"] as const) { for (const e of furniData[section]?.furnitype ?? []) { const offer = entryOfferId(e); if (offer <= 0 || Number(e.id) === offer) continue; if (dbById.get(offer) !== e.classname) continue; e.id = offer; normalizedIds++; } } if (fixedIds > 0 || fixedOfferIds > 0) { await writeFurniData(furniData as Record); } return { fixedIds, fixedOfferIds, normalizedIds, missing, conflicts }; }); } export interface CatalogOfferRebuildResult { /** catalog_items rows examined. */ checked: number; /** Rows whose offer_id was corrected. */ fixed: number; /** Fixed rows with no furnidata entry (offer_id fell back to the sprite id). */ fallback: number; } /** * Rebuild EVERY catalog_items.offer_id from the local FurnitureData.json. * * The Nitro client resolves offers through the furnidata `offerid`, and the * emulator maps that offer id back to a catalog item (CatalogManager * offerDefs). The chain only works when catalog_items.offer_id equals the * furnidata offerid — which this CMS reconciles to the items_base sprite id. * * For every catalog row: resolve item_ids → items_base classname → furnidata * offerid and write it into offer_id. Rows without a furnidata entry fall * back to the sprite id itself (same value in practice). Run after every * import so drift from manual edits or legacy rows is repaired automatically. */ export async function rebuildCatalogOfferIds(): Promise { return withFurniDataLock(async () => { const furniData = (await readFurniData()) as Record< string, { furnitype?: Array> } >; const offerById = new Map(); const offerByClass = new Map(); for (const section of ["roomitemtypes", "wallitemtypes"] as const) { for (const e of furniData[section]?.furnitype ?? []) { const cn = typeof e?.classname === "string" ? e.classname : ""; const entryId = Number(e?.id); const offer = Number(e?.offerid); if ( cn && Number.isFinite(offer) && offer > 0 && !offerByClass.has(cn) ) { offerByClass.set(cn, offer); } if ( Number.isFinite(entryId) && entryId > 0 && !offerById.has(entryId) ) { offerById.set( entryId, Number.isFinite(offer) && offer > 0 ? offer : entryId, ); } } } const itemRows = await db .select({ id: ItemsBase.id, itemName: ItemsBase.itemName }) .from(ItemsBase); const nameById = new Map(); for (const r of itemRows) nameById.set(r.id, r.itemName); const catalogRows = await db .select({ id: CatalogItems.id, itemIds: CatalogItems.itemIds, offerId: CatalogItems.offerId, }) .from(CatalogItems); let fixed = 0; let fallback = 0; for (const row of catalogRows) { const itemId = Number(String(row.itemIds ?? "").split(/[;,]/)[0]); if (!Number.isFinite(itemId) || itemId <= 0) continue; const classname = nameById.get(itemId); const furnidataOffer = offerById.get(itemId) ?? (classname ? offerByClass.get(classname) : undefined); const target = furnidataOffer ?? itemId; if (row.offerId === target) continue; await db .update(CatalogItems) .set({ offerId: target }) .where(eq(CatalogItems.id, row.id)); fixed++; if (furnidataOffer === undefined) fallback++; } return { checked: catalogRows.length, fixed, fallback }; }); }