264 lines
8.5 KiB
TypeScript
264 lines
8.5 KiB
TypeScript
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<string> {
|
|
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<string[]> {
|
|
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<string>()
|
|
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<string> {
|
|
const paths = await getFurnitureDataWritePaths()
|
|
const existing = paths.find((p) => existsSync(/*turbopackIgnore: true*/ p))
|
|
return existing ?? paths[0]
|
|
}
|
|
|
|
// ── FurnitureData.json lock + helpers ────────────────────────────────
|
|
|
|
let furniDataLock: Promise<void> = Promise.resolve()
|
|
|
|
async function acquireDiskLock(lockPath: string): Promise<void> {
|
|
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<T>(fn: () => Promise<T>): Promise<T> {
|
|
let release: () => void
|
|
const acquired = new Promise<void>((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<Record<string, unknown>> {
|
|
const target = await getFurnitureDataReadPath()
|
|
const raw = await fs.readFile(/*turbopackIgnore: true*/ target, 'utf-8')
|
|
return JSON.parse(raw)
|
|
}
|
|
|
|
export async function writeFurniData(data: Record<string, unknown>): Promise<void> {
|
|
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<string, unknown> {
|
|
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<Record<string, unknown>> },
|
|
entry: Record<string, unknown>,
|
|
) {
|
|
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<string, unknown>,
|
|
itemType: string,
|
|
): Promise<void> {
|
|
await withFurniDataLock(async () => {
|
|
const furniData = (await readFurniData()) as Record<
|
|
string,
|
|
{ furnitype: Array<Record<string, unknown>> }
|
|
>
|
|
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<string, unknown>)
|
|
})
|
|
}
|
|
|
|
export async function removeFurniEntry(classname: string): Promise<boolean> {
|
|
return withFurniDataLock(async () => {
|
|
const furniData = (await readFurniData()) as Record<
|
|
string,
|
|
{ furnitype: Array<Record<string, unknown>> }
|
|
>
|
|
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<string, unknown>)
|
|
}
|
|
return removed
|
|
})
|
|
}
|
|
|
|
export async function appendFurniEntriesBatch(
|
|
entries: Array<{ entry: Record<string, unknown>; itemType: string }>,
|
|
): Promise<void> {
|
|
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<Record<string, unknown>> },
|
|
entry,
|
|
)
|
|
} else {
|
|
if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] }
|
|
upsertEntryInSection(
|
|
furniData.roomitemtypes as { furnitype: Array<Record<string, unknown>> },
|
|
entry,
|
|
)
|
|
}
|
|
}
|
|
await writeFurniData(furniData as Record<string, unknown>)
|
|
})
|
|
}
|